diff --git a/rsql-jpa/src/main/java/io/github/perplexhub/rsql/QuerySupport.java b/rsql-jpa/src/main/java/io/github/perplexhub/rsql/QuerySupport.java index bd8cb261..5c156c19 100644 --- a/rsql-jpa/src/main/java/io/github/perplexhub/rsql/QuerySupport.java +++ b/rsql-jpa/src/main/java/io/github/perplexhub/rsql/QuerySupport.java @@ -27,12 +27,20 @@ public class QuerySupport { private Map, List> propertyBlacklist; private Collection procedureWhiteList; private Collection procedureBlackList; + /** + * Postgresql {@code jsonb_path_exists} function to use + */ + private String jsonbPathExists; + /** + * Postgresql {@code jsonb_path_exists_tz} function to use + */ + private String jsonbPathExistsTz; public static class QuerySupportBuilder {} @Override public String toString() { - return String.format("%s,distinct:%b,propertyPathMapper:%s,customPredicates:%d,joinHints:%s,propertyWhitelist:%s,propertyBlacklist:%s", - rsqlQuery, distinct, propertyPathMapper, customPredicates == null ? 0 : customPredicates.size(), joinHints, propertyWhitelist, propertyBlacklist); + return String.format("%s,distinct:%b,propertyPathMapper:%s,customPredicates:%d,joinHints:%s,propertyWhitelist:%s,propertyBlacklist:%s,jsonbPathExists:%s,jsonbPathExistsTz:%s", + rsqlQuery, distinct, propertyPathMapper, customPredicates == null ? 0 : customPredicates.size(), joinHints, propertyWhitelist, propertyBlacklist, jsonbPathExists, jsonbPathExistsTz); } } diff --git a/rsql-jpa/src/main/java/io/github/perplexhub/rsql/RSQLJPAPredicateConverter.java b/rsql-jpa/src/main/java/io/github/perplexhub/rsql/RSQLJPAPredicateConverter.java index 9dcb9211..bbd8d156 100644 --- a/rsql-jpa/src/main/java/io/github/perplexhub/rsql/RSQLJPAPredicateConverter.java +++ b/rsql-jpa/src/main/java/io/github/perplexhub/rsql/RSQLJPAPredicateConverter.java @@ -37,6 +37,8 @@ public class RSQLJPAPredicateConverter extends RSQLVisitorBase private final Collection procedureBlackList; private final boolean strictEquality; private final Character likeEscapeCharacter; + private final String jsonbPathExists; + private final String jsonbPathExistsTz; public RSQLJPAPredicateConverter(CriteriaBuilder builder, Map propertyPathMapper) { this(builder, propertyPathMapper, null, null); @@ -54,6 +56,17 @@ public RSQLJPAPredicateConverter(CriteriaBuilder builder, Map pr this(builder, propertyPathMapper, customPredicates, joinHints, procedureWhiteList, procedureBlackList, false, null); } + public RSQLJPAPredicateConverter(CriteriaBuilder builder, + Map propertyPathMapper, + List> customPredicates, + Map joinHints, + Collection proceduresWhiteList, + Collection proceduresBlackList, + boolean strictEquality, + Character likeEscapeCharacter) { + this(builder, propertyPathMapper, customPredicates, joinHints, proceduresWhiteList, proceduresBlackList, strictEquality, likeEscapeCharacter, null, null); + } + public RSQLJPAPredicateConverter(CriteriaBuilder builder, Map propertyPathMapper, List> customPredicates, @@ -61,7 +74,9 @@ public RSQLJPAPredicateConverter(CriteriaBuilder builder, Collection proceduresWhiteList, Collection proceduresBlackList, boolean strictEquality, - Character likeEscapeCharacter) { + Character likeEscapeCharacter, + String jsonbPathExists, + String jsonbPathExistsTz) { this.builder = builder; this.propertyPathMapper = propertyPathMapper != null ? propertyPathMapper : Collections.emptyMap(); this.customPredicates = customPredicates != null ? customPredicates.stream().collect(Collectors.toMap(RSQLCustomPredicate::getOperator, Function.identity(), (a, b) -> a)) : Collections.emptyMap(); @@ -70,6 +85,8 @@ public RSQLJPAPredicateConverter(CriteriaBuilder builder, this.procedureBlackList = proceduresBlackList != null ? proceduresBlackList : Collections.emptyList(); this.strictEquality = strictEquality; this.likeEscapeCharacter = likeEscapeCharacter; + this.jsonbPathExists = jsonbPathExists; + this.jsonbPathExistsTz = jsonbPathExistsTz; } RSQLJPAContext findPropertyPath(String propertyPath, Path startRoot) { @@ -244,7 +261,7 @@ private ResolvedExpression resolveExpression(ComparisonNode node, From root, Sel String jsonbPath = JsonbSupport.jsonPathOfSelector(attribute, jsonSelector); if(jsonbPath.contains(".")) { ComparisonNode jsonbNode = node.withSelector(jsonbPath); - return JsonbSupport.jsonbPathExistsExpression(builder, jsonbNode, path); + return JsonbSupport.jsonbPathExistsExpression(builder, jsonbNode, path, jsonbPathExists, jsonbPathExistsTz); } else { final Expression expression; if (path instanceof JpaExpression jpaExpression) { diff --git a/rsql-jpa/src/main/java/io/github/perplexhub/rsql/RSQLJPASupport.java b/rsql-jpa/src/main/java/io/github/perplexhub/rsql/RSQLJPASupport.java index c9400c32..3ac4a1e5 100644 --- a/rsql-jpa/src/main/java/io/github/perplexhub/rsql/RSQLJPASupport.java +++ b/rsql-jpa/src/main/java/io/github/perplexhub/rsql/RSQLJPASupport.java @@ -127,7 +127,8 @@ public static Specification toSpecification(final QuerySupport querySuppo RSQLJPAPredicateConverter visitor = new RSQLJPAPredicateConverter(cb, querySupport.getPropertyPathMapper(), querySupport.getCustomPredicates(), querySupport.getJoinHints(), querySupport.getProcedureWhiteList(), querySupport.getProcedureBlackList(), - querySupport.isStrictEquality(), querySupport.getLikeEscapeCharacter()); + querySupport.isStrictEquality(), querySupport.getLikeEscapeCharacter(), + querySupport.getJsonbPathExists(), querySupport.getJsonbPathExistsTz()); visitor.setPropertyWhitelist(querySupport.getPropertyWhitelist()); visitor.setPropertyBlacklist(querySupport.getPropertyBlacklist()); diff --git a/rsql-jpa/src/main/java/io/github/perplexhub/rsql/jsonb/JsonbExpressionBuilder.java b/rsql-jpa/src/main/java/io/github/perplexhub/rsql/jsonb/JsonbExpressionBuilder.java index 5d946820..66832d67 100644 --- a/rsql-jpa/src/main/java/io/github/perplexhub/rsql/jsonb/JsonbExpressionBuilder.java +++ b/rsql-jpa/src/main/java/io/github/perplexhub/rsql/jsonb/JsonbExpressionBuilder.java @@ -15,6 +15,9 @@ */ public class JsonbExpressionBuilder { + private final String jsonbPathExistsTz; + private final String jsonbPathExists; + /** * The base json type. */ @@ -170,6 +173,10 @@ public ArgValue convert(String s) { private final List values; JsonbExpressionBuilder(ComparisonOperator operator, String keyPath, List args) { + this(operator, keyPath, args, null, null); + } + + JsonbExpressionBuilder(ComparisonOperator operator, String keyPath, List args, String jsonbPathExists, String jsonbPathExistsTz) { this.operator = Objects.requireNonNull(operator); this.keyPath = Objects.requireNonNull(keyPath); if(FORBIDDEN_NEGATION.contains(operator)) { @@ -189,6 +196,8 @@ public ArgValue convert(String s) { throw new IllegalArgumentException("Operator " + operator + " requires at least one value"); } this.values = findMoreTypes(operator, candidateValues); + this.jsonbPathExistsTz = jsonbPathExistsTz == null ? JSONB_PATH_EXISTS_TZ : jsonbPathExistsTz; + this.jsonbPathExists = jsonbPathExists == null ? JSONB_PATH_EXISTS : jsonbPathExists; } /** @@ -210,7 +219,7 @@ public JsonbPathExpression getJsonPathExpression() { List templateArguments = new ArrayList<>(); templateArguments.add(valueReference); templateArguments.addAll(valuesToCompare); - var function = isDateTimeTz ? JSONB_PATH_EXISTS_TZ : JSONB_PATH_EXISTS; + var function = isDateTimeTz ? jsonbPathExistsTz : jsonbPathExists; var expression = String.format("%s ? %s", targetPath, String.format(comparisonTemplate, templateArguments.toArray())); return new JsonbPathExpression(function, expression); } diff --git a/rsql-jpa/src/main/java/io/github/perplexhub/rsql/jsonb/JsonbSupport.java b/rsql-jpa/src/main/java/io/github/perplexhub/rsql/jsonb/JsonbSupport.java index 4c0e7be3..24dda423 100644 --- a/rsql-jpa/src/main/java/io/github/perplexhub/rsql/jsonb/JsonbSupport.java +++ b/rsql-jpa/src/main/java/io/github/perplexhub/rsql/jsonb/JsonbSupport.java @@ -16,9 +16,7 @@ import io.github.perplexhub.rsql.ResolvedExpression; import jakarta.persistence.Column; import jakarta.persistence.criteria.CriteriaBuilder; -import jakarta.persistence.criteria.Expression; import jakarta.persistence.criteria.Path; -import jakarta.persistence.criteria.Predicate; import jakarta.persistence.metamodel.Attribute; import jakarta.persistence.metamodel.ManagedType; import org.springframework.orm.jpa.vendor.Database; @@ -63,9 +61,9 @@ record JsonbPathExpression(String jsonbFunction, String jsonbPath) { } - public static ResolvedExpression jsonbPathExistsExpression(CriteriaBuilder builder, ComparisonNode node, Path attrPath) { + public static ResolvedExpression jsonbPathExistsExpression(CriteriaBuilder builder, ComparisonNode node, Path attrPath, String jsonbPathExists, String jsonbPathExistsTz) { var mayBeInvertedOperator = Optional.ofNullable(NEGATE_OPERATORS.get(node.getOperator())); - var jsb = new JsonbExpressionBuilder(mayBeInvertedOperator.orElse(node.getOperator()), node.getSelector(), node.getArguments()); + var jsb = new JsonbExpressionBuilder(mayBeInvertedOperator.orElse(node.getOperator()), node.getSelector(), node.getArguments(), jsonbPathExists, jsonbPathExistsTz); var expression = jsb.getJsonPathExpression(); return ResolvedExpression.ofJson(builder.function(expression.jsonbFunction, Boolean.class, attrPath, builder.literal(expression.jsonbPath)), mayBeInvertedOperator.isPresent()); diff --git a/rsql-jpa/src/test/java/io/github/perplexhub/rsql/RSQLJPASupportPostgresJsonTest.java b/rsql-jpa/src/test/java/io/github/perplexhub/rsql/RSQLJPASupportPostgresJsonTest.java index bd0d86af..b8c680bb 100644 --- a/rsql-jpa/src/test/java/io/github/perplexhub/rsql/RSQLJPASupportPostgresJsonTest.java +++ b/rsql-jpa/src/test/java/io/github/perplexhub/rsql/RSQLJPASupportPostgresJsonTest.java @@ -17,6 +17,7 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.orm.jpa.vendor.Database; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.jdbc.Sql; import java.util.Collection; import java.util.HashMap; @@ -542,9 +543,9 @@ private static Stream meltedTimeZone() { var e3 = new PostgresJsonEntity(Map.of("a", "2020-01-01T00:00:00")); var allCases = List.of(e1, e2, e3); return Stream.of( - arguments(allCases, "properties.a=ge=1970-01-02T00:00:00+00:00", List.of(e2, e3)), - arguments(allCases, "properties.a=ge=1970-01-02T00:00:00+01:00", List.of(e2, e3)), - arguments(allCases, "properties.a=lt=2022-01-01T00:00:00+01:00", List.of(e1, e2, e3)), + arguments(allCases, "properties.a=ge=1970-01-02T00:00:00+00:00", List.of(e2, e3)), + arguments(allCases, "properties.a=ge=1970-01-02T00:00:00+01:00", List.of(e2, e3)), + arguments(allCases, "properties.a=lt=2022-01-01T00:00:00+01:00", List.of(e1, e2, e3)), null ).filter(Objects::nonNull); } @@ -721,4 +722,23 @@ private static Map nullMap(String key) { nullValue.put(key, null); return nullValue; } + + @Sql(statements = "CREATE OR REPLACE FUNCTION my_jsonb_path_exists(arg1 jsonb,arg2 jsonpath) RETURNS boolean AS 'SELECT $1 @? $2' LANGUAGE 'sql' IMMUTABLE;") + @Sql(statements = "DROP FUNCTION IF EXISTS my_jsonb_path_exists;", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) + @ParameterizedTest + @MethodSource("data") + void testJsonSearchCustomFunction(List entities, String rsql, List expected) { + //given + repository.saveAllAndFlush(entities); + + //when + List result = repository.findAll(toSpecification(new QuerySupport.QuerySupportBuilder().rsqlQuery(rsql).jsonbPathExists("my_jsonb_path_exists").build())); + + //then + assertThat(result) + .hasSameSizeAs(expected) + .containsExactlyInAnyOrderElementsOf(expected); + + entities.forEach(e -> e.setId(null)); + } } diff --git a/rsql-jpa/src/test/java/io/github/perplexhub/rsql/jsonb/JsonbExpressionBuilderTest.java b/rsql-jpa/src/test/java/io/github/perplexhub/rsql/jsonb/JsonbExpressionBuilderTest.java index c97d7d34..a801b987 100644 --- a/rsql-jpa/src/test/java/io/github/perplexhub/rsql/jsonb/JsonbExpressionBuilderTest.java +++ b/rsql-jpa/src/test/java/io/github/perplexhub/rsql/jsonb/JsonbExpressionBuilderTest.java @@ -37,6 +37,16 @@ void testJsonbPathExpressionWithTemporal(ComparisonOperator operator, String key assertEquals(expectedJsonbPath, expression.jsonbPath()); } + @ParameterizedTest + @MethodSource("customized") + void testJsonbPathExpressionCustomized(ComparisonOperator operator, String keyPath, List arguments, String expectedJsonbFunction, String expectedJsonbPath) { + JsonbSupport.DATE_TIME_SUPPORT = true; + JsonbExpressionBuilder builder = new JsonbExpressionBuilder(operator, keyPath, arguments, "my_jsonb_path_exists", "my_jsonb_path_exists_tz"); + var expression = builder.getJsonPathExpression(); + assertEquals(expectedJsonbFunction, expression.jsonbFunction()); + assertEquals(expectedJsonbPath, expression.jsonbPath()); + } + static Stream data() { return Stream.of( allOperators(), @@ -78,6 +88,17 @@ static Stream conversion() { ).filter(Objects::nonNull); } + static Stream customized() { + + return Stream.of( + arguments(RSQLOperators.EQUAL, "json.equal_key", Collections.singletonList("value"), "my_jsonb_path_exists", "$.equal_key ? (@ == \"value\")"), + arguments(RSQLOperators.GREATER_THAN, "json.greater_than_key", Collections.singletonList("value"), "my_jsonb_path_exists", "$.greater_than_key ? (@ > \"value\")"), + arguments(RSQLOperators.EQUAL, "json.equal_key", Collections.singletonList("1970-01-01T00:00:00.000"), "my_jsonb_path_exists", "$.equal_key ? (@.datetime() == \"1970-01-01T00:00:00.000\".datetime())"), + arguments(RSQLOperators.EQUAL, "json.equal_key", Collections.singletonList("1970-01-01T00:00:00.000Z"), "my_jsonb_path_exists_tz", "$.equal_key ? (@.datetime() == \"1970-01-01T00:00:00.000Z\".datetime())"), + null + ).filter(Objects::nonNull); + } + static Stream temporal() { return Stream.of(