Skip to content

Commit 7f91819

Browse files
authored
fix: parse PostgreSQL JSON path deletion and existence operators (#2664)
Signed-off-by: minleejae <mmj9808@gmail.com>
1 parent 5004ab4 commit 7f91819

2 files changed

Lines changed: 209 additions & 11 deletions

File tree

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

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -590,17 +590,27 @@ public class CCJSqlParser extends AbstractJSqlParser<CCJSqlParser> {
590590
return left;
591591
}
592592

593+
private boolean isPostgreSqlJsonAccess(Token token) {
594+
return ("->".equals(token.image) || "->>".equals(token.image)
595+
|| "#>".equals(token.image) || "#>>".equals(token.image))
596+
&& Dialect.POSTGRESQL.name().equals(getAsString(Feature.dialect));
597+
}
598+
593599
protected Expression prattArithRest(Expression left, int minPrec) throws ParseException {
594600
while (!interrupted) {
595601
Token t = getToken(1);
596602
int op = t.kind;
597603
int prec;
604+
boolean jsonAccess = isPostgreSqlJsonAccess(t);
598605

599606
// Named tokens: OP_SLASH(/), OP_CARET(^), K_DIV, OP_CONCAT(||),
600607
// OP_PIPE(|), OP_LSHIFT(<<), OP_RSHIFT(>>),
601608
// S_HASH_OPERATOR(#)
602609
// String-literal tokens: *, +, -, %, & (unnamed in JavaCC grammar)
603-
if (op == OP_SLASH || op == OP_CARET || op == K_DIV) prec = 6;
610+
if (jsonAccess || op == OP_JSON_PATH_DELETE || op == OP_JSON_PATH_EXISTS
611+
|| (op == OP_CONCAT && Dialect.POSTGRESQL.name()
612+
.equals(getAsString(Feature.dialect)))) prec = 4;
613+
else if (op == OP_SLASH || op == OP_CARET || op == K_DIV) prec = 6;
604614
else if (op == S_HASH_OPERATOR) prec = 5;
605615
else if (op == OP_CONCAT || op == OP_PIPE
606616
|| op == OP_LSHIFT || op == OP_RSHIFT) prec = 5;
@@ -626,7 +636,16 @@ public class CCJSqlParser extends AbstractJSqlParser<CCJSqlParser> {
626636
jj_consume_token(op, getToken(1).image);
627637
Expression right = prattArithRest(PrimaryExpression(), prec + 1);
628638

629-
if (op == OP_SLASH) { Division r = new Division(); r.setLeftExpression(left); r.setRightExpression(right); left = r; }
639+
if (jsonAccess) {
640+
JsonExpression access = left instanceof JsonExpression
641+
? (JsonExpression) left : new JsonExpression(left);
642+
access.addIdent(right, t.image);
643+
left = access;
644+
}
645+
else if (op == OP_JSON_PATH_DELETE || op == OP_JSON_PATH_EXISTS) {
646+
left = new JsonOperator(t.image).withLeftExpression(left).withRightExpression(right);
647+
}
648+
else if (op == OP_SLASH) { Division r = new Division(); r.setLeftExpression(left); r.setRightExpression(right); left = r; }
630649
else if (op == S_HASH_OPERATOR) { Intersects r = new Intersects(); r.setLeftExpression(left); r.setRightExpression(right); left = r; }
631650
else if (op == OP_CARET) { net.sf.jsqlparser.expression.operators.arithmetic.BitwiseXor r = new net.sf.jsqlparser.expression.operators.arithmetic.BitwiseXor(); r.setLeftExpression(left); r.setRightExpression(right); left = r; }
632651
else if (op == K_DIV) { IntegerDivision r = new IntegerDivision(); r.setLeftExpression(left); r.setRightExpression(right); left = r; }
@@ -2742,6 +2761,8 @@ TOKEN : /* Operators */
27422761
| <OP_SLASH: "/">
27432762
| <OP_LSHIFT: "<<">
27442763
| <OP_RSHIFT: ">>">
2764+
| <OP_JSON_PATH_DELETE: "#-">
2765+
| <OP_JSON_PATH_EXISTS: "@?">
27452766
| <OP_DOUBLEAND: "&&">
27462767
| <OP_CONTAINS: "&>">
27472768
| <OP_CONTAINEDBY: "<&">
@@ -2891,13 +2912,16 @@ TOKEN:
28912912
truncateIdentifierToken(matchedToken, hashIndex);
28922913
}
28932914
}
2894-
// PostgreSQL does not allow # in unquoted identifiers. Re-lex it as an
2895-
// operator, including #> and #>>, even when it touches the left operand.
2915+
// PostgreSQL does not allow # or @ in unquoted identifiers. Re-lex them as
2916+
// operators, including #>, #>>, #- and @?, when touching the left operand.
28962917
if (matchedToken.kind == S_IDENTIFIER
28972918
&& AbstractJSqlParser.Dialect.POSTGRESQL.name().equals(configuration.getValue(Feature.dialect))) {
28982919
int hashIndex = matchedToken.image.indexOf('#');
2899-
if (hashIndex > 0) {
2900-
truncateIdentifierToken(matchedToken, hashIndex);
2920+
int atIndex = matchedToken.image.indexOf('@');
2921+
int operatorIndex = hashIndex < 0 ? atIndex
2922+
: atIndex < 0 ? hashIndex : Math.min(hashIndex, atIndex);
2923+
if (operatorIndex > 0) {
2924+
truncateIdentifierToken(matchedToken, operatorIndex);
29012925
} else if (hashIndex == 0) {
29022926
truncateIdentifierToken(matchedToken, 1);
29032927
matchedToken.kind = S_HASH_OPERATOR;
@@ -10660,7 +10684,8 @@ Expression SimpleExpression():
1066010684
{
1066110685
[ LOOKAHEAD( 5, { getToken(1).kind == S_AT_IDENTIFIER } ) user = UserVariable() ( operation = "=" | operation = ":=" ) ]
1066210686
retval=PrimaryExpression()
10663-
{ retval = prattArithRest(retval, 5); }
10687+
// PostgreSQL path operators bind below arithmetic and above comparisons.
10688+
{ retval = prattArithRest(retval, 4); }
1066410689
{
1066510690
if (user != null) {
1066610691
VariableAssignment assignment = new VariableAssignment();
@@ -11016,8 +11041,9 @@ Expression PrimaryExpression() #PrimaryExpression:
1101611041

1101711042
// Check for JSON operands
1101811043
[
11019-
LOOKAHEAD(2, { (ternaryThenBranchDepth == 0 && mapKeyDepth == 0)
11020-
|| !":".equals(getToken(1).image) }) (
11044+
LOOKAHEAD(2, { !isPostgreSqlJsonAccess(getToken(1))
11045+
&& ((ternaryThenBranchDepth == 0 && mapKeyDepth == 0)
11046+
|| !":".equals(getToken(1).image)) }) (
1102111047
LOOKAHEAD(2) (
1102211048
token="->"
1102311049
|
@@ -11384,8 +11410,9 @@ JsonExpression JsonExpression(Expression expr, List<Map.Entry<Expression, String
1138411410
}
1138511411

1138611412
(
11387-
LOOKAHEAD(2, { (ternaryThenBranchDepth == 0 && mapKeyDepth == 0)
11388-
|| !":".equals(getToken(1).image) }) (
11413+
LOOKAHEAD(2, { !isPostgreSqlJsonAccess(getToken(1))
11414+
&& ((ternaryThenBranchDepth == 0 && mapKeyDepth == 0)
11415+
|| !":".equals(getToken(1).image)) }) (
1138911416
token="->"
1139011417
|
1139111418
token=":"
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
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 net.sf.jsqlparser.expression.Expression;
13+
import net.sf.jsqlparser.expression.JdbcParameter;
14+
import net.sf.jsqlparser.expression.JsonExpression;
15+
import net.sf.jsqlparser.expression.StringValue;
16+
import net.sf.jsqlparser.expression.operators.arithmetic.Concat;
17+
import net.sf.jsqlparser.expression.operators.relational.EqualsTo;
18+
import net.sf.jsqlparser.expression.operators.relational.IsBooleanExpression;
19+
import net.sf.jsqlparser.expression.operators.relational.JsonOperator;
20+
import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect;
21+
import net.sf.jsqlparser.statement.select.PlainSelect;
22+
import net.sf.jsqlparser.util.deparser.ExpressionDeParser;
23+
import net.sf.jsqlparser.util.deparser.SelectDeParser;
24+
import net.sf.jsqlparser.util.deparser.StatementDeParser;
25+
import org.junit.jupiter.api.Test;
26+
import org.junit.jupiter.params.ParameterizedTest;
27+
import org.junit.jupiter.params.provider.ValueSource;
28+
29+
import java.util.ArrayList;
30+
import java.util.List;
31+
32+
import static org.junit.jupiter.api.Assertions.*;
33+
34+
class PostgreSqlJsonPathOperatorTest {
35+
private PlainSelect parse(String sql) throws Exception {
36+
return (PlainSelect) CCJSqlParserUtil.parse(sql, p -> p.withDialect(Dialect.POSTGRESQL));
37+
}
38+
39+
@ParameterizedTest
40+
@ValueSource(strings = {
41+
"SELECT '{\"a\":1}'::jsonb #- '{a}'",
42+
"SELECT doc#-'{a}' FROM events",
43+
"SELECT doc/*comment*/#-ARRAY['a', '0'] FROM events",
44+
"SELECT doc@?'$.a[*] ? (@ > 2)' FROM events",
45+
"SELECT NULL::jsonb @? '$.a'",
46+
"SELECT doc #- '{a}' #- '{b}' FROM events"
47+
})
48+
void buildsJsonOperatorsAndPreservesOutput(String sql) throws Exception {
49+
PlainSelect select = parse(sql);
50+
JsonOperator operator =
51+
assertInstanceOf(JsonOperator.class, select.getSelectItem(0).getExpression());
52+
assertEquals(sql.contains("@?") ? "@?" : "#-", operator.getStringExpression());
53+
StringBuilder output = new StringBuilder();
54+
select.accept(new StatementDeParser(output), null);
55+
assertEquals(select.toString(), output.toString());
56+
assertEquals(select.toString(), parse(output.toString()).toString());
57+
assertFalse(output.toString().contains("# -"));
58+
}
59+
60+
@Test
61+
void preservesAssociativityAndComparisonPrecedence() throws Exception {
62+
PlainSelect select = parse("SELECT doc #- '{a}' #- '{b}' = '{}'::jsonb FROM events");
63+
EqualsTo comparison =
64+
assertInstanceOf(EqualsTo.class, select.getSelectItem(0).getExpression());
65+
JsonOperator deletion =
66+
assertInstanceOf(JsonOperator.class, comparison.getLeftExpression());
67+
assertEquals("#-", deletion.getStringExpression());
68+
assertInstanceOf(JsonOperator.class, deletion.getLeftExpression());
69+
IsBooleanExpression predicate = assertInstanceOf(IsBooleanExpression.class,
70+
parse("SELECT doc #- '{a}' @? '$.b' IS TRUE FROM events")
71+
.getSelectItem(0).getExpression());
72+
JsonOperator exists = assertInstanceOf(JsonOperator.class, predicate.getLeftExpression());
73+
assertEquals("@?", exists.getStringExpression());
74+
assertInstanceOf(JsonOperator.class, exists.getLeftExpression());
75+
}
76+
77+
@Test
78+
void sharesLeftAssociativityWithPostgresqlConcatenation() throws Exception {
79+
Concat concat = assertInstanceOf(Concat.class,
80+
parse("SELECT doc #- '{a}' || other FROM events").getSelectItem(0).getExpression());
81+
assertInstanceOf(JsonOperator.class, concat.getLeftExpression());
82+
JsonOperator deletion = assertInstanceOf(JsonOperator.class,
83+
parse("SELECT doc || other #- '{a}' FROM events").getSelectItem(0).getExpression());
84+
assertInstanceOf(Concat.class, deletion.getLeftExpression());
85+
}
86+
87+
@Test
88+
void composesAccessAndPathOperatorsInBothParserModes() throws Exception {
89+
for (boolean complex : List.of(false, true)) {
90+
for (String operator : List.of("->", "#>")) {
91+
CCJSqlParser parser = CCJSqlParserUtil.newParser(
92+
"SELECT doc " + operator + " '{a}' #- '{b}' FROM events")
93+
.withDialect(Dialect.POSTGRESQL).withAllowComplexParsing(complex);
94+
JsonOperator deletion = assertInstanceOf(JsonOperator.class,
95+
((PlainSelect) parser.Statement()).getSelectItem(0).getExpression());
96+
JsonExpression access =
97+
assertInstanceOf(JsonExpression.class, deletion.getLeftExpression());
98+
assertInstanceOf(StringValue.class, access.getIdentList().get(0).getKey());
99+
}
100+
CCJSqlParser parser = CCJSqlParserUtil.newParser(
101+
"SELECT doc #- '{a}' ->> 'b' = 'x' FROM events")
102+
.withDialect(Dialect.POSTGRESQL).withAllowComplexParsing(complex);
103+
EqualsTo comparison = assertInstanceOf(EqualsTo.class,
104+
((PlainSelect) parser.Statement()).getSelectItem(0).getExpression());
105+
JsonExpression access =
106+
assertInstanceOf(JsonExpression.class, comparison.getLeftExpression());
107+
assertInstanceOf(JsonOperator.class, access.getExpression());
108+
assertInstanceOf(StringValue.class, access.getIdentList().get(0).getKey());
109+
}
110+
}
111+
112+
@Test
113+
void distinguishesPathOperatorFromParametersAndVisitsOperands() throws Exception {
114+
PlainSelect select = parse("SELECT doc @? ? FROM events WHERE doc #- ? = ?::jsonb");
115+
JsonOperator exists = (JsonOperator) select.getSelectItem(0).getExpression();
116+
assertEquals(1, ((JdbcParameter) exists.getRightExpression()).getIndex());
117+
EqualsTo comparison = (EqualsTo) select.getWhere();
118+
JsonOperator deletion = (JsonOperator) comparison.getLeftExpression();
119+
assertEquals(2, ((JdbcParameter) deletion.getRightExpression()).getIndex());
120+
121+
List<String> visited = new ArrayList<>();
122+
ExpressionDeParser expressions = new ExpressionDeParser() {
123+
@Override
124+
public <S> StringBuilder visit(StringValue value, S context) {
125+
visited.add(value.getValue());
126+
return getBuilder().append("'changed'");
127+
}
128+
};
129+
StringBuilder output = new StringBuilder();
130+
parse("SELECT doc #- '{a}' @? '$.b' FROM events")
131+
.accept(new StatementDeParser(expressions, new SelectDeParser(), output), null);
132+
assertEquals(List.of("{a}", "$.b"), visited);
133+
assertEquals("SELECT doc #- 'changed' @? 'changed' FROM events", output.toString());
134+
}
135+
136+
@Test
137+
void retainsExistingHashAndJsonOperators() throws Exception {
138+
for (String sql : List.of("SELECT 5 # -1", "SELECT doc #> '{a}' FROM events",
139+
"SELECT doc #>> '{a}' FROM events", "SELECT doc ? 'a' FROM events",
140+
"SELECT doc @@ '$.a > 2' FROM events")) {
141+
assertEquals(parse(sql).toString(), parse(parse(sql).toString()).toString());
142+
}
143+
Expression expression = parse("SELECT doc #- '{a}' FROM events")
144+
.getSelectItem(0).getExpression();
145+
((JsonOperator) expression).setRightExpression(new StringValue("{b}"));
146+
assertEquals("doc #- '{b}'", expression.toString());
147+
}
148+
149+
@ParameterizedTest
150+
@ValueSource(strings = {"SELECT '{}'::jsonb #-", "SELECT '{}'::jsonb @?",
151+
"SELECT '{}'::jsonb @? , 1"})
152+
void requiresRightOperand(String sql) {
153+
assertThrows(net.sf.jsqlparser.JSQLParserException.class, () -> parse(sql));
154+
}
155+
156+
@Test
157+
void retainsAtSignIdentifierBehaviorInOtherDialects() throws Exception {
158+
for (Dialect dialect : List.of(Dialect.SQLSERVER, Dialect.MYSQL)) {
159+
PlainSelect select = (PlainSelect) CCJSqlParserUtil.parse("SELECT name@host FROM users",
160+
p -> p.withDialect(dialect));
161+
assertEquals("name@host", select.getSelectItem(0).getExpression().toString());
162+
}
163+
CCJSqlParser parser = CCJSqlParserUtil.newParser("doc@?'$.a'")
164+
.withDialect(Dialect.POSTGRESQL);
165+
assertEquals("doc", parser.getNextToken().image);
166+
Token operator = parser.getNextToken();
167+
assertEquals("@?", operator.image);
168+
assertEquals(4, operator.beginColumn);
169+
assertEquals(5, operator.endColumn);
170+
}
171+
}

0 commit comments

Comments
 (0)