Skip to content

Commit 08a837a

Browse files
committed
Support PostgreSQL tagged dollar strings and preserve literal bodies
1 parent b2115ac commit 08a837a

7 files changed

Lines changed: 242 additions & 33 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,10 @@ Beyond statement shapes, the grammar handles nested sub-selects, bind parameters
148148
array-literal ambiguity. The complete reference is on the
149149
[syntax page](https://jsqlparser.github.io/JSqlParser/syntax.html).
150150

151+
PostgreSQL dollar-quoted strings, including `$tag$…$tag$`, retain their delimiter and
152+
literal body in `StringValue`. For dialects that use the same spelling as an unquoted
153+
identifier, `parser.withDollarQuotedStringTags(false)` retains identifier parsing.
154+
151155
## Statement classification
152156

153157
Any parsed statement can say what it actually does — no second parse, no visitor to write:

src/main/java/net/sf/jsqlparser/expression/StringValue.java

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,14 @@ public StringValue(String escapedValue) {
4242
value = escapedValue.substring(1, escapedValue.length() - 1);
4343
quoteStr = "\"";
4444
return;
45-
} else if (escapedValue.length() >= 4 && escapedValue.startsWith("$$")
46-
&& escapedValue.endsWith("$$")) {
47-
value = escapedValue.substring(2, escapedValue.length() - 2);
48-
quoteStr = "$$";
45+
}
46+
47+
String delimiter = getDollarQuoteDelimiter(escapedValue);
48+
if (delimiter != null && escapedValue.length() >= 2 * delimiter.length()
49+
&& escapedValue.endsWith(delimiter)) {
50+
quoteStr = delimiter;
51+
value = escapedValue.substring(delimiter.length(),
52+
escapedValue.length() - delimiter.length());
4953
return;
5054
}
5155

@@ -64,6 +68,32 @@ public StringValue(String escapedValue) {
6468
value = escapedValue;
6569
}
6670

71+
/**
72+
* Returns the opening PostgreSQL dollar-quote delimiter, or null if there is none. A tag
73+
* follows unquoted identifier rules, excluding dollar signs. This method does not require the
74+
* closing delimiter or inspect the body.
75+
*/
76+
public static String getDollarQuoteDelimiter(String text) {
77+
if (text == null || text.length() < 2 || text.charAt(0) != '$') {
78+
return null;
79+
}
80+
int end = text.indexOf('$', 1);
81+
if (end < 0) {
82+
return null;
83+
}
84+
for (int i = 1; i < end;) {
85+
int character = text.codePointAt(i);
86+
boolean valid =
87+
i == 1 ? Character.isUnicodeIdentifierStart(character) || character == '_'
88+
: Character.isUnicodeIdentifierPart(character);
89+
if (!valid) {
90+
return null;
91+
}
92+
i += Character.charCount(character);
93+
}
94+
return text.substring(0, end + 1);
95+
}
96+
6797
public String getValue() {
6898
return value;
6999
}
@@ -90,6 +120,9 @@ public StringValue setQuoteStr(String quoteStr) {
90120
}
91121

92122
public String getNotExcapedValue() {
123+
if (quoteStr != null && quoteStr.startsWith("$")) {
124+
return value;
125+
}
93126
StringBuilder buffer = new StringBuilder(value);
94127
int index = 0;
95128
int deletesNum = 0;

src/main/java/net/sf/jsqlparser/parser/AbstractJSqlParser.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,11 @@ public P withBackslashEscapeCharacter(boolean allowBackslashEscapeCharacter) {
143143
return withFeature(Feature.allowBackslashEscapeCharacter, allowBackslashEscapeCharacter);
144144
}
145145

146+
/** Controls tagged dollar quotes; false preserves dollar-containing identifier spellings. */
147+
public P withDollarQuotedStringTags(boolean allowDollarQuotedStringTags) {
148+
return withFeature(Feature.allowDollarQuotedStringTags, allowDollarQuotedStringTags);
149+
}
150+
146151
public P withDoubleQuotedStrings() {
147152
return withFeature(Feature.allowDoubleQuotedStrings, true);
148153
}

src/main/java/net/sf/jsqlparser/parser/feature/Feature.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -808,6 +808,12 @@ public enum Feature {
808808
*/
809809
allowDoubleQuotedStrings(false),
810810

811+
/**
812+
* Recognizes PostgreSQL $tag$...$tag$ literals. Disable for dialects where these spellings are
813+
* unquoted identifiers. Untagged $$ literals are unaffected.
814+
*/
815+
allowDollarQuotedStringTags(true),
816+
811817
/**
812818
* concatenates adjacent String Literals: NEWLINE when separated by whitespace with at least one
813819
* newline (the SQL standard and PostgreSQL), WHITESPACE across any whitespace (GoogleSQL,

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

Lines changed: 36 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1664,40 +1664,44 @@ TOKEN_MGR_DECLS : {
16641664
return -1;
16651665
}
16661666

1667-
private static boolean endsWithDelimiter(Deque<Character> windowQueue, String delimiter) {
1668-
if (windowQueue.size() != delimiter.length()) {
1669-
return false;
1670-
}
1671-
1672-
int i = 0;
1673-
for (char ch : windowQueue) {
1674-
if (ch != delimiter.charAt(i++)) {
1675-
return false;
1667+
/** Scans a literal in linear time without tokenizing or rebuilding its whitespace. */
1668+
public void consumeDollarQuotedString(String closingQuote) {
1669+
int[] prefix = new int[closingQuote.length()];
1670+
for (int i = 1, matched = 0; i < closingQuote.length(); i++) {
1671+
while (matched > 0 && closingQuote.charAt(i) != closingQuote.charAt(matched)) {
1672+
matched = prefix[matched - 1];
16761673
}
1674+
if (closingQuote.charAt(i) == closingQuote.charAt(matched)) {
1675+
matched++;
1676+
}
1677+
prefix[i] = matched;
16771678
}
1678-
return true;
1679-
}
1680-
1681-
public void consumeDollarQuotedString(String closingQuote) {
1682-
Deque<Character> windowQueue = new ArrayDeque<Character>();
1683-
int delimiterLength = closingQuote.length();
1684-
16851679
try {
1686-
while (true) {
1680+
int matched = 0;
1681+
while (matched < closingQuote.length()) {
16871682
char ch = input_stream.readChar();
1688-
windowQueue.addLast(ch);
1689-
if (windowQueue.size() > delimiterLength) {
1690-
windowQueue.removeFirst();
1683+
while (matched > 0 && ch != closingQuote.charAt(matched)) {
1684+
matched = prefix[matched - 1];
16911685
}
1692-
if (endsWithDelimiter(windowQueue, closingQuote)) {
1693-
return;
1686+
if (ch == closingQuote.charAt(matched)) {
1687+
matched++;
16941688
}
16951689
}
16961690
} catch (java.io.IOException e) {
16971691
reportError(Math.max(closingQuote.length(), input_stream.GetImage().length()));
16981692
}
16991693
}
17001694

1695+
/** Rewinds any identifier suffix consumed by longest-match lexing before scanning the body. */
1696+
private void consumeDollarQuotedToken(Token token, String delimiter) {
1697+
input_stream.backup(token.image.length() - delimiter.length());
1698+
consumeDollarQuotedString(delimiter);
1699+
token.image = input_stream.GetImage();
1700+
token.kind = charLiteralIndex;
1701+
token.endLine = input_stream.getEndLine();
1702+
token.endColumn = input_stream.getEndColumn();
1703+
}
1704+
17011705
/**
17021706
* Consumes the body of a block comment after the opening delimiter has been matched,
17031707
* honouring nesting, up to and including the outermost closing delimiter. Then backs
@@ -2407,9 +2411,7 @@ TOKEN:
24072411
|
24082412
<S_DOLLAR_QUOTED_STRING: "$$">
24092413
{
2410-
consumeDollarQuotedString(matchedToken.image);
2411-
matchedToken.image = input_stream.GetImage();
2412-
matchedToken.kind = charLiteralIndex;
2414+
consumeDollarQuotedToken(matchedToken, matchedToken.image);
24132415
}
24142416
|
24152417
// Bare `#` as a binary operator (PostgreSQL bitwise XOR / geometric
@@ -2420,6 +2422,13 @@ TOKEN:
24202422
|
24212423
<S_IDENTIFIER: (<LETTER> (<PART_LETTER>)*) | "$" | ("$" <PART_LETTER_NO_DOLLAR> (<PART_LETTER>)*)>
24222424
{
2425+
if (matchedToken.image.charAt(0) == '$'
2426+
&& Boolean.TRUE.equals(configuration.getValue(Feature.allowDollarQuotedStringTags))) {
2427+
String delimiter = StringValue.getDollarQuoteDelimiter(matchedToken.image);
2428+
if (delimiter != null) {
2429+
consumeDollarQuotedToken(matchedToken, delimiter);
2430+
}
2431+
}
24232432
// MySQL `#` line comments (#2499): under the flag an unquoted identifier
24242433
// ends at its first `#`, the rest of the line becomes a comment via the
24252434
// stream-level substitution (real MySQL reads `42#24` as `42` plus
@@ -2428,7 +2437,7 @@ TOKEN:
24282437
// that never opted in (the stream is only wired through the feature
24292438
// consumers / withConfiguration); getValue avoids the String-based
24302439
// getAsBoolean roundtrip
2431-
if (input_stream.featureConfiguration != null
2440+
if (matchedToken.kind == S_IDENTIFIER && input_stream.featureConfiguration != null
24322441
&& Boolean.TRUE.equals(configuration.getValue(Feature.allowHashLineComments))) {
24332442
int hashIndex = matchedToken.image.indexOf('#');
24342443
if (hashIndex > 0) {
@@ -16680,7 +16689,7 @@ List<String> captureFunctionBody() {
1668016689
tokens.add(tok.image);
1668116690
}
1668216691
foundEnd |= (tok.kind == K_END)
16683-
|| ( tok.image.trim().startsWith("$$") && tok.image.trim().endsWith("$$")) ;
16692+
|| (tok.kind == S_CHAR_LITERAL && StringValue.getDollarQuoteDelimiter(tok.image) != null);
1668416693

1668516694
tok = getNextToken();
1668616695
}
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
/*-
2+
* #%L
3+
* JSQLParser library
4+
* %%
5+
* Copyright (C) 2004 - 2019 JSQLParser
6+
* %%
7+
* Dual licensed under GNU LGPL 2.1 or Apache License 2.0
8+
* #L%
9+
*/
10+
package net.sf.jsqlparser.expression;
11+
12+
import static org.junit.jupiter.api.Assertions.assertEquals;
13+
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
14+
import static org.junit.jupiter.api.Assertions.assertThrows;
15+
import java.io.StringReader;
16+
import java.nio.charset.StandardCharsets;
17+
import java.util.List;
18+
import java.util.stream.Stream;
19+
import net.sf.jsqlparser.JSQLParserException;
20+
import net.sf.jsqlparser.parser.CCJSqlParser;
21+
import net.sf.jsqlparser.parser.CCJSqlParserConstants;
22+
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
23+
import net.sf.jsqlparser.parser.Token;
24+
import net.sf.jsqlparser.schema.Column;
25+
import net.sf.jsqlparser.statement.Statements;
26+
import net.sf.jsqlparser.statement.select.PlainSelect;
27+
import net.sf.jsqlparser.test.TestUtils;
28+
import net.sf.jsqlparser.util.deparser.StatementDeParser;
29+
import org.junit.jupiter.api.Test;
30+
import org.junit.jupiter.api.Timeout;
31+
import org.junit.jupiter.params.ParameterizedTest;
32+
import org.junit.jupiter.params.provider.MethodSource;
33+
import org.junit.jupiter.params.provider.ValueSource;
34+
35+
class TaggedDollarStringTest {
36+
static Stream<String> tags() {
37+
return Stream.of("", "tag", "Tag_123", "_", "한글", "étiquette");
38+
}
39+
40+
@ParameterizedTest
41+
@MethodSource("tags")
42+
void preservesLiteralBodiesAndDelimiters(String tag) throws Exception {
43+
String delimiter = "$" + tag + "$";
44+
for (String body : List.of("", "abc", "a\nb\r\nc\t ", "x 'one' ''two'' \\ end",
45+
"/* comment */ -- more\n#hash", "[ {\"some\":\"json\",\"with\":\"properties$\"} ]",
46+
"$1 $other$ こんにちは")) {
47+
String literal = delimiter + body + delimiter;
48+
String sql = "SELECT " + literal + " AS value, 2 FROM t";
49+
PlainSelect select = (PlainSelect) TestUtils.assertSqlCanBeParsedAndDeparsed(sql);
50+
StringValue value =
51+
assertInstanceOf(StringValue.class, select.getSelectItem(0).getExpression());
52+
assertEquals(body, value.getValue());
53+
assertEquals(body, value.getNotExcapedValue());
54+
assertEquals(delimiter, value.getQuoteStr());
55+
assertEquals(literal, value.toString());
56+
StringBuilder builder = new StringBuilder();
57+
select.accept(new StatementDeParser(builder), null);
58+
PlainSelect again = (PlainSelect) CCJSqlParserUtil.parse(builder.toString());
59+
assertEquals(body, again.getSelectItem(0).getExpression(StringValue.class).getValue());
60+
assertEquals(select.toString(), builder.toString());
61+
}
62+
}
63+
64+
@Test
65+
void keepsDifferentTagsAndDollarSignsInsideBody() throws Exception {
66+
String body = "$other$ text $Tag$ $$ $1 $t";
67+
PlainSelect select =
68+
(PlainSelect) CCJSqlParserUtil.parse("SELECT $tag$" + body + "$tag$::text, $1");
69+
CastExpression cast = select.getSelectItem(0).getExpression(CastExpression.class);
70+
assertEquals(body, ((StringValue) cast.getLeftExpression()).getValue());
71+
assertInstanceOf(JdbcParameter.class, select.getSelectItem(1).getExpression());
72+
}
73+
74+
@Test
75+
void retainsIdentifiersAndSupportsOptOut() throws Exception {
76+
PlainSelect select = (PlainSelect) CCJSqlParserUtil
77+
.parse("SELECT $parameter, foo$bar, \"$tag$abc$tag$\", $1 FROM t");
78+
for (int i = 0; i < 3; i++) {
79+
assertInstanceOf(Column.class, select.getSelectItem(i).getExpression());
80+
}
81+
assertInstanceOf(JdbcParameter.class, select.getSelectItem(3).getExpression());
82+
for (String identifier : List.of("$tag$abc$tag$", "$tag$identifier")) {
83+
PlainSelect legacy = (PlainSelect) CCJSqlParserUtil.parse("SELECT " + identifier,
84+
parser -> parser.withDollarQuotedStringTags(false));
85+
assertEquals(identifier,
86+
legacy.getSelectItem(0).getExpression(Column.class).getColumnName());
87+
}
88+
PlainSelect untagged = (PlainSelect) CCJSqlParserUtil.parse("SELECT $$text$$",
89+
parser -> parser.withDollarQuotedStringTags(false));
90+
assertEquals("text", untagged.getSelectItem(0).getExpression(StringValue.class).getValue());
91+
}
92+
93+
@Test
94+
void retainsBodyWithOtherLexerOptions() throws Exception {
95+
PlainSelect select = (PlainSelect) CCJSqlParserUtil.parse(
96+
"SELECT $t$#hash\n\\text't$tag$ \"q\"$t$",
97+
parser -> parser
98+
.withDialect(net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect.MYSQL));
99+
assertEquals("#hash\n\\text't$tag$ \"q\"",
100+
select.getSelectItem(0).getExpression(StringValue.class).getValue());
101+
}
102+
103+
@Test
104+
void keepsLineColumnAndAbsoluteTokenPositions() {
105+
String literal = "$tag$a\nb$tag$";
106+
CCJSqlParser parser = CCJSqlParserUtil.newParser("SELECT " + literal + ", 2");
107+
parser.getNextToken();
108+
Token value = parser.getNextToken();
109+
Token comma = parser.getNextToken();
110+
assertEquals(CCJSqlParserConstants.S_CHAR_LITERAL, value.kind);
111+
assertEquals(literal, value.image);
112+
assertEquals(1, value.beginLine);
113+
assertEquals(8, value.beginColumn);
114+
assertEquals(2, value.endLine);
115+
assertEquals(6, value.endColumn);
116+
assertEquals(8, value.absoluteBegin);
117+
assertEquals(8 + literal.length(), value.absoluteEnd);
118+
assertEquals(value.absoluteEnd, comma.absoluteBegin);
119+
assertEquals(7, comma.beginColumn);
120+
}
121+
122+
@Test
123+
void recognizesFunctionBodyAndFollowingStatement() throws Exception {
124+
String body = "SELECT 'a;''b'::text;\n";
125+
String sql =
126+
"CREATE FUNCTION f() RETURNS text AS $fn$" + body + "$fn$ LANGUAGE SQL; SELECT 42;";
127+
Statements statements = CCJSqlParserUtil.parseStatements(sql);
128+
assertEquals(2, statements.size());
129+
assertEquals("SELECT 42", statements.get(1).toString());
130+
org.junit.jupiter.api.Assertions
131+
.assertTrue(statements.get(0).toString().contains("$fn$" + body + "$fn$"));
132+
assertEquals(2, CCJSqlParserUtil.parseStatements(statements.toString()).size());
133+
}
134+
135+
@Test
136+
@Timeout(10)
137+
void handlesLongBodiesAndOverlappingDelimiterPrefixes() throws Exception {
138+
String body = "$ta$tagX $tagtagX\n".repeat(12000);
139+
String sql = "SELECT $tagtag$" + body + "$tagtag$";
140+
PlainSelect select = (PlainSelect) CCJSqlParserUtil.parse(new StringReader(sql));
141+
assertEquals(body, select.getSelectItem(0).getExpression(StringValue.class).getValue());
142+
PlainSelect streamed = (PlainSelect) CCJSqlParserUtil.parse(
143+
new java.io.ByteArrayInputStream(sql.getBytes(StandardCharsets.UTF_8)), "UTF-8");
144+
assertEquals(body, streamed.getSelectItem(0).getExpression(StringValue.class).getValue());
145+
}
146+
147+
@ParameterizedTest
148+
@ValueSource(strings = {"SELECT $tag$missing", "SELECT $Tag$wrong$tag$", "SELECT $t$ends$t",
149+
"SELECT $a$text$b$", "SELECT $$missing"})
150+
void rejectsUnterminatedOrMismatchedTags(String sql) {
151+
assertThrows(JSQLParserException.class,
152+
() -> CCJSqlParserUtil.parse(sql, parser -> parser.withTimeOut(1000)));
153+
}
154+
}

src/test/java/net/sf/jsqlparser/statement/select/PostgresTest.java

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,6 @@ void testNextValueIssue1863() throws JSQLParserException {
108108
}
109109

110110
@Test
111-
@Disabled
112-
// wip
113111
void testDollarQuotedText() throws JSQLParserException {
114112
String sqlStr = "SELECT $tag$This\nis\na\nselect\ntest\n$tag$ from dual where a=b";
115113
PlainSelect st = (PlainSelect) CCJSqlParserUtil.parse(sqlStr);

0 commit comments

Comments
 (0)