From 862d6a1f456db9d011884461b872ab6c3ede4686 Mon Sep 17 00:00:00 2001 From: AF <18417689+perplexhub@users.noreply.github.com> Date: Wed, 2 Apr 2025 03:43:49 +0800 Subject: [PATCH 1/2] Apply custom function for field entity #177 --- .../perplexhub/rsql/RSQLCommonSupport.java | 10 ++ .../perplexhub/rsql/RSQLVisitorBase.java | 23 ++- .../io/github/perplexhub/rsql/model/User.java | 6 + .../rsql/RSQLFieldTransformerTest.java | 164 ++++++++++++++++++ 4 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 rsql-jpa/src/test/java/io/github/perplexhub/rsql/RSQLFieldTransformerTest.java diff --git a/rsql-common/src/main/java/io/github/perplexhub/rsql/RSQLCommonSupport.java b/rsql-common/src/main/java/io/github/perplexhub/rsql/RSQLCommonSupport.java index 25db8493..815358b7 100644 --- a/rsql-common/src/main/java/io/github/perplexhub/rsql/RSQLCommonSupport.java +++ b/rsql-common/src/main/java/io/github/perplexhub/rsql/RSQLCommonSupport.java @@ -28,6 +28,7 @@ public class RSQLCommonSupport { private @Getter static final Map valueTypeMap = new ConcurrentHashMap<>(); private @Getter static final Map, List> propertyWhitelist = new ConcurrentHashMap<>(); private @Getter static final Map, List> propertyBlacklist = new ConcurrentHashMap<>(); + private @Getter static final Map, Map>> fieldTransformers = new ConcurrentHashMap<>(); private @Getter static final ConfigurableConversionService conversionService = new DefaultConversionService(); public RSQLCommonSupport() { @@ -51,6 +52,7 @@ protected void init() { RSQLVisitorBase.setPropertyRemapping(getPropertyRemapping()); RSQLVisitorBase.setGlobalPropertyWhitelist(getPropertyWhitelist()); RSQLVisitorBase.setGlobalPropertyBlacklist(getPropertyBlacklist()); + RSQLVisitorBase.setFieldTransformers(getFieldTransformers()); RSQLVisitorBase.setDefaultConversionService(getConversionService()); log.info("RSQLCommonSupport {} is initialized", getVersion()); } @@ -127,6 +129,14 @@ public static void addEntityAttributeTypeMap(Class valueClass, Class mappedClass } } + public static void addFieldTransformer(Class entityClass, String fieldName, Function transformer) { + log.info("Adding field transformer for {}.{}", entityClass, fieldName); + if (entityClass != null && fieldName != null && transformer != null) { + fieldTransformers.computeIfAbsent(entityClass, k -> new ConcurrentHashMap<>()) + .put(fieldName, transformer); + } + } + protected String getVersion() { try { Properties prop = new Properties(); 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 d8ec4dfc..743c5c8c 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 @@ -3,9 +3,9 @@ import java.lang.reflect.*; import java.sql.Timestamp; import java.time.*; -import java.time.format.DateTimeParseException; import java.util.*; import java.util.Map.Entry; +import java.util.function.Function; import jakarta.persistence.EntityManager; import jakarta.persistence.metamodel.Attribute; @@ -16,7 +16,6 @@ import lombok.Getter; import org.hibernate.metamodel.model.domain.ManagedDomainType; import org.hibernate.metamodel.model.domain.PersistentAttribute; -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; @@ -37,6 +36,7 @@ public abstract class RSQLVisitorBase implements RSQLVisitor { protected static volatile @Setter Map, Map> propertyRemapping; protected static volatile @Setter Map, List> globalPropertyWhitelist; protected static volatile @Setter Map, List> globalPropertyBlacklist; + protected static volatile @Setter Map, Map>> fieldTransformers; protected static volatile @Setter ConfigurableConversionService defaultConversionService; protected @Setter Map, List> propertyWhitelist; @@ -77,6 +77,25 @@ public Map, Map> getPropertyRemapping() { protected Object convert(String source, Class targetType) { log.debug("convert(source:{},targetType:{})", source, targetType); + if (source == null) { + return null; + } + + // Check for field-specific transformer first + if (fieldTransformers != null) { + Map> entityTransformers = fieldTransformers.get(targetType); + if (entityTransformers != null) { + Function transformer = entityTransformers.get(source); + if (transformer != null) { + try { + return transformer.apply(source); + } catch (Exception e) { + log.warn("Failed to apply field transformer for {}.{}", targetType, source, e); + } + } + } + } + Object object = null; try { if (defaultConversionService.canConvert(String.class, targetType)) { diff --git a/rsql-common/src/test/java/io/github/perplexhub/rsql/model/User.java b/rsql-common/src/test/java/io/github/perplexhub/rsql/model/User.java index ca4db222..fb30d9cb 100644 --- a/rsql-common/src/test/java/io/github/perplexhub/rsql/model/User.java +++ b/rsql-common/src/test/java/io/github/perplexhub/rsql/model/User.java @@ -22,6 +22,12 @@ public class User { private String name; + private String number; + + private String email; + + private String phone; + @ManyToOne @JoinColumn(name = "companyId", referencedColumnName = "id") private Company company; diff --git a/rsql-jpa/src/test/java/io/github/perplexhub/rsql/RSQLFieldTransformerTest.java b/rsql-jpa/src/test/java/io/github/perplexhub/rsql/RSQLFieldTransformerTest.java new file mode 100644 index 00000000..a0480ca7 --- /dev/null +++ b/rsql-jpa/src/test/java/io/github/perplexhub/rsql/RSQLFieldTransformerTest.java @@ -0,0 +1,164 @@ +package io.github.perplexhub.rsql; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.Collections; +import java.util.Map; + +import io.github.perplexhub.rsql.model.User; +import jakarta.persistence.EntityManager; +import jakarta.persistence.TypedQuery; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import cz.jirutka.rsql.parser.RSQLParser; +import cz.jirutka.rsql.parser.ast.Node; + +@ExtendWith(MockitoExtension.class) +class RSQLFieldTransformerTest { + + @Mock + private EntityManager entityManager; + + @Mock + private CriteriaBuilder criteriaBuilder; + + @Mock + private CriteriaQuery criteriaQuery; + + @Mock + private Root root; + + @Mock + private TypedQuery typedQuery; + + private Map propertyPathMapper; + + @BeforeEach + void setUp() { + propertyPathMapper = Collections.emptyMap(); + + // Setup field transformers + RSQLCommonSupport.addFieldTransformer(User.class, "number", + value -> value.replaceAll("[^0-9]", "")); + + RSQLCommonSupport.addFieldTransformer(User.class, "email", + value -> value.toLowerCase()); + + RSQLCommonSupport.addFieldTransformer(User.class, "phone", + value -> value.replaceAll("[^0-9+]", "")); + } + + @Test + void testNumberFieldTransformer() { + // Given + String rsqlQuery = "number=like='123????'"; + when(entityManager.getCriteriaBuilder()).thenReturn(criteriaBuilder); + when(entityManager.createQuery(any(CriteriaQuery.class))).thenReturn(typedQuery); + when(criteriaQuery.from(User.class)).thenReturn(root); + when(root.get("number")).thenReturn(mock()); + when(criteriaBuilder.like(any(), eq("%123%"))).thenReturn(mock()); + + // When + Node rootNode = new RSQLParser().parse(rsqlQuery); + RSQLJPAPredicateConverter converter = new RSQLJPAPredicateConverter(criteriaBuilder, propertyPathMapper); + Predicate predicate = rootNode.accept(converter, root); + + // Then + assertNotNull(predicate); + verify(criteriaBuilder).like(any(), eq("%123%")); + } + + @Test + void testEmailFieldTransformer() { + // Given + String rsqlQuery = "email=like='TEST@EXAMPLE.COM'"; + when(entityManager.getCriteriaBuilder()).thenReturn(criteriaBuilder); + when(entityManager.createQuery(any(CriteriaQuery.class))).thenReturn(typedQuery); + when(criteriaQuery.from(User.class)).thenReturn(root); + when(root.get("email")).thenReturn(mock()); + when(criteriaBuilder.like(any(), eq("%test@example.com%"))).thenReturn(mock()); + + // When + Node rootNode = new RSQLParser().parse(rsqlQuery); + RSQLJPAPredicateConverter converter = new RSQLJPAPredicateConverter(criteriaBuilder, propertyPathMapper); + Predicate predicate = rootNode.accept(converter, root); + + // Then + assertNotNull(predicate); + verify(criteriaBuilder).like(any(), eq("%test@example.com%")); + } + + @Test + void testPhoneFieldTransformer() { + // Given + String rsqlQuery = "phone=like='+1 (555) 123-4567'"; + when(entityManager.getCriteriaBuilder()).thenReturn(criteriaBuilder); + when(entityManager.createQuery(any(CriteriaQuery.class))).thenReturn(typedQuery); + when(criteriaQuery.from(User.class)).thenReturn(root); + when(root.get("phone")).thenReturn(mock()); + when(criteriaBuilder.like(any(), eq("%+15551234567%"))).thenReturn(mock()); + + // When + Node rootNode = new RSQLParser().parse(rsqlQuery); + RSQLJPAPredicateConverter converter = new RSQLJPAPredicateConverter(criteriaBuilder, propertyPathMapper); + Predicate predicate = rootNode.accept(converter, root); + + // Then + assertNotNull(predicate); + verify(criteriaBuilder).like(any(), eq("%+15551234567%")); + } + + @Test + void testMultipleTransformers() { + // Given + String rsqlQuery = "number=like='123????' and email=like='TEST@EXAMPLE.COM'"; + when(entityManager.getCriteriaBuilder()).thenReturn(criteriaBuilder); + when(entityManager.createQuery(any(CriteriaQuery.class))).thenReturn(typedQuery); + when(criteriaQuery.from(User.class)).thenReturn(root); + when(root.get("number")).thenReturn(mock()); + when(root.get("email")).thenReturn(mock()); + when(criteriaBuilder.like(any(), eq("%123%"))).thenReturn(mock()); + when(criteriaBuilder.like(any(), eq("%test@example.com%"))).thenReturn(mock()); + when(criteriaBuilder.and(any(), any())).thenReturn(mock()); + + // When + Node rootNode = new RSQLParser().parse(rsqlQuery); + RSQLJPAPredicateConverter converter = new RSQLJPAPredicateConverter(criteriaBuilder, propertyPathMapper); + Predicate predicate = rootNode.accept(converter, root); + + // Then + assertNotNull(predicate); + verify(criteriaBuilder).like(any(), eq("%123%")); + verify(criteriaBuilder).like(any(), eq("%test@example.com%")); + } + + @Test + void testTransformerWithInvalidValue() { + // Given + String rsqlQuery = "number=like='abc'"; + when(entityManager.getCriteriaBuilder()).thenReturn(criteriaBuilder); + when(entityManager.createQuery(any(CriteriaQuery.class))).thenReturn(typedQuery); + when(criteriaQuery.from(User.class)).thenReturn(root); + when(root.get("number")).thenReturn(mock()); + when(criteriaBuilder.like(any(), eq("%%"))).thenReturn(mock()); + + // When + Node rootNode = new RSQLParser().parse(rsqlQuery); + RSQLJPAPredicateConverter converter = new RSQLJPAPredicateConverter(criteriaBuilder, propertyPathMapper); + Predicate predicate = rootNode.accept(converter, root); + + // Then + assertNotNull(predicate); + verify(criteriaBuilder).like(any(), eq("%%")); + } +} \ No newline at end of file From 1d4934522befe3c52fd6ff1957b5ab0620623756 Mon Sep 17 00:00:00 2001 From: AF <18417689+perplexhub@users.noreply.github.com> Date: Wed, 2 Apr 2025 04:05:30 +0800 Subject: [PATCH 2/2] Apply custom function for field entity #177 --- .github/FUNDING.yml | 2 +- .../rsql/RSQLFieldTransformerTest.java | 164 ------------------ .../perplexhub/rsql/RSQLJPASupportTest.java | 58 ++++++- 3 files changed, 56 insertions(+), 168 deletions(-) delete mode 100644 rsql-jpa/src/test/java/io/github/perplexhub/rsql/RSQLFieldTransformerTest.java diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index a501951a..42bf91fd 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,6 +1,6 @@ # These are supported funding model platforms -github: perplexhub # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username ko_fi: # Replace with a single Ko-fi username diff --git a/rsql-jpa/src/test/java/io/github/perplexhub/rsql/RSQLFieldTransformerTest.java b/rsql-jpa/src/test/java/io/github/perplexhub/rsql/RSQLFieldTransformerTest.java deleted file mode 100644 index a0480ca7..00000000 --- a/rsql-jpa/src/test/java/io/github/perplexhub/rsql/RSQLFieldTransformerTest.java +++ /dev/null @@ -1,164 +0,0 @@ -package io.github.perplexhub.rsql; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -import java.util.Collections; -import java.util.Map; - -import io.github.perplexhub.rsql.model.User; -import jakarta.persistence.EntityManager; -import jakarta.persistence.TypedQuery; -import jakarta.persistence.criteria.CriteriaBuilder; -import jakarta.persistence.criteria.CriteriaQuery; -import jakarta.persistence.criteria.Predicate; -import jakarta.persistence.criteria.Root; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import cz.jirutka.rsql.parser.RSQLParser; -import cz.jirutka.rsql.parser.ast.Node; - -@ExtendWith(MockitoExtension.class) -class RSQLFieldTransformerTest { - - @Mock - private EntityManager entityManager; - - @Mock - private CriteriaBuilder criteriaBuilder; - - @Mock - private CriteriaQuery criteriaQuery; - - @Mock - private Root root; - - @Mock - private TypedQuery typedQuery; - - private Map propertyPathMapper; - - @BeforeEach - void setUp() { - propertyPathMapper = Collections.emptyMap(); - - // Setup field transformers - RSQLCommonSupport.addFieldTransformer(User.class, "number", - value -> value.replaceAll("[^0-9]", "")); - - RSQLCommonSupport.addFieldTransformer(User.class, "email", - value -> value.toLowerCase()); - - RSQLCommonSupport.addFieldTransformer(User.class, "phone", - value -> value.replaceAll("[^0-9+]", "")); - } - - @Test - void testNumberFieldTransformer() { - // Given - String rsqlQuery = "number=like='123????'"; - when(entityManager.getCriteriaBuilder()).thenReturn(criteriaBuilder); - when(entityManager.createQuery(any(CriteriaQuery.class))).thenReturn(typedQuery); - when(criteriaQuery.from(User.class)).thenReturn(root); - when(root.get("number")).thenReturn(mock()); - when(criteriaBuilder.like(any(), eq("%123%"))).thenReturn(mock()); - - // When - Node rootNode = new RSQLParser().parse(rsqlQuery); - RSQLJPAPredicateConverter converter = new RSQLJPAPredicateConverter(criteriaBuilder, propertyPathMapper); - Predicate predicate = rootNode.accept(converter, root); - - // Then - assertNotNull(predicate); - verify(criteriaBuilder).like(any(), eq("%123%")); - } - - @Test - void testEmailFieldTransformer() { - // Given - String rsqlQuery = "email=like='TEST@EXAMPLE.COM'"; - when(entityManager.getCriteriaBuilder()).thenReturn(criteriaBuilder); - when(entityManager.createQuery(any(CriteriaQuery.class))).thenReturn(typedQuery); - when(criteriaQuery.from(User.class)).thenReturn(root); - when(root.get("email")).thenReturn(mock()); - when(criteriaBuilder.like(any(), eq("%test@example.com%"))).thenReturn(mock()); - - // When - Node rootNode = new RSQLParser().parse(rsqlQuery); - RSQLJPAPredicateConverter converter = new RSQLJPAPredicateConverter(criteriaBuilder, propertyPathMapper); - Predicate predicate = rootNode.accept(converter, root); - - // Then - assertNotNull(predicate); - verify(criteriaBuilder).like(any(), eq("%test@example.com%")); - } - - @Test - void testPhoneFieldTransformer() { - // Given - String rsqlQuery = "phone=like='+1 (555) 123-4567'"; - when(entityManager.getCriteriaBuilder()).thenReturn(criteriaBuilder); - when(entityManager.createQuery(any(CriteriaQuery.class))).thenReturn(typedQuery); - when(criteriaQuery.from(User.class)).thenReturn(root); - when(root.get("phone")).thenReturn(mock()); - when(criteriaBuilder.like(any(), eq("%+15551234567%"))).thenReturn(mock()); - - // When - Node rootNode = new RSQLParser().parse(rsqlQuery); - RSQLJPAPredicateConverter converter = new RSQLJPAPredicateConverter(criteriaBuilder, propertyPathMapper); - Predicate predicate = rootNode.accept(converter, root); - - // Then - assertNotNull(predicate); - verify(criteriaBuilder).like(any(), eq("%+15551234567%")); - } - - @Test - void testMultipleTransformers() { - // Given - String rsqlQuery = "number=like='123????' and email=like='TEST@EXAMPLE.COM'"; - when(entityManager.getCriteriaBuilder()).thenReturn(criteriaBuilder); - when(entityManager.createQuery(any(CriteriaQuery.class))).thenReturn(typedQuery); - when(criteriaQuery.from(User.class)).thenReturn(root); - when(root.get("number")).thenReturn(mock()); - when(root.get("email")).thenReturn(mock()); - when(criteriaBuilder.like(any(), eq("%123%"))).thenReturn(mock()); - when(criteriaBuilder.like(any(), eq("%test@example.com%"))).thenReturn(mock()); - when(criteriaBuilder.and(any(), any())).thenReturn(mock()); - - // When - Node rootNode = new RSQLParser().parse(rsqlQuery); - RSQLJPAPredicateConverter converter = new RSQLJPAPredicateConverter(criteriaBuilder, propertyPathMapper); - Predicate predicate = rootNode.accept(converter, root); - - // Then - assertNotNull(predicate); - verify(criteriaBuilder).like(any(), eq("%123%")); - verify(criteriaBuilder).like(any(), eq("%test@example.com%")); - } - - @Test - void testTransformerWithInvalidValue() { - // Given - String rsqlQuery = "number=like='abc'"; - when(entityManager.getCriteriaBuilder()).thenReturn(criteriaBuilder); - when(entityManager.createQuery(any(CriteriaQuery.class))).thenReturn(typedQuery); - when(criteriaQuery.from(User.class)).thenReturn(root); - when(root.get("number")).thenReturn(mock()); - when(criteriaBuilder.like(any(), eq("%%"))).thenReturn(mock()); - - // When - Node rootNode = new RSQLParser().parse(rsqlQuery); - RSQLJPAPredicateConverter converter = new RSQLJPAPredicateConverter(criteriaBuilder, propertyPathMapper); - Predicate predicate = rootNode.accept(converter, root); - - // Then - assertNotNull(predicate); - verify(criteriaBuilder).like(any(), eq("%%")); - } -} \ No newline at end of file diff --git a/rsql-jpa/src/test/java/io/github/perplexhub/rsql/RSQLJPASupportTest.java b/rsql-jpa/src/test/java/io/github/perplexhub/rsql/RSQLJPASupportTest.java index 6d08712e..9a6c9396 100644 --- a/rsql-jpa/src/test/java/io/github/perplexhub/rsql/RSQLJPASupportTest.java +++ b/rsql-jpa/src/test/java/io/github/perplexhub/rsql/RSQLJPASupportTest.java @@ -5,9 +5,12 @@ import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.hamcrest.CoreMatchers.*; import static org.hamcrest.MatcherAssert.assertThat; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; +import static org.mockito.Mockito.verify; import java.sql.Timestamp; import java.text.ParseException; @@ -17,6 +20,8 @@ import java.time.ZoneOffset; import java.util.*; +import cz.jirutka.rsql.parser.RSQLParser; +import cz.jirutka.rsql.parser.ast.Node; import io.github.perplexhub.rsql.custom.CustomType; import io.github.perplexhub.rsql.model.Project; import io.github.perplexhub.rsql.model.AdminProject; @@ -28,6 +33,7 @@ import io.github.perplexhub.rsql.repository.jpa.ProjectRepository; import io.github.perplexhub.rsql.repository.jpa.custom.CustomTypeRepository; import io.github.perplexhub.rsql.custom.EntityWithCustomType; +import jakarta.persistence.criteria.CriteriaQuery; import jakarta.persistence.criteria.Expression; import jakarta.persistence.criteria.JoinType; import jakarta.persistence.criteria.Predicate; @@ -1463,6 +1469,52 @@ void testSearchBySubtypeAttribute() { Assertions.assertThat(result).hasSize(1); } + @Test + void testNumberFieldTransformer() { + RSQLCommonSupport.addFieldTransformer(User.class, "number", value -> value.replaceAll("[^0-9]", "")); + String rsql = "number=like='123????'"; + List users = userRepository.findAll(toSpecification(rsql)); + for (User user : users) + System.out.println(user); + } + + @Test + void testEmailFieldTransformer() { + RSQLCommonSupport.addFieldTransformer(User.class, "email", value -> value.toLowerCase()); + String rsql = "email=like='TEST@EXAMPLE.COM'"; + List users = userRepository.findAll(toSpecification(rsql)); + for (User user : users) + System.out.println(user); + } + + @Test + void testPhoneFieldTransformer() { + RSQLCommonSupport.addFieldTransformer(User.class, "phone", value -> value.replaceAll("[^0-9+]", "")); + String rsql = "phone=like='+1 (555) 123-4567'"; + List users = userRepository.findAll(toSpecification(rsql)); + for (User user : users) + System.out.println(user); + } + + @Test + void testMultipleTransformers() { + RSQLCommonSupport.addFieldTransformer(User.class, "number", value -> value.replaceAll("[^0-9]", "")); + RSQLCommonSupport.addFieldTransformer(User.class, "email", value -> value.toLowerCase()); + String rsql = "number=like='123????' and email=like='TEST@EXAMPLE.COM'"; + List users = userRepository.findAll(toSpecification(rsql)); + for (User user : users) + System.out.println(user); + } + + @Test + void testTransformerWithInvalidValue() { + String rsql = "number=like='abc'"; + RSQLCommonSupport.addFieldTransformer(User.class, "number", value -> value.replaceAll("[^0-9]", "")); + List users = userRepository.findAll(toSpecification(rsql)); + for (User user : users) + System.out.println(user); + } + @BeforeEach void setUp() { getPropertyWhitelist().clear();