Skip to content

Commit 6d7abaa

Browse files
committed
feat: add a structured SQL JSON predicate
Signed-off-by: minleejae <mmj9808@gmail.com>
1 parent 7f91819 commit 6d7abaa

6 files changed

Lines changed: 260 additions & 1 deletion

File tree

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
import net.sf.jsqlparser.expression.operators.relational.IsDistinctExpression;
4747
import net.sf.jsqlparser.expression.operators.relational.IsNullExpression;
4848
import net.sf.jsqlparser.expression.operators.relational.IsUnknownExpression;
49+
import net.sf.jsqlparser.expression.operators.relational.IsJsonExpression;
4950
import net.sf.jsqlparser.expression.operators.relational.JsonOperator;
5051
import net.sf.jsqlparser.expression.operators.relational.LikeExpression;
5152
import net.sf.jsqlparser.expression.operators.relational.Matches;
@@ -370,6 +371,14 @@ default void visit(IsBooleanExpression isBooleanExpression) {
370371
this.visit(isBooleanExpression, null);
371372
}
372373

374+
default <S> T visit(IsJsonExpression expression, S context) {
375+
return expression.getLeftExpression().accept(this, context);
376+
}
377+
378+
default void visit(IsJsonExpression expression) {
379+
visit(expression, null);
380+
}
381+
373382
<S> T visit(IsUnknownExpression isUnknownExpression, S context);
374383

375384
default void visit(IsUnknownExpression isUnknownExpression) {
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
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.expression.operators.relational;
11+
12+
import java.util.function.Consumer;
13+
import net.sf.jsqlparser.expression.Expression;
14+
import net.sf.jsqlparser.expression.ExpressionVisitor;
15+
import net.sf.jsqlparser.parser.ASTNodeAccessImpl;
16+
17+
/** SQL/JSON type and key uniqueness predicate; the JSON value is not evaluated by the parser. */
18+
public class IsJsonExpression extends ASTNodeAccessImpl implements Expression {
19+
public enum Type {
20+
VALUE, SCALAR, ARRAY, OBJECT
21+
}
22+
23+
public enum UniqueKeys {
24+
WITH, WITHOUT
25+
}
26+
27+
private Expression leftExpression;
28+
private boolean not;
29+
private Type type;
30+
private UniqueKeys uniqueKeys;
31+
private boolean useKeysKeyword = true;
32+
33+
public Expression getLeftExpression() {
34+
return leftExpression;
35+
}
36+
37+
public void setLeftExpression(Expression leftExpression) {
38+
this.leftExpression = leftExpression;
39+
}
40+
41+
public boolean isNot() {
42+
return not;
43+
}
44+
45+
public void setNot(boolean not) {
46+
this.not = not;
47+
}
48+
49+
public Type getType() {
50+
return type;
51+
}
52+
53+
public void setType(Type type) {
54+
this.type = type;
55+
}
56+
57+
public UniqueKeys getUniqueKeys() {
58+
return uniqueKeys;
59+
}
60+
61+
public void setUniqueKeys(UniqueKeys uniqueKeys) {
62+
this.uniqueKeys = uniqueKeys;
63+
}
64+
65+
public boolean isUseKeysKeyword() {
66+
return useKeysKeyword;
67+
}
68+
69+
public void setUseKeysKeyword(boolean useKeysKeyword) {
70+
this.useKeysKeyword = useKeysKeyword;
71+
}
72+
73+
public StringBuilder appendTo(StringBuilder builder, Consumer<Expression> expressionRenderer) {
74+
expressionRenderer.accept(leftExpression);
75+
builder.append(not ? " IS NOT JSON" : " IS JSON");
76+
if (type != null) {
77+
builder.append(' ').append(type);
78+
}
79+
if (uniqueKeys != null) {
80+
builder.append(' ').append(uniqueKeys).append(" UNIQUE");
81+
if (useKeysKeyword) {
82+
builder.append(" KEYS");
83+
}
84+
}
85+
return builder;
86+
}
87+
88+
@Override
89+
public String toString() {
90+
StringBuilder builder = new StringBuilder();
91+
return appendTo(builder, builder::append).toString();
92+
}
93+
94+
@Override
95+
public <T, S> T accept(ExpressionVisitor<T> visitor, S context) {
96+
return visitor.visit(this, context);
97+
}
98+
}

src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
import net.sf.jsqlparser.expression.operators.relational.IsDistinctExpression;
7676
import net.sf.jsqlparser.expression.operators.relational.IsNullExpression;
7777
import net.sf.jsqlparser.expression.operators.relational.IsUnknownExpression;
78+
import net.sf.jsqlparser.expression.operators.relational.IsJsonExpression;
7879
import net.sf.jsqlparser.expression.operators.relational.JsonOperator;
7980
import net.sf.jsqlparser.expression.operators.relational.LikeExpression;
8081
import net.sf.jsqlparser.expression.operators.relational.Matches;
@@ -721,6 +722,12 @@ public <S> Void visit(IsBooleanExpression isBooleanExpression, S context) {
721722
return null;
722723
}
723724

725+
@Override
726+
public <S> Void visit(IsJsonExpression expression, S context) {
727+
expression.getLeftExpression().accept(this, context);
728+
return null;
729+
}
730+
724731
@Override
725732
public <S> Void visit(IsUnknownExpression isUnknownExpression, S context) {
726733

src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@
121121
import net.sf.jsqlparser.expression.operators.relational.IsDistinctExpression;
122122
import net.sf.jsqlparser.expression.operators.relational.IsNullExpression;
123123
import net.sf.jsqlparser.expression.operators.relational.IsUnknownExpression;
124+
import net.sf.jsqlparser.expression.operators.relational.IsJsonExpression;
124125
import net.sf.jsqlparser.expression.operators.relational.JsonOperator;
125126
import net.sf.jsqlparser.expression.operators.relational.LikeExpression;
126127
import net.sf.jsqlparser.expression.operators.relational.Matches;
@@ -477,6 +478,11 @@ public <S> StringBuilder visit(IsBooleanExpression isBooleanExpression, S contex
477478
return builder;
478479
}
479480

481+
@Override
482+
public <S> StringBuilder visit(IsJsonExpression expression, S context) {
483+
return expression.appendTo(builder, value -> value.accept(this, context));
484+
}
485+
480486
@Override
481487
public <S> StringBuilder visit(IsUnknownExpression isUnknownExpression, S context) {
482488
if (isUnknownExpression

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

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10009,6 +10009,7 @@ Expression Condition():
1000910009
| LOOKAHEAD(2) result = MemberOfExpression(left)
1001010010
| LOOKAHEAD(3) result=IsNullExpression(left)
1001110011
| LOOKAHEAD(3) result=IsBooleanExpression(left)
10012+
| LOOKAHEAD(3) result=IsJsonExpression(left)
1001210013
| LOOKAHEAD(3) result=IsUnknownExpression(left)
1001310014
| LOOKAHEAD(2) result=LikeExpression(left)
1001410015
| LOOKAHEAD(3) result=IsDistinctExpression(left)
@@ -10141,7 +10142,8 @@ Expression SQLCondition():
1014110142
|
1014210143
LOOKAHEAD(3) result=IsBooleanExpression(left)
1014310144
|
10144-
LOOKAHEAD(3) result=IsUnknownExpression(left)
10145+
LOOKAHEAD(3) result=IsJsonExpression(left)
10146+
| LOOKAHEAD(3) result=IsUnknownExpression(left)
1014510147
|
1014610148
LOOKAHEAD(2) result=LikeExpression(left)
1014710149
|
@@ -10401,6 +10403,24 @@ Expression IsBooleanExpression(Expression leftExpression):
1040110403
}
1040210404
}
1040310405

10406+
Expression IsJsonExpression(Expression leftExpression):
10407+
{
10408+
IsJsonExpression result = new IsJsonExpression();
10409+
Token type;
10410+
}
10411+
{
10412+
<K_IS> [ <K_NOT> { result.setNot(true); } ] <K_JSON>
10413+
[ LOOKAHEAD({ isKeywordAhead("VALUE") || isKeywordAhead("SCALAR")
10414+
|| isKeywordAhead("ARRAY") || isKeywordAhead("OBJECT") })
10415+
( type=<K_VALUE> | type=<K_ARRAY_LITERAL> | type=<S_IDENTIFIER> | type=<DATA_TYPE> )
10416+
{ result.setType(IsJsonExpression.Type.valueOf(type.image.toUpperCase(Locale.ROOT))); } ]
10417+
[ LOOKAHEAD(2) ( <K_WITH> { result.setUniqueKeys(IsJsonExpression.UniqueKeys.WITH); }
10418+
| <K_WITHOUT> { result.setUniqueKeys(IsJsonExpression.UniqueKeys.WITHOUT); } )
10419+
<K_UNIQUE> { result.setUseKeysKeyword(false); }
10420+
[ LOOKAHEAD(<K_KEYS>) <K_KEYS> { result.setUseKeysKeyword(true); } ] ]
10421+
{ result.setLeftExpression(leftExpression); return result; }
10422+
}
10423+
1040410424
Expression IsUnknownExpression(Expression leftExpression):
1040510425
{
1040610426
IsUnknownExpression result = new IsUnknownExpression();
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
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.expression.operators.relational;
11+
12+
import static org.assertj.core.api.Assertions.assertThat;
13+
import static org.junit.jupiter.api.Assertions.*;
14+
import java.util.ArrayList;
15+
import java.util.List;
16+
import net.sf.jsqlparser.JSQLParserException;
17+
import net.sf.jsqlparser.expression.*;
18+
import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect;
19+
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
20+
import net.sf.jsqlparser.parser.feature.Feature;
21+
import net.sf.jsqlparser.statement.select.PlainSelect;
22+
import net.sf.jsqlparser.statement.Statement;
23+
import net.sf.jsqlparser.util.TablesNamesFinder;
24+
import net.sf.jsqlparser.util.deparser.ExpressionDeParser;
25+
import net.sf.jsqlparser.util.deparser.StatementDeParser;
26+
import net.sf.jsqlparser.util.validation.Validation;
27+
import net.sf.jsqlparser.util.validation.feature.FeaturesAllowed;
28+
import org.junit.jupiter.api.Test;
29+
import org.junit.jupiter.params.ParameterizedTest;
30+
import org.junit.jupiter.params.provider.ValueSource;
31+
32+
class IsJsonExpressionTest {
33+
@ParameterizedTest
34+
@ValueSource(strings = {"", " VALUE", " SCALAR", " ARRAY", " OBJECT",
35+
" WITH UNIQUE KEYS", " WITHOUT UNIQUE", " OBJECT WITH UNIQUE",
36+
" ARRAY WITHOUT UNIQUE KEYS"})
37+
void modelsOptionalTypeAndUniquenessInBothParserModes(String options) throws Exception {
38+
for (boolean complex : List.of(false, true)) {
39+
for (String not : List.of("", " NOT")) {
40+
String sql = "SELECT payload IS" + not + " JSON" + options + " FROM events";
41+
PlainSelect select = (PlainSelect) CCJSqlParserUtil.parse(sql,
42+
p -> p.withDialect(Dialect.POSTGRESQL).withAllowComplexParsing(complex));
43+
IsJsonExpression predicate = assertInstanceOf(IsJsonExpression.class,
44+
select.getSelectItem(0).getExpression());
45+
assertEquals(!not.isEmpty(), predicate.isNot());
46+
assertEquals(sql, select.toString());
47+
roundTrip(select);
48+
}
49+
}
50+
}
51+
52+
@Test
53+
void exposesMutableOptionsAndVisitsTheOperandWithContext() throws Exception {
54+
PlainSelect select = parse("SELECT '{\"a\":1}' IS JSON");
55+
IsJsonExpression predicate = (IsJsonExpression) select.getSelectItem(0).getExpression();
56+
assertNull(predicate.getType());
57+
assertNull(predicate.getUniqueKeys());
58+
predicate.setType(IsJsonExpression.Type.OBJECT);
59+
predicate.setUniqueKeys(IsJsonExpression.UniqueKeys.WITH);
60+
predicate.setNot(true);
61+
predicate.setLeftExpression(new StringValue("{}"));
62+
assertEquals("SELECT '{}' IS NOT JSON OBJECT WITH UNIQUE KEYS", select.toString());
63+
roundTrip(select);
64+
Object expected = new Object();
65+
List<String> seen = new ArrayList<>();
66+
predicate.accept(new ExpressionVisitorAdapter<Void>() {
67+
@Override
68+
public <S> Void visit(StringValue value, S context) {
69+
assertSame(expected, context);
70+
seen.add(value.getValue());
71+
return null;
72+
}
73+
}, expected);
74+
assertEquals(List.of("{}"), seen);
75+
StringBuilder output = new StringBuilder();
76+
ExpressionDeParser visitor = new ExpressionDeParser() {
77+
@Override
78+
public <S> StringBuilder visit(StringValue value, S context) {
79+
assertSame(expected, context);
80+
return getBuilder().append("'[]'");
81+
}
82+
};
83+
visitor.setBuilder(output);
84+
predicate.accept(visitor, expected);
85+
assertEquals("'[]' IS NOT JSON OBJECT WITH UNIQUE KEYS", output.toString());
86+
}
87+
88+
@Test
89+
void traversesSubqueriesAndValidatesOperandFeatures() throws Exception {
90+
PlainSelect select = parse("SELECT 1 FROM t WHERE (SELECT payload FROM hidden) IS JSON");
91+
assertThat(new TablesNamesFinder().getTables((Statement) select)).containsExactlyInAnyOrder(
92+
"t",
93+
"hidden");
94+
FeaturesAllowed allowed =
95+
new FeaturesAllowed().add(FeaturesAllowed.DML).remove(Feature.jdbcParameter);
96+
assertFalse(
97+
new Validation(List.of(allowed), "SELECT 1 WHERE ? IS JSON").validate().isEmpty());
98+
roundTrip(parse("SELECT NULL IS JSON OR '{}' IS NOT JSON AND '1' IS JSON SCALAR"));
99+
}
100+
101+
@ParameterizedTest
102+
@ValueSource(strings = {"SELECT x IS JSON WITH", "SELECT x IS JSON WITH KEYS",
103+
"SELECT x IS JSON WITH UNIQUE KEYS WITHOUT UNIQUE",
104+
"SELECT x IS NOT JSON WITH UNIQUE,"})
105+
void rejectsIncompleteOptions(String sql) {
106+
assertThrows(JSQLParserException.class, () -> parse(sql));
107+
}
108+
109+
private static PlainSelect parse(String sql) throws JSQLParserException {
110+
return (PlainSelect) CCJSqlParserUtil.parse(sql, p -> p.withDialect(Dialect.POSTGRESQL));
111+
}
112+
113+
private static void roundTrip(PlainSelect select) throws Exception {
114+
StringBuilder out = new StringBuilder();
115+
select.accept(new StatementDeParser(out), null);
116+
assertEquals(select.toString(), out.toString());
117+
assertEquals(select.toString(), parse(out.toString()).toString());
118+
}
119+
}

0 commit comments

Comments
 (0)