Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,11 @@ Beyond statement shapes, the grammar handles nested sub-selects, bind parameters
array-literal ambiguity. The complete reference is on the
[syntax page](https://jsqlparser.github.io/JSqlParser/syntax.html).

PostgreSQL dollar-quoted strings, including `$tag$…$tag$`, retain their delimiter and
literal body in `StringValue`. Tagged quotes are disabled by default to preserve
identifier parsing. Enable them with `parser.withDialect(Dialect.POSTGRESQL)` or
`parser.withDollarQuotedStringTags(true)`. Untagged `$$…$$` literals remain enabled.

## Statement classification

Any parsed statement can say what it actually does — no second parse, no visitor to write:
Expand Down
41 changes: 37 additions & 4 deletions src/main/java/net/sf/jsqlparser/expression/StringValue.java
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,14 @@ public StringValue(String escapedValue) {
value = escapedValue.substring(1, escapedValue.length() - 1);
quoteStr = "\"";
return;
} else if (escapedValue.length() >= 4 && escapedValue.startsWith("$$")
&& escapedValue.endsWith("$$")) {
value = escapedValue.substring(2, escapedValue.length() - 2);
quoteStr = "$$";
}

String delimiter = getDollarQuoteDelimiter(escapedValue);
if (delimiter != null && escapedValue.length() >= 2 * delimiter.length()
&& escapedValue.endsWith(delimiter)) {
quoteStr = delimiter;
value = escapedValue.substring(delimiter.length(),
escapedValue.length() - delimiter.length());
return;
}

Expand All @@ -64,6 +68,32 @@ public StringValue(String escapedValue) {
value = escapedValue;
}

/**
* Returns the opening PostgreSQL dollar-quote delimiter, or null if there is none. A tag
* follows unquoted identifier rules, excluding dollar signs. This method does not require the
* closing delimiter or inspect the body.
*/
public static String getDollarQuoteDelimiter(String text) {
if (text == null || text.length() < 2 || text.charAt(0) != '$') {
return null;
}
int end = text.indexOf('$', 1);
if (end < 0) {
return null;
}
for (int i = 1; i < end;) {
int character = text.codePointAt(i);
boolean valid =
i == 1 ? Character.isUnicodeIdentifierStart(character) || character == '_'
: Character.isUnicodeIdentifierPart(character);
if (!valid) {
return null;
}
i += Character.charCount(character);
}
return text.substring(0, end + 1);
}

public String getValue() {
return value;
}
Expand All @@ -90,6 +120,9 @@ public StringValue setQuoteStr(String quoteStr) {
}

public String getNotExcapedValue() {
if (quoteStr != null && quoteStr.startsWith("$")) {
return value;
}
StringBuilder buffer = new StringBuilder(value);
int index = 0;
int deletesNum = 0;
Expand Down
11 changes: 10 additions & 1 deletion src/main/java/net/sf/jsqlparser/parser/AbstractJSqlParser.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ public enum Dialect {
Feature.allowHashLineComments,
Feature.allowDoubleQuotedStrings), SQLSERVER(AdjacentStringLiterals.OFF,
Feature.allowSquareBracketQuotation), POSTGRESQL(
AdjacentStringLiterals.NEWLINE), H2, EXASOL, BIGQUERY(
AdjacentStringLiterals.NEWLINE,
Feature.allowDollarQuotedStringTags), H2, EXASOL, BIGQUERY(
AdjacentStringLiterals.WHITESPACE,
Feature.allowDoubleQuotedStrings,
Feature.allowHashLineComments,
Expand Down Expand Up @@ -143,6 +144,14 @@ public P withBackslashEscapeCharacter(boolean allowBackslashEscapeCharacter) {
return withFeature(Feature.allowBackslashEscapeCharacter, allowBackslashEscapeCharacter);
}

/**
* Controls tagged dollar quotes; disabled by default, enabled by the PostgreSQL dialect preset.
* False preserves dollar-containing identifier spellings.
*/
public P withDollarQuotedStringTags(boolean allowDollarQuotedStringTags) {
return withFeature(Feature.allowDollarQuotedStringTags, allowDollarQuotedStringTags);
}

public P withDoubleQuotedStrings() {
return withFeature(Feature.allowDoubleQuotedStrings, true);
}
Expand Down
6 changes: 6 additions & 0 deletions src/main/java/net/sf/jsqlparser/parser/feature/Feature.java
Original file line number Diff line number Diff line change
Expand Up @@ -808,6 +808,12 @@ public enum Feature {
*/
allowDoubleQuotedStrings(false),

/**
* Recognizes PostgreSQL $tag$...$tag$ literals; disabled by default to preserve unquoted
* identifiers, enabled by the PostgreSQL dialect preset. Untagged $$ literals are unaffected.
*/
allowDollarQuotedStringTags(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,
Expand Down
63 changes: 36 additions & 27 deletions src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt
Original file line number Diff line number Diff line change
Expand Up @@ -1664,40 +1664,44 @@ TOKEN_MGR_DECLS : {
return -1;
}

private static boolean endsWithDelimiter(Deque<Character> windowQueue, String delimiter) {
if (windowQueue.size() != delimiter.length()) {
return false;
}

int i = 0;
for (char ch : windowQueue) {
if (ch != delimiter.charAt(i++)) {
return false;
/** Scans a literal in linear time without tokenizing or rebuilding its whitespace. */
public void consumeDollarQuotedString(String closingQuote) {
int[] prefix = new int[closingQuote.length()];
for (int i = 1, matched = 0; i < closingQuote.length(); i++) {
while (matched > 0 && closingQuote.charAt(i) != closingQuote.charAt(matched)) {
matched = prefix[matched - 1];
}
if (closingQuote.charAt(i) == closingQuote.charAt(matched)) {
matched++;
}
prefix[i] = matched;
}
return true;
}

public void consumeDollarQuotedString(String closingQuote) {
Deque<Character> windowQueue = new ArrayDeque<Character>();
int delimiterLength = closingQuote.length();

try {
while (true) {
int matched = 0;
while (matched < closingQuote.length()) {
char ch = input_stream.readChar();
windowQueue.addLast(ch);
if (windowQueue.size() > delimiterLength) {
windowQueue.removeFirst();
while (matched > 0 && ch != closingQuote.charAt(matched)) {
matched = prefix[matched - 1];
}
if (endsWithDelimiter(windowQueue, closingQuote)) {
return;
if (ch == closingQuote.charAt(matched)) {
matched++;
}
}
} catch (java.io.IOException e) {
reportError(Math.max(closingQuote.length(), input_stream.GetImage().length()));
}
}

/** Rewinds any identifier suffix consumed by longest-match lexing before scanning the body. */
private void consumeDollarQuotedToken(Token token, String delimiter) {
input_stream.backup(token.image.length() - delimiter.length());
consumeDollarQuotedString(delimiter);
token.image = input_stream.GetImage();
token.kind = charLiteralIndex;
token.endLine = input_stream.getEndLine();
token.endColumn = input_stream.getEndColumn();
}

/**
* Consumes the body of a block comment after the opening delimiter has been matched,
* honouring nesting, up to and including the outermost closing delimiter. Then backs
Expand Down Expand Up @@ -2407,9 +2411,7 @@ TOKEN:
|
<S_DOLLAR_QUOTED_STRING: "$$">
{
consumeDollarQuotedString(matchedToken.image);
matchedToken.image = input_stream.GetImage();
matchedToken.kind = charLiteralIndex;
consumeDollarQuotedToken(matchedToken, matchedToken.image);
}
|
// Bare `#` as a binary operator (PostgreSQL bitwise XOR / geometric
Expand All @@ -2420,6 +2422,13 @@ TOKEN:
|
<S_IDENTIFIER: (<LETTER> (<PART_LETTER>)*) | "$" | ("$" <PART_LETTER_NO_DOLLAR> (<PART_LETTER>)*)>
{
if (matchedToken.image.charAt(0) == '$'
&& Boolean.TRUE.equals(configuration.getValue(Feature.allowDollarQuotedStringTags))) {
String delimiter = StringValue.getDollarQuoteDelimiter(matchedToken.image);
if (delimiter != null) {
consumeDollarQuotedToken(matchedToken, delimiter);
}
}
// MySQL `#` line comments (#2499): under the flag an unquoted identifier
// ends at its first `#`, the rest of the line becomes a comment via the
// stream-level substitution (real MySQL reads `42#24` as `42` plus
Expand All @@ -2428,7 +2437,7 @@ TOKEN:
// that never opted in (the stream is only wired through the feature
// consumers / withConfiguration); getValue avoids the String-based
// getAsBoolean roundtrip
if (input_stream.featureConfiguration != null
if (matchedToken.kind == S_IDENTIFIER && input_stream.featureConfiguration != null
&& Boolean.TRUE.equals(configuration.getValue(Feature.allowHashLineComments))) {
int hashIndex = matchedToken.image.indexOf('#');
if (hashIndex > 0) {
Expand Down Expand Up @@ -16680,7 +16689,7 @@ List<String> captureFunctionBody() {
tokens.add(tok.image);
}
foundEnd |= (tok.kind == K_END)
|| ( tok.image.trim().startsWith("$$") && tok.image.trim().endsWith("$$")) ;
|| (tok.kind == S_CHAR_LITERAL && StringValue.getDollarQuoteDelimiter(tok.image) != null);

tok = getNextToken();
}
Expand Down
Loading