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..68136c84 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 @@ -1,5 +1,6 @@ package io.github.perplexhub.rsql; +import io.github.perplexhub.rsql.jsonb.JsonbConfiguration; import lombok.Builder; import lombok.Data; @@ -27,12 +28,14 @@ public class QuerySupport { private Map, List> propertyBlacklist; private Collection procedureWhiteList; private Collection procedureBlackList; + @Builder.Default + private JsonbConfiguration jsonbConfiguration = JsonbConfiguration.DEFAULT; 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,jsonbConfiguration:%s", + rsqlQuery, distinct, propertyPathMapper, customPredicates == null ? 0 : customPredicates.size(), joinHints, propertyWhitelist, propertyBlacklist, jsonbConfiguration); } } 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..f6162f84 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 @@ -8,6 +8,7 @@ import java.util.function.Function; import java.util.stream.Collectors; +import io.github.perplexhub.rsql.jsonb.JsonbConfiguration; import io.github.perplexhub.rsql.jsonb.JsonbSupport; import jakarta.persistence.criteria.*; import jakarta.persistence.metamodel.Attribute; @@ -37,6 +38,7 @@ public class RSQLJPAPredicateConverter extends RSQLVisitorBase private final Collection procedureBlackList; private final boolean strictEquality; private final Character likeEscapeCharacter; + private final JsonbConfiguration jsonbConfiguration; public RSQLJPAPredicateConverter(CriteriaBuilder builder, Map propertyPathMapper) { this(builder, propertyPathMapper, null, null); @@ -54,14 +56,26 @@ 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, JsonbConfiguration.DEFAULT); + } + public RSQLJPAPredicateConverter(CriteriaBuilder builder, - Map propertyPathMapper, - List> customPredicates, - Map joinHints, - Collection proceduresWhiteList, - Collection proceduresBlackList, - boolean strictEquality, - Character likeEscapeCharacter) { + Map propertyPathMapper, + List> customPredicates, + Map joinHints, + Collection proceduresWhiteList, + Collection proceduresBlackList, + boolean strictEquality, + Character likeEscapeCharacter, + JsonbConfiguration jsonbConfiguration) { 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 +84,7 @@ public RSQLJPAPredicateConverter(CriteriaBuilder builder, this.procedureBlackList = proceduresBlackList != null ? proceduresBlackList : Collections.emptyList(); this.strictEquality = strictEquality; this.likeEscapeCharacter = likeEscapeCharacter; + this.jsonbConfiguration = jsonbConfiguration; } RSQLJPAContext findPropertyPath(String propertyPath, Path startRoot) { @@ -244,7 +259,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, jsonbConfiguration); } 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..0ddadd63 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.getJsonbConfiguration()); visitor.setPropertyWhitelist(querySupport.getPropertyWhitelist()); visitor.setPropertyBlacklist(querySupport.getPropertyBlacklist()); diff --git a/rsql-jpa/src/main/java/io/github/perplexhub/rsql/jsonb/JsonbConfiguration.java b/rsql-jpa/src/main/java/io/github/perplexhub/rsql/jsonb/JsonbConfiguration.java new file mode 100644 index 00000000..4bec7cbb --- /dev/null +++ b/rsql-jpa/src/main/java/io/github/perplexhub/rsql/jsonb/JsonbConfiguration.java @@ -0,0 +1,29 @@ +package io.github.perplexhub.rsql.jsonb; + +import lombok.Builder; + +/** + * convenient way to define configuration, based on default values + * + * @param pathExists Postgresql {@code jsonb_path_exists} function to use + * @param pathExistsTz Postgresql {@code jsonb_path_exists_tz} function to use + * @param useDateTime enable temporal values support + */ +@Builder +public record JsonbConfiguration(String pathExists, String pathExistsTz, boolean useDateTime) { + + public static final JsonbConfiguration DEFAULT = JsonbConfiguration.builder().build(); + + public static class JsonbConfigurationBuilder { + JsonbConfigurationBuilder() { + pathExists = "jsonb_path_exists"; + pathExistsTz = "jsonb_path_exists_tz"; + useDateTime = false; + } + } + + @Override + public String toString() { + return String.format("pathExists:%s,pathExistsTz:%s,useDateTime:%b", pathExists, pathExistsTz, useDateTime); + } +} 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..c9333f12 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,8 @@ */ public class JsonbExpressionBuilder { + private final JsonbConfiguration configuration; + /** * The base json type. */ @@ -161,15 +163,15 @@ public ArgValue convert(String s) { Map.entry(BETWEEN, "(%1$s >= %2$s && %1$s <= %3$s)") ); - private static final String JSONB_PATH_EXISTS = "jsonb_path_exists"; - - private static final String JSONB_PATH_EXISTS_TZ = "jsonb_path_exists_tz"; - private final ComparisonOperator operator; private final String keyPath; private final List values; JsonbExpressionBuilder(ComparisonOperator operator, String keyPath, List args) { + this(operator, keyPath, args, JsonbConfiguration.DEFAULT); + } + + JsonbExpressionBuilder(ComparisonOperator operator, String keyPath, List args, JsonbConfiguration configuration) { this.operator = Objects.requireNonNull(operator); this.keyPath = Objects.requireNonNull(keyPath); if(FORBIDDEN_NEGATION.contains(operator)) { @@ -188,6 +190,7 @@ public ArgValue convert(String s) { if(REQUIRE_AT_LEAST_ONE_ARGUMENT.contains(operator) && candidateValues.isEmpty()) { throw new IllegalArgumentException("Operator " + operator + " requires at least one value"); } + this.configuration = configuration; this.values = findMoreTypes(operator, candidateValues); } @@ -210,7 +213,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 ? configuration.pathExistsTz() : configuration.pathExists(); var expression = String.format("%s ? %s", targetPath, String.format(comparisonTemplate, templateArguments.toArray())); return new JsonbPathExpression(function, expression); } @@ -234,7 +237,7 @@ private List findMoreTypes(ComparisonOperator operator, List v return values.stream().map(s -> new ArgValue(s, BaseJsonType.STRING)).toList(); } - List argConverters = DATE_TIME_SUPPORT ? + List argConverters = configuration.useDateTime() ? List.of(DATE_TIME_CONVERTER, DATE_TIME_CONVERTER_TZ, NUMBER_CONVERTER, BOOLEAN_ARG_CONVERTER) : List.of(NUMBER_CONVERTER, BOOLEAN_ARG_CONVERTER); Optional candidateConverter = argConverters.stream() 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..bac49e1c 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; @@ -28,8 +26,6 @@ */ public class JsonbSupport { - public static boolean DATE_TIME_SUPPORT = false; - private static final Set JSON_SUPPORT = EnumSet.of(Database.POSTGRESQL); private static final Map NEGATE_OPERATORS = @@ -63,9 +59,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, JsonbConfiguration configuration) { 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(), configuration); 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..7d968634 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 @@ -1,6 +1,6 @@ package io.github.perplexhub.rsql; -import io.github.perplexhub.rsql.jsonb.JsonbSupport; +import io.github.perplexhub.rsql.jsonb.JsonbConfiguration; import io.github.perplexhub.rsql.model.EntityWithJsonb; import io.github.perplexhub.rsql.model.JsonbEntity; import io.github.perplexhub.rsql.model.PostgresJsonEntity; @@ -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; @@ -47,7 +48,6 @@ class RSQLJPASupportPostgresJsonTest { void setup(@Autowired EntityManager em) { RSQLVisitorBase.setEntityManagerDatabase(Map.of(em, Database.POSTGRESQL)); clear(); - JsonbSupport.DATE_TIME_SUPPORT = false; } @AfterEach @@ -83,12 +83,11 @@ void testJsonSearch(List entities, String rsql, List entities, String rsql, List expected) { - JsonbSupport.DATE_TIME_SUPPORT = true; //given repository.saveAllAndFlush(entities); //when - List result = repository.findAll(toSpecification(rsql)); + List result = repository.findAll(toSpecification(QuerySupport.builder().rsqlQuery(rsql).jsonbConfiguration(JsonbConfiguration.builder().useDateTime(true).build()).build())); //then assertThat(result) @@ -161,7 +160,6 @@ void testJsonSearchOnMappedRelation(List jsonbEntities, String rsql @ParameterizedTest @MethodSource("sortByRelation") void testJsonSortOnRelation(List jsonbEntities, String rsql, List expected) { - JsonbSupport.DATE_TIME_SUPPORT = true; //given Collection entitiesWithJsonb = jsonbEntityRepository.saveAllAndFlush(jsonbEntities).stream() .map(jsonbEntity -> EntityWithJsonb.builder().jsonb(jsonbEntity).build()) @@ -183,7 +181,6 @@ void testJsonSortOnRelation(List jsonbEntities, String rsql, List jsonbEntities, String rsql, List expected) { - JsonbSupport.DATE_TIME_SUPPORT = true; //given Collection entitiesWithJsonb = jsonbEntityRepository.saveAllAndFlush(jsonbEntities).stream() .map(jsonbEntity -> EntityWithJsonb.builder().jsonb(jsonbEntity).build()) @@ -542,9 +539,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 +718,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(QuerySupport.builder().rsqlQuery(rsql).jsonbConfiguration(JsonbConfiguration.builder().pathExists("my_jsonb_path_exists").build()).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..f0b15431 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 @@ -20,8 +20,7 @@ class JsonbExpressionBuilderTest { @ParameterizedTest @MethodSource("data") void testJsonbPathExpression(ComparisonOperator operator, String keyPath, List arguments, String expectedJsonbFunction, String expectedJsonbPath) { - JsonbSupport.DATE_TIME_SUPPORT = false; - JsonbExpressionBuilder builder = new JsonbExpressionBuilder(operator, keyPath, arguments); + JsonbExpressionBuilder builder = new JsonbExpressionBuilder(operator, keyPath, arguments, JsonbConfiguration.DEFAULT); var expression = builder.getJsonPathExpression(); assertEquals(expectedJsonbFunction, expression.jsonbFunction()); assertEquals(expectedJsonbPath, expression.jsonbPath()); @@ -30,8 +29,16 @@ void testJsonbPathExpression(ComparisonOperator operator, String keyPath, List arguments, String expectedJsonbFunction, String expectedJsonbPath) { - JsonbSupport.DATE_TIME_SUPPORT = true; - JsonbExpressionBuilder builder = new JsonbExpressionBuilder(operator, keyPath, arguments); + JsonbExpressionBuilder builder = new JsonbExpressionBuilder(operator, keyPath, arguments, JsonbConfiguration.builder().useDateTime(true).build()); + var expression = builder.getJsonPathExpression(); + assertEquals(expectedJsonbFunction, expression.jsonbFunction()); + assertEquals(expectedJsonbPath, expression.jsonbPath()); + } + + @ParameterizedTest + @MethodSource("customized") + void testJsonbPathExpressionCustomized(ComparisonOperator operator, String keyPath, List arguments, String expectedJsonbFunction, String expectedJsonbPath) { + JsonbExpressionBuilder builder = new JsonbExpressionBuilder(operator, keyPath, arguments, JsonbConfiguration.builder().pathExists("my_jsonb_path_exists").pathExistsTz("my_jsonb_path_exists_tz").useDateTime(true).build()); var expression = builder.getJsonPathExpression(); assertEquals(expectedJsonbFunction, expression.jsonbFunction()); assertEquals(expectedJsonbPath, expression.jsonbPath()); @@ -78,6 +85,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(