From 9b83ea7405be471682bb63eb9e44ab0868f6fe2a Mon Sep 17 00:00:00 2001 From: Edgar Asatryan Date: Sun, 27 Aug 2023 20:22:55 +0400 Subject: [PATCH] Basic support for queries against Postgres jsonb type. Closes: #114 --- rsql-common/pom.xml | 6 + .../perplexhub/rsql/RSQLVisitorBase.java | 3 + .../rsql/RSQLJPAAutoConfiguration.java | 98 +++++++++- rsql-jpa/pom.xml | 17 ++ .../rsql/RSQLJPAPredicateConverter.java | 118 +++++++++--- .../perplexhub/rsql/RSQLJPASupport.java | 6 + .../rsql/RSQLJPASupportPostgresJsonTest.java | 173 ++++++++++++++++++ .../rsql/model/PostgresJsonEntity.java | 44 +++++ .../PostgresJsonEntityRepository.java | 11 ++ .../test/resources/application-postgres.yml | 7 + 10 files changed, 446 insertions(+), 37 deletions(-) create mode 100644 rsql-jpa/src/test/java/io/github/perplexhub/rsql/RSQLJPASupportPostgresJsonTest.java create mode 100644 rsql-jpa/src/test/java/io/github/perplexhub/rsql/model/PostgresJsonEntity.java create mode 100644 rsql-jpa/src/test/java/io/github/perplexhub/rsql/repository/jpa/postgres/PostgresJsonEntityRepository.java create mode 100644 rsql-jpa/src/test/resources/application-postgres.yml diff --git a/rsql-common/pom.xml b/rsql-common/pom.xml index fb06a29a..1f07b503 100644 --- a/rsql-common/pom.xml +++ b/rsql-common/pom.xml @@ -37,6 +37,12 @@ hamcrest test + + io.hypersistence + hypersistence-utils-hibernate-62 + 3.5.1 + test + com.h2database h2 diff --git a/rsql-common/src/main/java/io/github/perplexhub/rsql/RSQLVisitorBase.java b/rsql-common/src/main/java/io/github/perplexhub/rsql/RSQLVisitorBase.java index 81132467..8bb317f2 100644 --- a/rsql-common/src/main/java/io/github/perplexhub/rsql/RSQLVisitorBase.java +++ b/rsql-common/src/main/java/io/github/perplexhub/rsql/RSQLVisitorBase.java @@ -12,8 +12,10 @@ import jakarta.persistence.metamodel.ManagedType; import jakarta.persistence.metamodel.PluralAttribute; +import lombok.Getter; import org.springframework.core.convert.ConversionFailedException; import org.springframework.core.convert.support.ConfigurableConversionService; +import org.springframework.orm.jpa.vendor.Database; import org.springframework.util.StringUtils; import cz.jirutka.rsql.parser.ast.RSQLVisitor; @@ -27,6 +29,7 @@ public abstract class RSQLVisitorBase implements RSQLVisitor { protected static volatile @Setter Map managedTypeMap; protected static volatile @Setter Map entityManagerMap; + protected static volatile @Setter @Getter Map entityManagerDatabase = Map.of(); protected static final Map primitiveToWrapper; protected static volatile @Setter Map, Map> propertyRemapping; protected static volatile @Setter Map, List> globalPropertyWhitelist; diff --git a/rsql-jpa-spring-boot-starter/src/main/java/io/github/perplexhub/rsql/RSQLJPAAutoConfiguration.java b/rsql-jpa-spring-boot-starter/src/main/java/io/github/perplexhub/rsql/RSQLJPAAutoConfiguration.java index cc30873f..29f7ba2c 100644 --- a/rsql-jpa-spring-boot-starter/src/main/java/io/github/perplexhub/rsql/RSQLJPAAutoConfiguration.java +++ b/rsql-jpa-spring-boot-starter/src/main/java/io/github/perplexhub/rsql/RSQLJPAAutoConfiguration.java @@ -1,24 +1,104 @@ package io.github.perplexhub.rsql; -import java.util.Map; +import static java.util.stream.Collectors.collectingAndThen; +import static java.util.stream.Collectors.toMap; +import io.github.perplexhub.rsql.RSQLJPAAutoConfiguration.HibernateEntityManagerDatabaseConfiguration; import jakarta.persistence.EntityManager; - +import java.util.IdentityHashMap; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Objects; +import java.util.Optional; +import lombok.extern.slf4j.Slf4j; +import org.hibernate.Session; +import org.hibernate.dialect.AbstractHANADialect; +import org.hibernate.dialect.CockroachDialect; +import org.hibernate.dialect.DB2Dialect; +import org.hibernate.dialect.DerbyDialect; +import org.hibernate.dialect.Dialect; +import org.hibernate.dialect.H2Dialect; +import org.hibernate.dialect.HSQLDialect; +import org.hibernate.dialect.MySQLDialect; +import org.hibernate.dialect.OracleDialect; +import org.hibernate.dialect.PostgreSQLDialect; +import org.hibernate.dialect.SQLServerDialect; +import org.hibernate.dialect.SybaseDialect; +import org.hibernate.engine.spi.SessionImplementor; +import org.hibernate.internal.SessionFactoryImpl; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; - -import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Import; +import org.springframework.orm.jpa.vendor.Database; @Slf4j @Configuration @ConditionalOnClass(EntityManager.class) +@Import(HibernateEntityManagerDatabaseConfiguration.class) public class RSQLJPAAutoConfiguration { - @Bean - public RSQLCommonSupport rsqlCommonSupport(Map entityManagerMap) { - log.info("RSQLJPAAutoConfiguration.rsqlCommonSupport(entityManagerMap:{})", entityManagerMap.size()); - return new RSQLCommonSupport(entityManagerMap); - } + @Bean + public RSQLCommonSupport rsqlCommonSupport(Map entityManagerMap, + ObjectProvider entityManagerDatabaseProvider) { + log.info("RSQLJPAAutoConfiguration.rsqlCommonSupport(entityManagerMap:{})", entityManagerMap.size()); + var entityManagerDatabase = entityManagerDatabaseProvider.getIfAvailable(() -> new EntityManagerDatabase(Map.of())); + + return new RSQLJPASupport(entityManagerMap, entityManagerDatabase.value()); + } + + @Configuration + @ConditionalOnClass(SessionImplementor.class) + static + class HibernateEntityManagerDatabaseConfiguration { + + @Bean + public EntityManagerDatabase entityManagerDatabase(ObjectProvider entityManagers) { + return entityManagers.stream() + .map(entityManager -> { + var sessionFactory = entityManager.unwrap(Session.class).getSessionFactory(); + var dialect = ((SessionFactoryImpl) sessionFactory).getJdbcServices().getDialect(); + + return Optional.ofNullable(toDatabase(dialect)) + .map(db -> Map.entry(entityManager, db)) + .orElse(null); + }) + .filter(Objects::nonNull) + .collect(collectingAndThen( + toMap(Entry::getKey, Entry::getValue, (db1, db2) -> db1, IdentityHashMap::new), + EntityManagerDatabase::new + )); + } + + private Database toDatabase(Dialect dialect) { + if (dialect instanceof PostgreSQLDialect || dialect instanceof CockroachDialect) { + return Database.POSTGRESQL; + } else if (dialect instanceof MySQLDialect) { + return Database.MYSQL; + } else if (dialect instanceof SQLServerDialect) { + return Database.SQL_SERVER; + } else if (dialect instanceof OracleDialect) { + return Database.ORACLE; + } else if (dialect instanceof DerbyDialect) { + return Database.DERBY; + } else if (dialect instanceof DB2Dialect) { + return Database.DB2; + } else if (dialect instanceof H2Dialect) { + return Database.H2; + } else if (dialect instanceof AbstractHANADialect) { + return Database.HANA; + } else if (dialect instanceof HSQLDialect) { + return Database.HSQL; + } else if (dialect instanceof SybaseDialect) { + return Database.SQL_SERVER; + } + + return null; + } + } + + record EntityManagerDatabase(Map value) { + } } diff --git a/rsql-jpa/pom.xml b/rsql-jpa/pom.xml index 8e31e7df..db3c3541 100644 --- a/rsql-jpa/pom.xml +++ b/rsql-jpa/pom.xml @@ -41,6 +41,23 @@ h2 test + + org.postgresql + postgresql + test + + + org.testcontainers + postgresql + 1.19.0 + test + + + io.hypersistence + hypersistence-utils-hibernate-60 + 3.5.2 + test + org.projectlombok lombok 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 a3bdd46d..9d858347 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 @@ -2,6 +2,8 @@ import static io.github.perplexhub.rsql.RSQLOperators.*; +import jakarta.persistence.Column; +import java.lang.reflect.Field; import java.lang.reflect.Modifier; import java.util.*; import java.util.function.Function; @@ -18,14 +20,18 @@ import cz.jirutka.rsql.parser.ast.ComparisonNode; import cz.jirutka.rsql.parser.ast.ComparisonOperator; import cz.jirutka.rsql.parser.ast.OrNode; +import java.util.stream.Stream; import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.reflections.Reflections; +import org.springframework.orm.jpa.vendor.Database; @Slf4j @SuppressWarnings({ "rawtypes", "unchecked" }) public class RSQLJPAPredicateConverter extends RSQLVisitorBase { + private static final Set JSON_SUPPORT = EnumSet.of(Database.POSTGRESQL); + private final CriteriaBuilder builder; private final Map cachedJoins = new HashMap<>(); private final @Getter Map propertyPathMapper; @@ -133,6 +139,10 @@ RSQLJPAContext findPropertyPath(String propertyPath, Path startRoot) { String keyJoin = getKeyJoin(root, mappedProperty); log.debug("Create a element collection join between [{}] and [{}] using key [{}]", previousClass, classMetadata.getJavaType().getName(), keyJoin); root = join(keyJoin, root, mappedProperty); + } else if (isJsonType(mappedProperty, classMetadata)) { + root = root.get(mappedProperty); + attribute = classMetadata.getAttribute(mappedProperty); + break; } else { log.debug("Create property path for type [{}] property [{}]", classMetadata.getJavaType().getName(), mappedProperty); root = root.get(mappedProperty); @@ -156,6 +166,35 @@ RSQLJPAContext findPropertyPath(String propertyPath, Path startRoot) { return RSQLJPAContext.of(root, attribute); } + private boolean isJsonType(String mappedProperty, ManagedType classMetadata) { + return Optional.ofNullable(classMetadata.getAttribute(mappedProperty)) + .map(this::isJsonType) + .orElse(false); + } + + private boolean isJsonType(Attribute attribute) { + return isJsonColumn(attribute) && getDatabase(attribute).map(JSON_SUPPORT::contains).orElse(false); + } + + private boolean isJsonColumn(Attribute attribute) { + return Optional.ofNullable(attribute) + .filter(attr -> attr.getJavaMember() instanceof Field) + .map(attr -> ((Field) attr.getJavaMember())) + .map(field -> field.getAnnotation(Column.class)) + .map(Column::columnDefinition) + .map("jsonb"::equalsIgnoreCase) + .orElse(false); + } + + private Optional getDatabase(Attribute attribute) { + return getEntityManagerMap() + .values() + .stream() + .filter(em -> em.getMetamodel().getManagedTypes().contains(attribute.getDeclaringType())) + .findFirst() + .map(em -> getEntityManagerDatabase().get(em)); + } + private String getKeyJoin(Path root, String mappedProperty) { return root.getJavaType().getSimpleName().concat(".").concat(mappedProperty); } @@ -194,7 +233,9 @@ public Predicate visit(ComparisonNode node, From root) { return customPredicate.getConverter().apply(RSQLCustomPredicateInput.of(builder, attrPath, attribute, arguments, root)); } - Class type = attribute != null ? attribute.getJavaType() : null; + final boolean json = isJsonType(attribute); + Class type = json ? String.class : attribute != null ? attribute.getJavaType() : null; + if (attribute != null) { if (attribute.getPersistentAttributeType() == PersistentAttributeType.ELEMENT_COLLECTION) { type = getElementCollectionGenericType(type, attribute); @@ -205,6 +246,7 @@ public Predicate visit(ComparisonNode node, From root) { type = RSQLJPASupport.getValueTypeMap().get(type); // if you want to treat Enum as String and apply like search, etc } } + Expression expr = json ? getJsonExpression(attrPath, attribute, node) : attrPath; if (node.getArguments().size() > 1) { List listObject = new ArrayList<>(); @@ -212,51 +254,52 @@ public Predicate visit(ComparisonNode node, From root) { listObject.add(convert(argument, type)); } if (op.equals(IN)) { - return attrPath.in(listObject); + return expr.in(listObject); } if (op.equals(NOT_IN)) { - return attrPath.in(listObject).not(); + return expr.in(listObject).not(); } if (op.equals(BETWEEN) && listObject.size() == 2 && listObject.get(0) instanceof Comparable && listObject.get(1) instanceof Comparable) { - return builder.between(attrPath, (Comparable) listObject.get(0), (Comparable) listObject.get(1)); + return builder.between(expr, (Comparable) listObject.get(0), (Comparable) listObject.get(1)); } if (op.equals(NOT_BETWEEN) && listObject.size() == 2 && listObject.get(0) instanceof Comparable && listObject.get(1) instanceof Comparable) { - return builder.between(attrPath, (Comparable) listObject.get(0), (Comparable) listObject.get(1)).not(); + return builder.between(expr, (Comparable) listObject.get(0), (Comparable) listObject.get(1)).not(); } } else { + if (op.equals(IS_NULL)) { - return builder.isNull(attrPath); + return builder.isNull(expr); } if (op.equals(NOT_NULL)) { - return builder.isNotNull(attrPath); + return builder.isNotNull(expr); } Object argument = convert(node.getArguments().get(0), type); if (op.equals(IN)) { - return builder.equal(attrPath, argument); + return builder.equal(expr, argument); } if (op.equals(NOT_IN)) { - return builder.notEqual(attrPath, argument); + return builder.notEqual(expr, argument); } if (op.equals(LIKE)) { - return builder.like(attrPath, "%" + argument.toString() + "%"); + return builder.like(expr, "%" + argument.toString() + "%"); } if (op.equals(NOT_LIKE)) { - return builder.like(attrPath, "%" + argument.toString() + "%").not(); + return builder.like(expr, "%" + argument.toString() + "%").not(); } if (op.equals(IGNORE_CASE)) { - return builder.equal(builder.upper(attrPath), argument.toString().toUpperCase()); + return builder.equal(builder.upper(expr), argument.toString().toUpperCase()); } if (op.equals(IGNORE_CASE_LIKE)) { - return builder.like(builder.upper(attrPath), "%" + argument.toString().toUpperCase() + "%"); + return builder.like(builder.upper(expr), "%" + argument.toString().toUpperCase() + "%"); } if (op.equals(IGNORE_CASE_NOT_LIKE)) { - return builder.like(builder.upper(attrPath), "%" + argument.toString().toUpperCase() + "%").not(); + return builder.like(builder.upper(expr), "%" + argument.toString().toUpperCase() + "%").not(); } if (op.equals(EQUAL)) { - return equalPredicate(attrPath, type, argument); + return equalPredicate(expr, type, argument); } if (op.equals(NOT_EQUAL)) { - return equalPredicate(attrPath, type, argument).not(); + return equalPredicate(expr, type, argument).not(); } if (!Comparable.class.isAssignableFrom(type)) { log.error("Operator {} can be used only for Comparables", op); @@ -265,43 +308,62 @@ public Predicate visit(ComparisonNode node, From root) { Comparable comparable = (Comparable) argument; if (op.equals(GREATER_THAN)) { - return builder.greaterThan(attrPath, comparable); + return builder.greaterThan(expr, comparable); } if (op.equals(GREATER_THAN_OR_EQUAL)) { - return builder.greaterThanOrEqualTo(attrPath, comparable); + return builder.greaterThanOrEqualTo(expr, comparable); } if (op.equals(LESS_THAN)) { - return builder.lessThan(attrPath, comparable); + return builder.lessThan(expr, comparable); } if (op.equals(LESS_THAN_OR_EQUAL)) { - return builder.lessThanOrEqualTo(attrPath, comparable); + return builder.lessThanOrEqualTo(expr, comparable); } } log.error("Unknown operator: {}", op); throw new RSQLException("Unknown operator: " + op); } - private Predicate equalPredicate(Path attrPath, Class type, Object argument) { + private Expression getJsonExpression(Path path, Attribute attribute, ComparisonNode node) { + var database = getDatabase(attribute).orElse(null); + + if (database == Database.POSTGRESQL) { + var args = new ArrayList>(); + args.add(path); + + Stream.of(node.getSelector().split("\\.")) + .skip(1) // skip root + .map(builder::literal) + .map(expr -> expr.as(String.class)) + .forEach(args::add); + + return builder.function("jsonb_extract_path_text", String.class, args.toArray(Expression[]::new)); + } + + return path; + } + + private Predicate equalPredicate(Expression expr, Class type, Object argument) { if (type.equals(String.class)) { String argStr = argument.toString(); if (strictEquality) { - return builder.equal(attrPath, argument); + return builder.equal(expr, argument); } else { if (argStr.contains("*") && argStr.contains("^")) { - return builder.like(builder.upper(attrPath), argStr.replace('*', '%').replace("^", "").toUpperCase()); + return builder.like(builder.upper(expr), argStr.replace('*', '%').replace("^", "").toUpperCase()); } else if (argStr.contains("*")) { - return builder.like(attrPath, argStr.replace('*', '%')); + return builder.like(expr, argStr.replace('*', '%')); } else if (argStr.contains("^")) { - return builder.equal(builder.upper(attrPath), argStr.replace("^", "").toUpperCase()); + return builder.equal(builder.upper(expr), argStr.replace("^", "").toUpperCase()); } else { - return builder.equal(attrPath, argument); + return builder.equal(expr, argument); } } } else if (argument == null) { - return builder.isNull(attrPath); + return builder.isNull(expr); } else { - return builder.equal(attrPath, argument); + return builder.equal(expr, argument); } } 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 b032e188..c07c2b09 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 @@ -15,6 +15,7 @@ import org.springframework.data.jpa.domain.Specification; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.lang.Nullable; +import org.springframework.orm.jpa.vendor.Database; import org.springframework.util.StringUtils; import cz.jirutka.rsql.parser.RSQLParser; @@ -31,7 +32,12 @@ public RSQLJPASupport() { } public RSQLJPASupport(Map entityManagerMap) { + this(entityManagerMap, Map.of()); + } + + public RSQLJPASupport(Map entityManagerMap, Map entityManagerDatabase) { super(entityManagerMap); + RSQLVisitorBase.setEntityManagerDatabase(entityManagerDatabase); } public static Specification rsql(final String rsqlQuery) { 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 new file mode 100644 index 00000000..0f42a842 --- /dev/null +++ b/rsql-jpa/src/test/java/io/github/perplexhub/rsql/RSQLJPASupportPostgresJsonTest.java @@ -0,0 +1,173 @@ +package io.github.perplexhub.rsql; + +import static io.github.perplexhub.rsql.RSQLJPASupport.toSpecification; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.params.provider.Arguments.arguments; + +import io.github.perplexhub.rsql.model.PostgresJsonEntity; +import io.github.perplexhub.rsql.repository.jpa.postgres.PostgresJsonEntityRepository; +import jakarta.persistence.EntityManager; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.orm.jpa.vendor.Database; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles("postgres") +class RSQLJPASupportPostgresJsonTest { + + @Autowired + private PostgresJsonEntityRepository repository; + + @BeforeEach + void setup(@Autowired EntityManager em) { + RSQLVisitorBase.setEntityManagerDatabase(Map.of(em, Database.POSTGRESQL)); + } + + @AfterEach + void tearDown() { + repository.deleteAll(); + repository.flush(); + RSQLVisitorBase.setEntityManagerDatabase(Map.of()); + } + + @ParameterizedTest + @MethodSource("data") + void testJson(List users, String rsql, List expected) { + //given + repository.saveAllAndFlush(users); + + //when + List result = repository.findAll(toSpecification(rsql)); + + //then + assertThat(result) + .hasSameSizeAs(expected) + .containsExactlyInAnyOrderElementsOf(expected); + + users.forEach(e -> e.setId(null)); + } + + static Stream data() { + return Stream.of( + equalsData(), + inData(), + betweenData(), + likeData(), + gtLtData(), + miscData() + ) + .flatMap(s -> s); + } + + private static Stream equalsData() { + var e1 = new PostgresJsonEntity(Map.of("a1", "b1")); + var e2 = new PostgresJsonEntity(Map.of("a1", Map.of("a11", Map.of("a111", "b1")))); + + var e3 = new PostgresJsonEntity(e1); + var e4 = new PostgresJsonEntity(e2); + + var e5 = new PostgresJsonEntity(Map.of("a", "b1")); + var e6 = new PostgresJsonEntity(Map.of("a", "b2")); + var e7 = new PostgresJsonEntity(Map.of("a", "c1")); + + return Stream.of( + arguments(List.of(e1, e2), "properties.a1==b1", List.of(e1)), + arguments(List.of(e1, e2), "properties.a1!=b1", List.of(e2)), + arguments(List.of(e1, e2), "properties.a1=ic=B1", List.of(e1)), + arguments(List.of(e1, e2), "properties.a1==b2", List.of()), + arguments(List.of(e3, e4), "properties.a1.a11.a111==b1", List.of(e4)), + arguments(List.of(e3, e4), "properties.a1.a11.a111==b2", List.of()), + + arguments(List.of(e5, e6, e7), "properties.a==b*", List.of(e5, e6)), + arguments(List.of(e5, e6, e7), "properties.a==c*", List.of(e7)), + arguments(List.of(e5, e6, e7), "properties.a==*1", List.of(e5, e7)) + ); + } + + private static Stream inData() { + var e1 = new PostgresJsonEntity(Map.of("a", "b1")); + var e2 = new PostgresJsonEntity(Map.of("a", "b2")); + var e3 = new PostgresJsonEntity(Map.of("a", "c1")); + var e4 = new PostgresJsonEntity(Map.of("a", "d1")); + + return Stream.of( + arguments(List.of(e1, e2, e3, e4), "properties.a=in=(b1, c1)", List.of(e1, e3)), + arguments(List.of(e1, e2, e3, e4), "properties.a=out=(b1, c1)", List.of(e2, e4)), + arguments(List.of(e1, e2, e3, e4), "properties.a=in=(b1)", List.of(e1)), + arguments(List.of(e1, e2, e3, e4), "properties.a=out=(b1)", List.of(e2, e3, e4)) + ); + } + + private static Stream betweenData() { + var e1 = new PostgresJsonEntity(Map.of("a", "a")); + var e2 = new PostgresJsonEntity(Map.of("a", "b")); + var e3 = new PostgresJsonEntity(Map.of("a", "c")); + var e4 = new PostgresJsonEntity(Map.of("a", "d")); + + return Stream.of( + arguments(List.of(e1, e2, e3, e4), "properties.a=bt=(a, c)", List.of(e1, e2, e3)), + arguments(List.of(e1, e2, e3, e4), "properties.a=nb=(b, d)", List.of(e1)) + ); + } + + private static Stream likeData() { + var e1 = new PostgresJsonEntity(Map.of("a", "a b c")); + var e2 = new PostgresJsonEntity(Map.of("a", "b c d")); + var e3 = new PostgresJsonEntity(Map.of("a", "c d e")); + + return Stream.of( + arguments(List.of(e1, e2, e3), "properties.a=ke='a b'", List.of(e1)), + arguments(List.of(e1, e2, e3), "properties.a=ke='b c'", List.of(e1, e2)), + arguments(List.of(e1, e2, e3), "properties.a=ke='c d'", List.of(e2, e3)), + arguments(List.of(e1, e2, e3), "properties.a=ke='d e'", List.of(e3)), + + arguments(List.of(e1, e2, e3), "properties.a=ik='A B'", List.of(e1)), + arguments(List.of(e1, e2, e3), "properties.a=ik='B C'", List.of(e1, e2)), + arguments(List.of(e1, e2, e3), "properties.a=ik='C D'", List.of(e2, e3)), + arguments(List.of(e1, e2, e3), "properties.a=ik='D E'", List.of(e3)), + + arguments(List.of(e1, e2, e3), "properties.a=nk='a b'", List.of(e2, e3)), + arguments(List.of(e1, e2, e3), "properties.a=ni='A B'", List.of(e2, e3)) + ); + } + + private static Stream gtLtData() { + var e1 = new PostgresJsonEntity(Map.of("a", "a")); + var e2 = new PostgresJsonEntity(Map.of("a", "b")); + var e3 = new PostgresJsonEntity(Map.of("a", "c")); + var e4 = new PostgresJsonEntity(Map.of("a", "d")); + + return Stream.of( + arguments(List.of(e1, e2, e3, e4), "properties.a>=a", List.of(e1, e2, e3, e4)), + arguments(List.of(e1, e2, e3, e4), "properties.a>a", List.of(e2, e3, e4)), + arguments(List.of(e1, e2, e3, e4), "properties.a miscData() { + var e1 = new PostgresJsonEntity(Map.of("a", "b1")); + var e2 = new PostgresJsonEntity(Map.of("a", "b2")); + var e3 = new PostgresJsonEntity(Map.of("b", "c1")); + var e4 = new PostgresJsonEntity(Map.of("b", "d1")); + + return Stream.of( + arguments(List.of(e1, e2, e3, e4), "properties.a=nn=''", List.of(e1, e2)), + arguments(List.of(e1, e2, e3, e4), "properties.a=na=''", List.of(e3, e4)), + + arguments(List.of(e1, e2, e3, e4), "properties.b=nn=''", List.of(e3, e4)), + arguments(List.of(e1, e2, e3, e4), "properties.b=na=''", List.of(e1, e2)) + ); + } +} diff --git a/rsql-jpa/src/test/java/io/github/perplexhub/rsql/model/PostgresJsonEntity.java b/rsql-jpa/src/test/java/io/github/perplexhub/rsql/model/PostgresJsonEntity.java new file mode 100644 index 00000000..ee0379a7 --- /dev/null +++ b/rsql-jpa/src/test/java/io/github/perplexhub/rsql/model/PostgresJsonEntity.java @@ -0,0 +1,44 @@ +package io.github.perplexhub.rsql.model; + +import io.hypersistence.utils.hibernate.type.json.JsonType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import org.hibernate.annotations.Type; + +@Getter +@Setter +@EqualsAndHashCode(of = "id") +@ToString +@Entity +@NoArgsConstructor +public class PostgresJsonEntity { + + @Id + @GeneratedValue + private UUID id; + + @Type(JsonType.class) + @Column(columnDefinition = "jsonb") + private Map properties = new HashMap<>(); + + public PostgresJsonEntity(Map properties) { + this.properties = Objects.requireNonNull(properties); + } + + public PostgresJsonEntity(PostgresJsonEntity other) { + this(other.getProperties()); + } +} diff --git a/rsql-jpa/src/test/java/io/github/perplexhub/rsql/repository/jpa/postgres/PostgresJsonEntityRepository.java b/rsql-jpa/src/test/java/io/github/perplexhub/rsql/repository/jpa/postgres/PostgresJsonEntityRepository.java new file mode 100644 index 00000000..69606335 --- /dev/null +++ b/rsql-jpa/src/test/java/io/github/perplexhub/rsql/repository/jpa/postgres/PostgresJsonEntityRepository.java @@ -0,0 +1,11 @@ +package io.github.perplexhub.rsql.repository.jpa.postgres; + +import io.github.perplexhub.rsql.model.PostgresJsonEntity; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +public interface PostgresJsonEntityRepository extends JpaRepository, + JpaSpecificationExecutor { + +} diff --git a/rsql-jpa/src/test/resources/application-postgres.yml b/rsql-jpa/src/test/resources/application-postgres.yml new file mode 100644 index 00000000..a457b142 --- /dev/null +++ b/rsql-jpa/src/test/resources/application-postgres.yml @@ -0,0 +1,7 @@ +spring: + datasource: + driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver + url: jdbc:tc:postgresql:12://localhost/test + jpa: + hibernate: + ddl-auto: create-drop \ No newline at end of file