diff --git a/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitor.java b/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitor.java index 91185a462..69bc47385 100644 --- a/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitor.java +++ b/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitor.java @@ -46,6 +46,7 @@ import net.sf.jsqlparser.expression.operators.relational.IsDistinctExpression; import net.sf.jsqlparser.expression.operators.relational.IsNullExpression; import net.sf.jsqlparser.expression.operators.relational.IsUnknownExpression; +import net.sf.jsqlparser.expression.operators.relational.IsJsonExpression; import net.sf.jsqlparser.expression.operators.relational.JsonOperator; import net.sf.jsqlparser.expression.operators.relational.LikeExpression; import net.sf.jsqlparser.expression.operators.relational.Matches; @@ -370,6 +371,14 @@ default void visit(IsBooleanExpression isBooleanExpression) { this.visit(isBooleanExpression, null); } + default T visit(IsJsonExpression expression, S context) { + return expression.getLeftExpression().accept(this, context); + } + + default void visit(IsJsonExpression expression) { + visit(expression, null); + } + T visit(IsUnknownExpression isUnknownExpression, S context); default void visit(IsUnknownExpression isUnknownExpression) { diff --git a/src/main/java/net/sf/jsqlparser/expression/operators/relational/IsJsonExpression.java b/src/main/java/net/sf/jsqlparser/expression/operators/relational/IsJsonExpression.java new file mode 100644 index 000000000..e70b0bdd2 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/expression/operators/relational/IsJsonExpression.java @@ -0,0 +1,98 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.expression.operators.relational; + +import java.util.function.Consumer; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.ExpressionVisitor; +import net.sf.jsqlparser.parser.ASTNodeAccessImpl; + +/** SQL/JSON type and key uniqueness predicate; the JSON value is not evaluated by the parser. */ +public class IsJsonExpression extends ASTNodeAccessImpl implements Expression { + public enum Type { + VALUE, SCALAR, ARRAY, OBJECT + } + + public enum UniqueKeys { + WITH, WITHOUT + } + + private Expression leftExpression; + private boolean not; + private Type type; + private UniqueKeys uniqueKeys; + private boolean useKeysKeyword = true; + + public Expression getLeftExpression() { + return leftExpression; + } + + public void setLeftExpression(Expression leftExpression) { + this.leftExpression = leftExpression; + } + + public boolean isNot() { + return not; + } + + public void setNot(boolean not) { + this.not = not; + } + + public Type getType() { + return type; + } + + public void setType(Type type) { + this.type = type; + } + + public UniqueKeys getUniqueKeys() { + return uniqueKeys; + } + + public void setUniqueKeys(UniqueKeys uniqueKeys) { + this.uniqueKeys = uniqueKeys; + } + + public boolean isUseKeysKeyword() { + return useKeysKeyword; + } + + public void setUseKeysKeyword(boolean useKeysKeyword) { + this.useKeysKeyword = useKeysKeyword; + } + + public StringBuilder appendTo(StringBuilder builder, Consumer expressionRenderer) { + expressionRenderer.accept(leftExpression); + builder.append(not ? " IS NOT JSON" : " IS JSON"); + if (type != null) { + builder.append(' ').append(type); + } + if (uniqueKeys != null) { + builder.append(' ').append(uniqueKeys).append(" UNIQUE"); + if (useKeysKeyword) { + builder.append(" KEYS"); + } + } + return builder; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + return appendTo(builder, builder::append).toString(); + } + + @Override + public T accept(ExpressionVisitor visitor, S context) { + return visitor.visit(this, context); + } +} diff --git a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java index 58ed96435..287c2ff28 100644 --- a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java +++ b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java @@ -75,6 +75,7 @@ import net.sf.jsqlparser.expression.operators.relational.IsDistinctExpression; import net.sf.jsqlparser.expression.operators.relational.IsNullExpression; import net.sf.jsqlparser.expression.operators.relational.IsUnknownExpression; +import net.sf.jsqlparser.expression.operators.relational.IsJsonExpression; import net.sf.jsqlparser.expression.operators.relational.JsonOperator; import net.sf.jsqlparser.expression.operators.relational.LikeExpression; import net.sf.jsqlparser.expression.operators.relational.Matches; @@ -721,6 +722,12 @@ public Void visit(IsBooleanExpression isBooleanExpression, S context) { return null; } + @Override + public Void visit(IsJsonExpression expression, S context) { + expression.getLeftExpression().accept(this, context); + return null; + } + @Override public Void visit(IsUnknownExpression isUnknownExpression, S context) { diff --git a/src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java b/src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java index 3ad46ae72..f79da130a 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java @@ -121,6 +121,7 @@ import net.sf.jsqlparser.expression.operators.relational.IsDistinctExpression; import net.sf.jsqlparser.expression.operators.relational.IsNullExpression; import net.sf.jsqlparser.expression.operators.relational.IsUnknownExpression; +import net.sf.jsqlparser.expression.operators.relational.IsJsonExpression; import net.sf.jsqlparser.expression.operators.relational.JsonOperator; import net.sf.jsqlparser.expression.operators.relational.LikeExpression; import net.sf.jsqlparser.expression.operators.relational.Matches; @@ -477,6 +478,11 @@ public StringBuilder visit(IsBooleanExpression isBooleanExpression, S contex return builder; } + @Override + public StringBuilder visit(IsJsonExpression expression, S context) { + return expression.appendTo(builder, value -> value.accept(this, context)); + } + @Override public StringBuilder visit(IsUnknownExpression isUnknownExpression, S context) { if (isUnknownExpression diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 6fd8d0375..cb2d65b11 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -10009,6 +10009,7 @@ Expression Condition(): | LOOKAHEAD(2) result = MemberOfExpression(left) | LOOKAHEAD(3) result=IsNullExpression(left) | LOOKAHEAD(3) result=IsBooleanExpression(left) + | LOOKAHEAD(3) result=IsJsonExpression(left) | LOOKAHEAD(3) result=IsUnknownExpression(left) | LOOKAHEAD(2) result=LikeExpression(left) | LOOKAHEAD(3) result=IsDistinctExpression(left) @@ -10141,7 +10142,8 @@ Expression SQLCondition(): | LOOKAHEAD(3) result=IsBooleanExpression(left) | - LOOKAHEAD(3) result=IsUnknownExpression(left) + LOOKAHEAD(3) result=IsJsonExpression(left) + | LOOKAHEAD(3) result=IsUnknownExpression(left) | LOOKAHEAD(2) result=LikeExpression(left) | @@ -10401,6 +10403,24 @@ Expression IsBooleanExpression(Expression leftExpression): } } +Expression IsJsonExpression(Expression leftExpression): +{ + IsJsonExpression result = new IsJsonExpression(); + Token type; +} +{ + [ { result.setNot(true); } ] + [ LOOKAHEAD({ isKeywordAhead("VALUE") || isKeywordAhead("SCALAR") + || isKeywordAhead("ARRAY") || isKeywordAhead("OBJECT") }) + ( type= | type= | type= | type= ) + { result.setType(IsJsonExpression.Type.valueOf(type.image.toUpperCase(Locale.ROOT))); } ] + [ LOOKAHEAD(2) ( { result.setUniqueKeys(IsJsonExpression.UniqueKeys.WITH); } + | { result.setUniqueKeys(IsJsonExpression.UniqueKeys.WITHOUT); } ) + { result.setUseKeysKeyword(false); } + [ LOOKAHEAD() { result.setUseKeysKeyword(true); } ] ] + { result.setLeftExpression(leftExpression); return result; } +} + Expression IsUnknownExpression(Expression leftExpression): { IsUnknownExpression result = new IsUnknownExpression(); diff --git a/src/test/java/net/sf/jsqlparser/expression/operators/relational/IsJsonExpressionTest.java b/src/test/java/net/sf/jsqlparser/expression/operators/relational/IsJsonExpressionTest.java new file mode 100644 index 000000000..cf1109403 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/expression/operators/relational/IsJsonExpressionTest.java @@ -0,0 +1,119 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.expression.operators.relational; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import java.util.ArrayList; +import java.util.List; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.*; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.parser.feature.Feature; +import net.sf.jsqlparser.statement.select.PlainSelect; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.util.TablesNamesFinder; +import net.sf.jsqlparser.util.deparser.ExpressionDeParser; +import net.sf.jsqlparser.util.deparser.StatementDeParser; +import net.sf.jsqlparser.util.validation.Validation; +import net.sf.jsqlparser.util.validation.feature.FeaturesAllowed; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class IsJsonExpressionTest { + @ParameterizedTest + @ValueSource(strings = {"", " VALUE", " SCALAR", " ARRAY", " OBJECT", + " WITH UNIQUE KEYS", " WITHOUT UNIQUE", " OBJECT WITH UNIQUE", + " ARRAY WITHOUT UNIQUE KEYS"}) + void modelsOptionalTypeAndUniquenessInBothParserModes(String options) throws Exception { + for (boolean complex : List.of(false, true)) { + for (String not : List.of("", " NOT")) { + String sql = "SELECT payload IS" + not + " JSON" + options + " FROM events"; + PlainSelect select = (PlainSelect) CCJSqlParserUtil.parse(sql, + p -> p.withDialect(Dialect.POSTGRESQL).withAllowComplexParsing(complex)); + IsJsonExpression predicate = assertInstanceOf(IsJsonExpression.class, + select.getSelectItem(0).getExpression()); + assertEquals(!not.isEmpty(), predicate.isNot()); + assertEquals(sql, select.toString()); + roundTrip(select); + } + } + } + + @Test + void exposesMutableOptionsAndVisitsTheOperandWithContext() throws Exception { + PlainSelect select = parse("SELECT '{\"a\":1}' IS JSON"); + IsJsonExpression predicate = (IsJsonExpression) select.getSelectItem(0).getExpression(); + assertNull(predicate.getType()); + assertNull(predicate.getUniqueKeys()); + predicate.setType(IsJsonExpression.Type.OBJECT); + predicate.setUniqueKeys(IsJsonExpression.UniqueKeys.WITH); + predicate.setNot(true); + predicate.setLeftExpression(new StringValue("{}")); + assertEquals("SELECT '{}' IS NOT JSON OBJECT WITH UNIQUE KEYS", select.toString()); + roundTrip(select); + Object expected = new Object(); + List seen = new ArrayList<>(); + predicate.accept(new ExpressionVisitorAdapter() { + @Override + public Void visit(StringValue value, S context) { + assertSame(expected, context); + seen.add(value.getValue()); + return null; + } + }, expected); + assertEquals(List.of("{}"), seen); + StringBuilder output = new StringBuilder(); + ExpressionDeParser visitor = new ExpressionDeParser() { + @Override + public StringBuilder visit(StringValue value, S context) { + assertSame(expected, context); + return getBuilder().append("'[]'"); + } + }; + visitor.setBuilder(output); + predicate.accept(visitor, expected); + assertEquals("'[]' IS NOT JSON OBJECT WITH UNIQUE KEYS", output.toString()); + } + + @Test + void traversesSubqueriesAndValidatesOperandFeatures() throws Exception { + PlainSelect select = parse("SELECT 1 FROM t WHERE (SELECT payload FROM hidden) IS JSON"); + assertThat(new TablesNamesFinder().getTables((Statement) select)).containsExactlyInAnyOrder( + "t", + "hidden"); + FeaturesAllowed allowed = + new FeaturesAllowed().add(FeaturesAllowed.DML).remove(Feature.jdbcParameter); + assertFalse( + new Validation(List.of(allowed), "SELECT 1 WHERE ? IS JSON").validate().isEmpty()); + roundTrip(parse("SELECT NULL IS JSON OR '{}' IS NOT JSON AND '1' IS JSON SCALAR")); + } + + @ParameterizedTest + @ValueSource(strings = {"SELECT x IS JSON WITH", "SELECT x IS JSON WITH KEYS", + "SELECT x IS JSON WITH UNIQUE KEYS WITHOUT UNIQUE", + "SELECT x IS NOT JSON WITH UNIQUE,"}) + void rejectsIncompleteOptions(String sql) { + assertThrows(JSQLParserException.class, () -> parse(sql)); + } + + private static PlainSelect parse(String sql) throws JSQLParserException { + return (PlainSelect) CCJSqlParserUtil.parse(sql, p -> p.withDialect(Dialect.POSTGRESQL)); + } + + private static void roundTrip(PlainSelect select) throws Exception { + StringBuilder out = new StringBuilder(); + select.accept(new StatementDeParser(out), null); + assertEquals(select.toString(), out.toString()); + assertEquals(select.toString(), parse(out.toString()).toString()); + } +}