Skip to content

Commit 5ae8b87

Browse files
committed
Split PostgreSQL hash operators from adjacent identifiers
1 parent eddb1fb commit 5ae8b87

3 files changed

Lines changed: 126 additions & 4 deletions

File tree

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

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1723,6 +1723,14 @@ TOKEN_MGR_DECLS : {
17231723
token.endColumn = input_stream.getEndColumn();
17241724
}
17251725

1726+
/** Splits a longest-match identifier without losing token positions. */
1727+
private void truncateIdentifierToken(Token token, int length) {
1728+
input_stream.backup(token.image.length() - length);
1729+
token.image = token.image.substring(0, length);
1730+
token.endLine = input_stream.getEndLine();
1731+
token.endColumn = input_stream.getEndColumn();
1732+
}
1733+
17261734
/**
17271735
* Consumes the body of a block comment after the opening delimiter has been matched,
17281736
* honouring nesting, up to and including the outermost closing delimiter. Then backs
@@ -2462,8 +2470,19 @@ TOKEN:
24622470
&& Boolean.TRUE.equals(configuration.getValue(Feature.allowHashLineComments))) {
24632471
int hashIndex = matchedToken.image.indexOf('#');
24642472
if (hashIndex > 0) {
2465-
input_stream.backup(matchedToken.image.length() - hashIndex);
2466-
matchedToken.image = matchedToken.image.substring(0, hashIndex);
2473+
truncateIdentifierToken(matchedToken, hashIndex);
2474+
}
2475+
}
2476+
// PostgreSQL does not allow # in unquoted identifiers. Re-lex it as an
2477+
// operator, including #> and #>>, even when it touches the left operand.
2478+
if (matchedToken.kind == S_IDENTIFIER
2479+
&& AbstractJSqlParser.Dialect.POSTGRESQL.name().equals(configuration.getValue(Feature.dialect))) {
2480+
int hashIndex = matchedToken.image.indexOf('#');
2481+
if (hashIndex > 0) {
2482+
truncateIdentifierToken(matchedToken, hashIndex);
2483+
} else if (hashIndex == 0) {
2484+
truncateIdentifierToken(matchedToken, 1);
2485+
matchedToken.kind = S_HASH_OPERATOR;
24672486
}
24682487
}
24692488
}
@@ -2484,8 +2503,7 @@ TOKEN:
24842503
&& Boolean.TRUE.equals(configuration.getValue(Feature.allowHashLineComments))) {
24852504
int hashIndex = matchedToken.image.indexOf('#');
24862505
if (hashIndex > 0) {
2487-
input_stream.backup(matchedToken.image.length() - hashIndex);
2488-
matchedToken.image = matchedToken.image.substring(0, hashIndex);
2506+
truncateIdentifierToken(matchedToken, hashIndex);
24892507
}
24902508
}
24912509
}

‎src/site/sphinx/usage.rst‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -716,6 +716,11 @@ One grammar covers every supported RDBMS, but a few pieces of syntax mean differ
716716

717717
Features set explicitly *after* the preset win over it.
718718

719+
With ``Dialect.POSTGRESQL``, ``#`` terminates an unquoted identifier, so JSON
720+
operators such as ``js#>>'{a}'`` and ``js#>'{a}'`` work without surrounding
721+
spaces. Quote identifiers containing ``#``, for example ``"js#"``. Other
722+
dialects retain their existing identifier and hash-comment rules.
723+
719724
With ``Dialect.SQLSERVER``, ``PRIMARY KEY NONCLUSTERED (id)`` and
720725
``UNIQUE CLUSTERED (id)`` store their clustering option in ``Index.getClustering()``
721726
for both ``CREATE TABLE`` and ``ALTER TABLE``. Without that dialect, these words
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/*-
2+
* #%L
3+
* JSQLParser library
4+
* %%
5+
* Copyright (C) 2004 - 2026 JSQLParser
6+
* %%
7+
* Dual licensed under GNU LGPL 2.1 or Apache License 2.0
8+
* #L%
9+
*/
10+
package net.sf.jsqlparser.parser;
11+
12+
import static org.junit.jupiter.api.Assertions.*;
13+
14+
import net.sf.jsqlparser.expression.JsonExpression;
15+
import net.sf.jsqlparser.expression.operators.arithmetic.BitwiseRightShift;
16+
import net.sf.jsqlparser.expression.operators.relational.Intersects;
17+
import net.sf.jsqlparser.expression.operators.relational.NotEqualsTo;
18+
import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect;
19+
import net.sf.jsqlparser.schema.Column;
20+
import net.sf.jsqlparser.statement.select.PlainSelect;
21+
import net.sf.jsqlparser.util.deparser.StatementDeParser;
22+
import org.junit.jupiter.api.Test;
23+
import org.junit.jupiter.params.ParameterizedTest;
24+
import org.junit.jupiter.params.provider.ValueSource;
25+
26+
class PostgreSqlHashOperatorTest {
27+
private static PlainSelect parse(String sql) throws Exception {
28+
return (PlainSelect) CCJSqlParserUtil.parse(sql, p -> p.withDialect(Dialect.POSTGRESQL));
29+
}
30+
31+
@ParameterizedTest
32+
@ValueSource(strings = {"js#>>'{a,b,1}'", "js #>> '{a,b,1}'", "js#>> '{a,b,1}'",
33+
"t.js#>>'{a,b,1}'", "\"js#\"#>>'{a,b,1}'", "js/*comment*/#>>'{a,b,1}'"})
34+
void preservesJsonPrecedenceWithOrWithoutSpacesIssue2163(String expression) throws Exception {
35+
PlainSelect select = parse("SELECT * FROM t WHERE " + expression + " <> 'bar'");
36+
NotEqualsTo condition = (NotEqualsTo) select.getWhere();
37+
assertInstanceOf(JsonExpression.class, condition.getLeftExpression());
38+
assertTrue(select.toString().contains(" #>> "));
39+
StringBuilder output = new StringBuilder();
40+
select.accept(new StatementDeParser(output));
41+
assertEquals(select.toString(), output.toString());
42+
assertEquals(select.toString(), parse(output.toString()).toString());
43+
}
44+
45+
@ParameterizedTest
46+
@ValueSource(strings = {"js#>'{a}'", "js#>'{a}'#>>'{b}'"})
47+
void recognizesOtherHashJsonOperators(String expression) throws Exception {
48+
PlainSelect select = parse("SELECT " + expression + " FROM t");
49+
assertInstanceOf(JsonExpression.class, select.getSelectItem(0).getExpression());
50+
assertEquals(select.toString(), parse(select.toString()).toString());
51+
}
52+
53+
@Test
54+
void splitsHashXorAndPreservesQuotedIdentifiersAndLiterals() throws Exception {
55+
PlainSelect select = parse("SELECT a#b, a#2, \"a#b\", '#>>', $$a#b$$ FROM t");
56+
assertInstanceOf(Intersects.class, select.getSelectItem(0).getExpression());
57+
assertInstanceOf(Intersects.class, select.getSelectItem(1).getExpression());
58+
assertEquals("\"a#b\"", ((Column) select.getSelectItem(2).getExpression()).getColumnName());
59+
assertEquals(select.toString(), parse(select.toString()).toString());
60+
}
61+
62+
@Test
63+
void leavesLegacyAndSqlServerNamesAndMysqlCommentsIntact() throws Exception {
64+
for (Dialect dialect : Dialect.values()) {
65+
if (dialect != Dialect.POSTGRESQL && dialect != Dialect.MYSQL
66+
&& dialect != Dialect.MARIADB && dialect != Dialect.BIGQUERY) {
67+
PlainSelect select = (PlainSelect) CCJSqlParserUtil.parse("SELECT a#b FROM #temp",
68+
p -> p.withDialect(dialect));
69+
assertEquals("a#b",
70+
((Column) select.getSelectItem(0).getExpression()).getColumnName());
71+
}
72+
}
73+
PlainSelect legacy = (PlainSelect) CCJSqlParserUtil.parse("SELECT js#>>'{a}' FROM t");
74+
assertInstanceOf(BitwiseRightShift.class, legacy.getSelectItem(0).getExpression());
75+
PlainSelect mysql = (PlainSelect) CCJSqlParserUtil.parse("SELECT a#comment\nFROM t",
76+
p -> p.withDialect(Dialect.MYSQL));
77+
assertEquals("SELECT a FROM t", mysql.toString());
78+
}
79+
80+
@Test
81+
void keepsTokenOffsetsAfterSplittingAndAcrossStatements() throws Exception {
82+
CCJSqlParser parser = CCJSqlParserUtil.newParser("js#>>'{}'")
83+
.withDialect(Dialect.POSTGRESQL);
84+
Token name = parser.getNextToken();
85+
Token operator = parser.getNextToken();
86+
assertEquals("js", name.image);
87+
assertEquals(1, name.beginColumn);
88+
assertEquals(2, name.endColumn);
89+
assertEquals(1, name.absoluteBegin);
90+
assertEquals(3, name.absoluteEnd);
91+
assertEquals("#>>", operator.image);
92+
assertEquals(3, operator.beginColumn);
93+
assertEquals(5, operator.endColumn);
94+
assertEquals(3, operator.absoluteBegin);
95+
assertEquals(6, operator.absoluteEnd);
96+
assertEquals(2, CCJSqlParserUtil.parseStatements("SELECT js#>>'{}' FROM t; SELECT 1;",
97+
p -> p.withDialect(Dialect.POSTGRESQL)).size());
98+
}
99+
}

0 commit comments

Comments
 (0)