From 5a9c5c6a08359a785be8b84176a9e9c32c9f4238 Mon Sep 17 00:00:00 2001 From: Andrej Petras Date: Fri, 19 Jan 2024 15:24:36 +0100 Subject: [PATCH] feat: add utility for search query like or equal predicate --- README.md | 14 +- extensions/jpa/runtime/pom.xml | 12 ++ .../quarkus/jpa/utils/QueryCriteriaUtil.java | 153 +++++++++++++++++- .../jpa/utils/QueryCriteriaUtilTest.java | 81 ++++++++++ .../tkit/quarkus/jpa/test/UserDAOTest.java | 4 +- extensions/rest-dto/README.md | 2 + .../quarkus/rs/exceptions/RestException.java | 1 + .../rs/mappers/DefaultExceptionMapper.java | 1 + .../rs/models/AbstractTraceableDTO.java | 1 + .../rs/models/BusinessTraceableDTO.java | 1 + .../tkit/quarkus/rs/models/PageResultDTO.java | 1 + .../quarkus/rs/models/RestExceptionDTO.java | 1 + .../tkit/quarkus/rs/models/TraceableDTO.java | 1 + .../quarkus/rs/resources/ResourceManager.java | 1 + 14 files changed, 261 insertions(+), 13 deletions(-) create mode 100644 extensions/jpa/runtime/src/test/java/org/tkit/quarkus/jpa/utils/QueryCriteriaUtilTest.java diff --git a/README.md b/README.md index 675bbe0..1a7856f 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ Include the component in your project by including the corresponding dependency. | Log RS | tkit-quarkus-log-rs | | Quarkus extension for HTTP request logging (client & server). | [Link](extensions/log/rs) | | Log JSON | tkit-quarkus-log-json | | Custom JSON log formatter that provides additional features not included in [Official Quarkus json logger](https://quarkus.io/guides/logging#json-logging). Make sure you only include this if you need those extra features, otherwise use the official extension. | [Link](extensions/log/json) | | Rest | tkit-quarkus-rest | | Helper classes for JAX-RS and Jackson. | [Link](extensions/rest-dto) | -| Rest DTO | tkit-quarkus-rest-dto | | Helper classes for REST - model mapping, exception handling, DTOs. | [Link](extensions/rest) | +| Rest DTO | tkit-quarkus-rest-dto | > DEPRECATED | Helper classes for REST - model mapping, exception handling, DTOs. | [Link](extensions/rest) | | Test data import | tkit-quarkus-test-db-import | | Test extension for data import from excel into database during unit tests. | [Link](extensions/test-db-import) | | Security | tkit-quarkus-security | | Enhanced security configuration | [Link](extensions/security) | @@ -65,10 +65,7 @@ Include the component in your project by including the corresponding dependency. If you have used previous versions of tkit quarkus libraries (mvn groupId `org.tkit.quarkus`) then there are a few breaking changes in this new version, however the migration is straightforward: -1. Quarkus 2.x -Tkit Quarkus libs only support Quarkus version 2. Check the [official guide](https://github.com/quarkusio/quarkus/wiki/Migration-Guide-2.0) for migration instructions. - -2. Change maven imports +#### Change maven imports Group id of the libraries has changed to `org.tkit.quarkus.lib`. Also, prefer the use of bom import, to ensure version compatibility. So if your current pom.xml looks sth like this: ```xml @@ -97,7 +94,7 @@ Change it to: ``` -3. Update configuration +#### Update configuration All extensions and libraries now have unified configuration properties structure, starting with `tkit.` prefix, some keys have been renamed or otherwise updated. Check the table bellow for config property migration: | Old | New | Note | @@ -105,7 +102,7 @@ All extensions and libraries now have unified configuration properties structure | `quarkus.tkit.log.ignore.pattern` | `tkit.log.cdi.auto-discovery.ignore.pattern` | | | `quarkus.tkit.log.packages` | `tkit.log.cdi.auto-discovery.packages` | In order to enable auto binding of logging extension, you must add property `tkit.log.cdi.auto-discovery.enabled=true` | -4. Default behavior changes +#### Default behavior changes Logging: CDI logging now only logs end of business methods (success or error) to reduce logging verbosity. If you restore the behavior and still log start method invocations, set the property: `tkit.log.cdi.start.enabled=true` @@ -122,7 +119,8 @@ New behavior: [com.acme.dom.dao.SomeBean] someMethod(param):SomeResultClass [0.035s] ``` -5. Use `modificationCount` instead of `version` when working with `TraceableEntity`. Therefore, annotations like `@Mapping(target = "version", ignore = true)` should be changed to `@Mapping(target = "modificationCount", ignore = true)`. +#### JPA ModificationCount +Use `modificationCount` instead of `version` when working with `TraceableEntity`. Therefore, annotations like `@Mapping(target = "version", ignore = true)` should be changed to `@Mapping(target = "modificationCount", ignore = true)`. ## Contributors ✨ diff --git a/extensions/jpa/runtime/pom.xml b/extensions/jpa/runtime/pom.xml index 0fde9d3..280e3a8 100644 --- a/extensions/jpa/runtime/pom.xml +++ b/extensions/jpa/runtime/pom.xml @@ -31,6 +31,18 @@ tkit-quarkus-context ${project.version} + + + + io.quarkus + quarkus-junit5 + test + + + io.quarkus + quarkus-junit5-mockito + test + diff --git a/extensions/jpa/runtime/src/main/java/org/tkit/quarkus/jpa/utils/QueryCriteriaUtil.java b/extensions/jpa/runtime/src/main/java/org/tkit/quarkus/jpa/utils/QueryCriteriaUtil.java index fb5f074..efa516c 100644 --- a/extensions/jpa/runtime/src/main/java/org/tkit/quarkus/jpa/utils/QueryCriteriaUtil.java +++ b/extensions/jpa/runtime/src/main/java/org/tkit/quarkus/jpa/utils/QueryCriteriaUtil.java @@ -19,6 +19,7 @@ import java.util.Collection; import java.util.List; import java.util.Map; +import java.util.function.Function; import jakarta.persistence.criteria.CriteriaBuilder; import jakarta.persistence.criteria.Expression; @@ -30,6 +31,13 @@ */ public class QueryCriteriaUtil { + /** + * Custom query like characters. + */ + private static final Map DEFAULT_LIKE_MAPPING_CHARACTERS = Map.of( + "*", "%", + "?", "_"); + /** * The default constructor. */ @@ -38,7 +46,7 @@ private QueryCriteriaUtil() { } /** - * Wildcard the search string with case insensitive {@code true}. + * Wildcard the search string with case-insensitive {@code true}. * * @param searchString the search string. * @return the corresponding search string. @@ -89,7 +97,7 @@ public static Predicate inClause(Expression path, Collection values, Crite subList.clear(); } predicates.add(path.in(valuesList)); - result = cb.or(predicates.toArray(new Predicate[predicates.size()])); + result = cb.or(predicates.toArray(new Predicate[0])); } return result; } @@ -114,7 +122,7 @@ public static Predicate notInClause(Expression path, Collection values, Cr subList.clear(); } predicates.add(cb.not(path.in(valuesList))); - result = cb.and(predicates.toArray(new Predicate[predicates.size()])); + result = cb.and(predicates.toArray(new Predicate[0])); } return result; } @@ -129,6 +137,7 @@ public static Predicate notInClause(Expression path, Collection values, Cr * @param parameters the parameters to be added from the IN clause * @return the query string with the IN clause */ + @Deprecated public static String inClause(String attribute, String attributeName, Collection values, Map parameters) { StringBuilder sb = new StringBuilder(); @@ -159,6 +168,7 @@ public static String inClause(String attribute, String attributeName, Collection * @param parameters the parameters to be added from the NOT IN clause * @return the query string with the NOT IN clause */ + @Deprecated public static String notInClause(String attribute, String attributeName, Collection values, Map parameters) { StringBuilder sb = new StringBuilder(); @@ -178,4 +188,141 @@ public static String notInClause(String attribute, String attributeName, Collect parameters.put(attributeName, valuesList); return sb.toString(); } + + /** + * Add a search predicate to the list of predicates. + * + * @param predicates - list of predicates + * @param criteriaBuilder - CriteriaBuilder + * @param column - column Path [root.get(Entity_.attribute)] + * @param searchString - string to search. if Contains [*,?] like will be used + * @param caseInsensitive - true in case of insensitive search (db column and search string are given to lower case) + * @return {@code true} add predicate to the list + */ + public static boolean addSearchStringPredicate(List predicates, CriteriaBuilder criteriaBuilder, + Expression column, + String searchString, final boolean caseInsensitive) { + if (predicates == null) { + return false; + } + var predicate = createSearchStringPredicate(criteriaBuilder, column, searchString, caseInsensitive); + if (predicate == null) { + return false; + } + + return predicates.add(predicate); + } + + /** + * Add a search predicate as a case of insensitive search to the list of predicates. + * + * @param predicates - list of predicates + * @param criteriaBuilder - CriteriaBuilder + * @param column - column Path [root.get(Entity_.attribute)] + * @param searchString - string to search. if Contains [*,?] like will be used + * @return {@code true} add predicate to the list + */ + public static boolean addSearchStringPredicate(List predicates, CriteriaBuilder criteriaBuilder, + Expression column, + String searchString) { + if (predicates == null) { + return false; + } + var predicate = createSearchStringPredicate(criteriaBuilder, column, searchString, true); + if (predicate == null) { + return false; + } + + return predicates.add(predicate); + } + + /** + * Create a search predicate as a case of insensitive search. + * + * @param criteriaBuilder - CriteriaBuilder + * @param column - column Path [root.get(Entity_.attribute)] + * @param searchString - string to search. if Contains [*,?] like will be used + * @return LIKE or EQUAL Predicate according to the search string + */ + public static Predicate createSearchStringPredicate(CriteriaBuilder criteriaBuilder, Expression column, + String searchString) { + return createSearchStringPredicate(criteriaBuilder, column, searchString, true); + } + + /** + * Create a search predicate. + * + * @param criteriaBuilder - CriteriaBuilder + * @param column - column Path [root.get(Entity_.attribute)] + * @param searchString - string to search. if Contains [*,?] like will be used + * @param caseInsensitive - true in case of insensitive search (db column and search string are given to lower case) + * @return LIKE or EQUAL Predicate according to the search string + */ + public static Predicate createSearchStringPredicate(CriteriaBuilder criteriaBuilder, Expression column, + String searchString, final boolean caseInsensitive) { + return createSearchStringPredicate(criteriaBuilder, column, searchString, caseInsensitive, + QueryCriteriaUtil::defaultReplaceFunction, DEFAULT_LIKE_MAPPING_CHARACTERS); + } + + /** + * Create a search predicate. + * + * @param criteriaBuilder - CriteriaBuilder + * @param column - column Path [root.get(Entity_.attribute)] + * @param searchString - string to search. if Contains [*,?] like will be used + * @param caseInsensitive - true in case of insensitive search (db column and search string are given to lower case) + * @param replaceFunction - replace special character function for characters in the searchString + * @param likeMapping - map of like query mapping characters ['*','%', ...] + * @return LIKE or EQUAL Predicate according to the search string + */ + public static Predicate createSearchStringPredicate(CriteriaBuilder criteriaBuilder, Expression column, + String searchString, final boolean caseInsensitive, + Function replaceFunction, + Map likeMapping) { + + if (searchString == null || searchString.isBlank()) { + return null; + } + + // replace function for special characters + if (replaceFunction != null) { + searchString = replaceFunction.apply(searchString); + } + + // case insensitive + Expression columnDefinition = column; + if (caseInsensitive) { + searchString = searchString.toLowerCase(); + columnDefinition = criteriaBuilder.lower(column); + } + + // check for like characters + boolean like = false; + if (likeMapping != null) { + for (Map.Entry item : likeMapping.entrySet()) { + if (searchString.contains(item.getKey())) { + searchString = searchString.replace(item.getKey(), item.getValue()); + like = true; + } + } + } + + // like predicate + if (like) { + return criteriaBuilder.like(columnDefinition, searchString); + } + + // equal predicate + return criteriaBuilder.equal(columnDefinition, searchString); + } + + /** + * Escape the extra DB characters + */ + public static String defaultReplaceFunction(String searchString) { + return searchString + .replace("\\", "\\\\") + .replace("%", "\\%") + .replace("_", "\\_"); + } } diff --git a/extensions/jpa/runtime/src/test/java/org/tkit/quarkus/jpa/utils/QueryCriteriaUtilTest.java b/extensions/jpa/runtime/src/test/java/org/tkit/quarkus/jpa/utils/QueryCriteriaUtilTest.java new file mode 100644 index 0000000..3cb25aa --- /dev/null +++ b/extensions/jpa/runtime/src/test/java/org/tkit/quarkus/jpa/utils/QueryCriteriaUtilTest.java @@ -0,0 +1,81 @@ +package org.tkit.quarkus.jpa.utils; + +import static org.mockito.ArgumentMatchers.any; + +import jakarta.persistence.criteria.*; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.mockito.stubbing.Answer; + +class QueryCriteriaUtilTest { + + @Test + void createSearchStringPredicateTest() { + + final var result = new TestResult(); + + Predicate predicate = Mockito.mock(Predicate.class); + CriteriaBuilder cb = Mockito.mock(CriteriaBuilder.class); + Mockito.when(cb.lower(any())).thenReturn(null); + Mockito.when(cb.like(any(), (String) any())).thenAnswer(invocation -> { + result.like = true; + result.searchString = invocation.getArgument(1); + return predicate; + }); + Mockito.when(cb.equal(any(), (String) any())).thenAnswer((Answer) invocation -> { + result.equal = true; + result.searchString = invocation.getArgument(1); + return predicate; + }); + + Expression expression = Mockito.mock(Expression.class); + Mockito.when(cb.lower(any())).thenAnswer(invocation -> { + result.lower = false; + return expression; + }); + + Predicate p = QueryCriteriaUtil.createSearchStringPredicate(cb, expression, "searchText", false); + Assertions.assertNotNull(p); + Assertions.assertFalse(result.like); + Assertions.assertTrue(result.equal); + Assertions.assertEquals("searchText", result.searchString); + + result.reset(); + p = QueryCriteriaUtil.createSearchStringPredicate(cb, expression, "ThisIsNotSearchText", true); + Assertions.assertNotNull(p); + Assertions.assertFalse(result.like); + Assertions.assertTrue(result.equal); + Assertions.assertEquals("thisisnotsearchtext", result.searchString); + + result.reset(); + p = QueryCriteriaUtil.createSearchStringPredicate(cb, expression, "?ThisIsNotSearchText", true); + Assertions.assertNotNull(p); + Assertions.assertFalse(result.equal); + Assertions.assertTrue(result.like); + Assertions.assertEquals("_thisisnotsearchtext", result.searchString); + + result.reset(); + p = QueryCriteriaUtil.createSearchStringPredicate(cb, expression, "?ThisIsNotSearchText*", true); + Assertions.assertNotNull(p); + Assertions.assertFalse(result.equal); + Assertions.assertTrue(result.like); + Assertions.assertEquals("_thisisnotsearchtext%", result.searchString); + } + + public static class TestResult { + boolean like = false; + boolean equal = false; + boolean lower = false; + + String searchString = null; + + void reset() { + like = false; + equal = false; + lower = false; + searchString = null; + } + } +} diff --git a/extensions/jpa/tests/src/test/java/org/tkit/quarkus/jpa/test/UserDAOTest.java b/extensions/jpa/tests/src/test/java/org/tkit/quarkus/jpa/test/UserDAOTest.java index 08ee5b5..ded3abc 100644 --- a/extensions/jpa/tests/src/test/java/org/tkit/quarkus/jpa/test/UserDAOTest.java +++ b/extensions/jpa/tests/src/test/java/org/tkit/quarkus/jpa/test/UserDAOTest.java @@ -7,6 +7,7 @@ import java.util.stream.Stream; import jakarta.inject.Inject; +import jakarta.persistence.OptimisticLockException; import jakarta.persistence.criteria.Order; import jakarta.transaction.Transactional; @@ -19,7 +20,6 @@ import org.tkit.quarkus.jpa.daos.Page; import org.tkit.quarkus.jpa.daos.PageResult; import org.tkit.quarkus.jpa.daos.PagedQuery; -import org.tkit.quarkus.jpa.exceptions.DAOException; import org.tkit.quarkus.jpa.models.TraceableEntity; import io.quarkus.test.junit.QuarkusTest; @@ -48,7 +48,7 @@ public void updateUserTest() { userDAO.update(loaded); user.setName("update-name"); - Assertions.assertThrows(DAOException.class, () -> { + Assertions.assertThrows(OptimisticLockException.class, () -> { userDAO.update(user); }); } diff --git a/extensions/rest-dto/README.md b/extensions/rest-dto/README.md index 2403ed0..eee3f77 100644 --- a/extensions/rest-dto/README.md +++ b/extensions/rest-dto/README.md @@ -1,5 +1,7 @@ # tkit-quarkus-rest-dto +> tkit-quarkus-rest-dto is deprecated. It will be removed in the next major release. + Helper classes for JAX-RS - model mapping, exception handling, DTOs. Maven dependency diff --git a/extensions/rest-dto/src/main/java/org/tkit/quarkus/rs/exceptions/RestException.java b/extensions/rest-dto/src/main/java/org/tkit/quarkus/rs/exceptions/RestException.java index 765fa87..24e8185 100644 --- a/extensions/rest-dto/src/main/java/org/tkit/quarkus/rs/exceptions/RestException.java +++ b/extensions/rest-dto/src/main/java/org/tkit/quarkus/rs/exceptions/RestException.java @@ -12,6 +12,7 @@ * The REST exception. The DTO for this class {@link org.tkit.quarkus.rs.models.RestExceptionDTO} * THe exception mapper {@link org.tkit.quarkus.rs.mappers.DefaultExceptionMapper} */ +@Deprecated public class RestException extends RuntimeException { /** diff --git a/extensions/rest-dto/src/main/java/org/tkit/quarkus/rs/mappers/DefaultExceptionMapper.java b/extensions/rest-dto/src/main/java/org/tkit/quarkus/rs/mappers/DefaultExceptionMapper.java index 8241bf7..e53d232 100644 --- a/extensions/rest-dto/src/main/java/org/tkit/quarkus/rs/mappers/DefaultExceptionMapper.java +++ b/extensions/rest-dto/src/main/java/org/tkit/quarkus/rs/mappers/DefaultExceptionMapper.java @@ -24,6 +24,7 @@ /** * The default exception mapper with priority {@code PRIORITY}. */ +@Deprecated @Provider @Priority(DefaultExceptionMapper.PRIORITY) public class DefaultExceptionMapper implements ExceptionMapper { diff --git a/extensions/rest-dto/src/main/java/org/tkit/quarkus/rs/models/AbstractTraceableDTO.java b/extensions/rest-dto/src/main/java/org/tkit/quarkus/rs/models/AbstractTraceableDTO.java index 72e9149..219b33b 100644 --- a/extensions/rest-dto/src/main/java/org/tkit/quarkus/rs/models/AbstractTraceableDTO.java +++ b/extensions/rest-dto/src/main/java/org/tkit/quarkus/rs/models/AbstractTraceableDTO.java @@ -23,6 +23,7 @@ /** * The persistent entity interface. */ +@Deprecated @RegisterForReflection public abstract class AbstractTraceableDTO implements Serializable { diff --git a/extensions/rest-dto/src/main/java/org/tkit/quarkus/rs/models/BusinessTraceableDTO.java b/extensions/rest-dto/src/main/java/org/tkit/quarkus/rs/models/BusinessTraceableDTO.java index eeb4913..32ee7c4 100644 --- a/extensions/rest-dto/src/main/java/org/tkit/quarkus/rs/models/BusinessTraceableDTO.java +++ b/extensions/rest-dto/src/main/java/org/tkit/quarkus/rs/models/BusinessTraceableDTO.java @@ -19,6 +19,7 @@ import io.quarkus.runtime.annotations.RegisterForReflection; +@Deprecated @RegisterForReflection public class BusinessTraceableDTO extends AbstractTraceableDTO { diff --git a/extensions/rest-dto/src/main/java/org/tkit/quarkus/rs/models/PageResultDTO.java b/extensions/rest-dto/src/main/java/org/tkit/quarkus/rs/models/PageResultDTO.java index 402e5e1..7ac923c 100644 --- a/extensions/rest-dto/src/main/java/org/tkit/quarkus/rs/models/PageResultDTO.java +++ b/extensions/rest-dto/src/main/java/org/tkit/quarkus/rs/models/PageResultDTO.java @@ -19,6 +19,7 @@ import io.quarkus.runtime.annotations.RegisterForReflection; +@Deprecated @RegisterForReflection public class PageResultDTO { diff --git a/extensions/rest-dto/src/main/java/org/tkit/quarkus/rs/models/RestExceptionDTO.java b/extensions/rest-dto/src/main/java/org/tkit/quarkus/rs/models/RestExceptionDTO.java index 3562ada..ace2728 100644 --- a/extensions/rest-dto/src/main/java/org/tkit/quarkus/rs/models/RestExceptionDTO.java +++ b/extensions/rest-dto/src/main/java/org/tkit/quarkus/rs/models/RestExceptionDTO.java @@ -24,6 +24,7 @@ /** * The rest exception DTO model. */ +@Deprecated @RegisterForReflection public class RestExceptionDTO { diff --git a/extensions/rest-dto/src/main/java/org/tkit/quarkus/rs/models/TraceableDTO.java b/extensions/rest-dto/src/main/java/org/tkit/quarkus/rs/models/TraceableDTO.java index 05d4d05..6fea9f9 100644 --- a/extensions/rest-dto/src/main/java/org/tkit/quarkus/rs/models/TraceableDTO.java +++ b/extensions/rest-dto/src/main/java/org/tkit/quarkus/rs/models/TraceableDTO.java @@ -23,6 +23,7 @@ /** * The persistent entity with string GUID. */ +@Deprecated @RegisterForReflection public class TraceableDTO extends AbstractTraceableDTO { diff --git a/extensions/rest-dto/src/main/java/org/tkit/quarkus/rs/resources/ResourceManager.java b/extensions/rest-dto/src/main/java/org/tkit/quarkus/rs/resources/ResourceManager.java index 25b8583..c8b042c 100644 --- a/extensions/rest-dto/src/main/java/org/tkit/quarkus/rs/resources/ResourceManager.java +++ b/extensions/rest-dto/src/main/java/org/tkit/quarkus/rs/resources/ResourceManager.java @@ -14,6 +14,7 @@ /** * The resource manager for messages. */ +@Deprecated public class ResourceManager { private static final Logger log = LoggerFactory.getLogger(ResourceManager.class);