diff --git a/jpa/keyset-pagination/boot-data-window-pagination/src/main/java/com/example/keysetpagination/repositories/AnimalRepository.java b/jpa/keyset-pagination/boot-data-window-pagination/src/main/java/com/example/keysetpagination/repositories/AnimalRepository.java index 81364b452..12c8d0e0d 100644 --- a/jpa/keyset-pagination/boot-data-window-pagination/src/main/java/com/example/keysetpagination/repositories/AnimalRepository.java +++ b/jpa/keyset-pagination/boot-data-window-pagination/src/main/java/com/example/keysetpagination/repositories/AnimalRepository.java @@ -2,5 +2,7 @@ import com.example.keysetpagination.entities.Animal; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; -public interface AnimalRepository extends JpaRepository, CustomRepository {} +public interface AnimalRepository + extends JpaRepository, CustomRepository, JpaSpecificationExecutor {} diff --git a/jpa/keyset-pagination/boot-data-window-pagination/src/main/java/com/example/keysetpagination/repositories/CustomRepository.java b/jpa/keyset-pagination/boot-data-window-pagination/src/main/java/com/example/keysetpagination/repositories/CustomRepository.java index 46a9ef172..ebdb37238 100644 --- a/jpa/keyset-pagination/boot-data-window-pagination/src/main/java/com/example/keysetpagination/repositories/CustomRepository.java +++ b/jpa/keyset-pagination/boot-data-window-pagination/src/main/java/com/example/keysetpagination/repositories/CustomRepository.java @@ -16,7 +16,7 @@ public interface CustomRepository { /** * Finds all entities matching the given specification using keyset pagination. * - * @param spec The specification to filter entites + * @param spec The specification to filter entities * @param pageRequest The pagination information * @param scrollPosition The current position in the result set * @param entityClass The entity class on which operation should occur diff --git a/jpa/keyset-pagination/boot-data-window-pagination/src/main/java/com/example/keysetpagination/services/EntitySpecification.java b/jpa/keyset-pagination/boot-data-window-pagination/src/main/java/com/example/keysetpagination/services/EntitySpecification.java index 29d9fd085..1630092ee 100644 --- a/jpa/keyset-pagination/boot-data-window-pagination/src/main/java/com/example/keysetpagination/services/EntitySpecification.java +++ b/jpa/keyset-pagination/boot-data-window-pagination/src/main/java/com/example/keysetpagination/services/EntitySpecification.java @@ -8,9 +8,12 @@ import jakarta.persistence.criteria.Root; import jakarta.persistence.metamodel.ManagedType; import jakarta.persistence.metamodel.Metamodel; +import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; import java.util.List; +import java.util.UUID; import java.util.function.BiFunction; import java.util.function.Function; import org.springframework.data.jpa.domain.Specification; @@ -77,7 +80,9 @@ private Predicate createPredicate(SearchCriteria searchCriteria, Root root, C criteriaBuilder::and); case LIKE, CONTAINS -> combinePredicates( typedValues, - value -> criteriaBuilder.like(root.get(fieldName), "%" + value + "%"), + value -> criteriaBuilder.like( + criteriaBuilder.lower(root.get(fieldName)), + "%" + value.toString().toLowerCase() + "%"), criteriaBuilder::or); case STARTS_WITH -> combinePredicates( typedValues, value -> criteriaBuilder.like(root.get(fieldName), value + "%"), criteriaBuilder::and); @@ -102,6 +107,10 @@ private Object convertToType(String value, Class fieldType) { try { if (fieldType.equals(String.class)) { return value; + } else if (fieldType.equals(BigDecimal.class)) { + return new BigDecimal(value); + } else if (fieldType.equals(UUID.class)) { + return UUID.fromString(value); } else if (fieldType.equals(Integer.class) || fieldType.equals(int.class)) { return Integer.valueOf(value); } else if (fieldType.equals(Long.class) || fieldType.equals(long.class)) { @@ -111,9 +120,9 @@ private Object convertToType(String value, Class fieldType) { } else if (fieldType.equals(Boolean.class) || fieldType.equals(boolean.class)) { return Boolean.valueOf(value); } else if (fieldType.equals(LocalDate.class)) { - return LocalDate.parse(value); + return LocalDate.parse(value, DateTimeFormatter.ISO_DATE); } else if (fieldType.equals(LocalDateTime.class)) { - return LocalDateTime.parse(value); + return LocalDateTime.parse(value, DateTimeFormatter.ISO_DATE_TIME); } else if (Enum.class.isAssignableFrom(fieldType)) { return Enum.valueOf((Class) fieldType, value); } else { @@ -129,10 +138,14 @@ private Predicate combinePredicates( List values, Function predicateFunction, BiFunction combiner) { + if (values.size() == 1) { + return predicateFunction.apply(values.getFirst()); + } return values.stream() .map(predicateFunction) .reduce(combiner::apply) - .orElseThrow(() -> new IllegalArgumentException("No predicates could be generated from values")); + .orElseThrow(() -> new IllegalArgumentException( + String.format("No predicates could be generated from values: %s", values))); } private void validateMetadata(List searchCriteriaList, Class entityClass) { diff --git a/jpa/keyset-pagination/boot-data-window-pagination/src/test/java/com/example/keysetpagination/services/EntitySpecificationIntTest.java b/jpa/keyset-pagination/boot-data-window-pagination/src/test/java/com/example/keysetpagination/repository/EntitySpecificationTest.java similarity index 58% rename from jpa/keyset-pagination/boot-data-window-pagination/src/test/java/com/example/keysetpagination/services/EntitySpecificationIntTest.java rename to jpa/keyset-pagination/boot-data-window-pagination/src/test/java/com/example/keysetpagination/repository/EntitySpecificationTest.java index 160e04252..a915bbd7a 100644 --- a/jpa/keyset-pagination/boot-data-window-pagination/src/test/java/com/example/keysetpagination/services/EntitySpecificationIntTest.java +++ b/jpa/keyset-pagination/boot-data-window-pagination/src/test/java/com/example/keysetpagination/repository/EntitySpecificationTest.java @@ -1,29 +1,48 @@ -package com.example.keysetpagination.services; +package com.example.keysetpagination.repository; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; -import com.example.keysetpagination.common.AbstractIntegrationTest; +import com.example.keysetpagination.common.ContainersConfig; +import com.example.keysetpagination.config.JpaAuditConfig; import com.example.keysetpagination.entities.Animal; import com.example.keysetpagination.model.query.QueryOperator; import com.example.keysetpagination.model.query.SearchCriteria; +import com.example.keysetpagination.repositories.AnimalRepository; +import com.example.keysetpagination.services.EntitySpecification; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; import java.util.List; -import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; import org.springframework.data.jpa.domain.Specification; -class EntitySpecificationIntTest extends AbstractIntegrationTest { +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@DataJpaTest +@Import({ContainersConfig.class, JpaAuditConfig.class}) +class EntitySpecificationTest { private EntitySpecification entitySpecification; @PersistenceContext private EntityManager entityManager; - @BeforeEach + @Autowired + private AnimalRepository animalRepository; + + @BeforeAll void setUp() { entitySpecification = new EntitySpecification<>(entityManager); + // Add test data + Animal mammal = new Animal().setName("Lion").setType("Mammal").setHabitat("Savanna"); + Animal bird = new Animal().setName("Eagle").setType("Bird").setHabitat("Forest"); + Animal fish = new Animal().setName("Shark").setType("Fish").setHabitat("Ocean"); + animalRepository.saveAll(List.of(mammal, bird, fish)); } @Test @@ -31,6 +50,8 @@ void shouldBuildSpecificationForEQOperator() { SearchCriteria criteria = new SearchCriteria(QueryOperator.EQ, "type", List.of("Mammal")); Specification spec = entitySpecification.specificationBuilder(List.of(criteria), Animal.class); assertThat(spec).isNotNull(); + List results = animalRepository.findAll(spec); + assertThat(results).isNotEmpty().extracting("type").containsOnly("Mammal"); } @Test @@ -38,6 +59,8 @@ void shouldBuildSpecificationForNEOperator() { SearchCriteria criteria = new SearchCriteria(QueryOperator.NE, "type", List.of("Reptile")); Specification spec = entitySpecification.specificationBuilder(List.of(criteria), Animal.class); assertThat(spec).isNotNull(); + List results = animalRepository.findAll(spec); + assertThat(results).isNotEmpty().extracting("type").containsOnly("Mammal", "Bird", "Fish"); } @Test @@ -45,6 +68,8 @@ void shouldBuildSpecificationForLTOperator() { SearchCriteria criteria = new SearchCriteria(QueryOperator.LT, "id", List.of("5")); Specification spec = entitySpecification.specificationBuilder(List.of(criteria), Animal.class); assertThat(spec).isNotNull(); + List results = animalRepository.findAll(spec); + assertThat(results).isNotEmpty().extracting("type").containsOnly("Mammal", "Bird", "Fish"); } @Test @@ -52,6 +77,8 @@ void shouldBuildSpecificationForGTOperator() { SearchCriteria criteria = new SearchCriteria(QueryOperator.GT, "id", List.of("2")); Specification spec = entitySpecification.specificationBuilder(List.of(criteria), Animal.class); assertThat(spec).isNotNull(); + List results = animalRepository.findAll(spec); + assertThat(results).isNotEmpty().extracting("type").containsOnly("Fish"); } @Test @@ -59,6 +86,8 @@ void shouldBuildSpecificationForGTEOperator() { SearchCriteria criteria = new SearchCriteria(QueryOperator.GTE, "id", List.of("3")); Specification spec = entitySpecification.specificationBuilder(List.of(criteria), Animal.class); assertThat(spec).isNotNull(); + List results = animalRepository.findAll(spec); + assertThat(results).isNotEmpty().extracting("type").containsOnly("Fish"); } @Test @@ -66,6 +95,8 @@ void shouldBuildSpecificationForLTEOperator() { SearchCriteria criteria = new SearchCriteria(QueryOperator.LTE, "id", List.of("7")); Specification spec = entitySpecification.specificationBuilder(List.of(criteria), Animal.class); assertThat(spec).isNotNull(); + List results = animalRepository.findAll(spec); + assertThat(results).isNotEmpty().extracting("type").containsOnly("Mammal", "Bird", "Fish"); } @Test @@ -73,6 +104,8 @@ void shouldBuildSpecificationForBetweenOperator() { SearchCriteria criteria = new SearchCriteria(QueryOperator.BETWEEN, "id", List.of("1", "5")); Specification spec = entitySpecification.specificationBuilder(List.of(criteria), Animal.class); assertThat(spec).isNotNull(); + List results = animalRepository.findAll(spec); + assertThat(results).isNotEmpty().extracting("type").containsOnly("Mammal", "Bird", "Fish"); } @Test @@ -80,6 +113,8 @@ void shouldBuildSpecificationForINOperator() { SearchCriteria criteria = new SearchCriteria(QueryOperator.IN, "type", List.of("Mammal", "Bird")); Specification spec = entitySpecification.specificationBuilder(List.of(criteria), Animal.class); assertThat(spec).isNotNull(); + List results = animalRepository.findAll(spec); + assertThat(results).isNotEmpty().extracting("type").containsOnly("Mammal", "Bird"); } @Test @@ -87,6 +122,8 @@ void shouldBuildSpecificationForNOTINOperator() { SearchCriteria criteria = new SearchCriteria(QueryOperator.NOT_IN, "type", List.of("Fish", "Reptile")); Specification spec = entitySpecification.specificationBuilder(List.of(criteria), Animal.class); assertThat(spec).isNotNull(); + List results = animalRepository.findAll(spec); + assertThat(results).isNotEmpty().extracting("type").containsOnly("Mammal", "Bird"); } @Test @@ -94,6 +131,8 @@ void shouldBuildSpecificationForLIKEOperator() { SearchCriteria criteria = new SearchCriteria(QueryOperator.LIKE, "name", List.of("%Lion%")); Specification spec = entitySpecification.specificationBuilder(List.of(criteria), Animal.class); assertThat(spec).isNotNull(); + List results = animalRepository.findAll(spec); + assertThat(results).isNotEmpty().extracting("type").containsOnly("Mammal"); } @Test @@ -101,6 +140,8 @@ void shouldBuildSpecificationForCONTAINSOperator() { SearchCriteria criteria = new SearchCriteria(QueryOperator.CONTAINS, "name", List.of("ar")); Specification spec = entitySpecification.specificationBuilder(List.of(criteria), Animal.class); assertThat(spec).isNotNull(); + List results = animalRepository.findAll(spec); + assertThat(results).isNotEmpty().extracting("type").containsOnly("Fish"); } @Test @@ -108,6 +149,8 @@ void shouldBuildSpecificationForSTARTS_WITHOperator() { SearchCriteria criteria = new SearchCriteria(QueryOperator.STARTS_WITH, "name", List.of("E")); Specification spec = entitySpecification.specificationBuilder(List.of(criteria), Animal.class); assertThat(spec).isNotNull(); + List results = animalRepository.findAll(spec); + assertThat(results).isNotEmpty().extracting("type").containsOnly("Bird"); } @Test @@ -115,6 +158,8 @@ void shouldBuildSpecificationForENDS_WITHOperator() { SearchCriteria criteria = new SearchCriteria(QueryOperator.ENDS_WITH, "name", List.of("e")); Specification spec = entitySpecification.specificationBuilder(List.of(criteria), Animal.class); assertThat(spec).isNotNull(); + List results = animalRepository.findAll(spec); + assertThat(results).isNotEmpty().extracting("type").containsOnly("Bird"); } @Test @@ -126,6 +171,8 @@ void shouldBuildSpecificationForANDOperator() { Specification spec = entitySpecification.specificationBuilder(List.of(criteria1, criteriaAnd, criteria2), Animal.class); assertThat(spec).isNotNull(); + List results = animalRepository.findAll(spec); + assertThat(results).isNotEmpty().extracting("type").containsOnly("Mammal"); } @Test @@ -133,5 +180,16 @@ void shouldBuildSpecificationForOROperator() { SearchCriteria criteriaOr = new SearchCriteria(QueryOperator.OR, "type", List.of("Amphibian", "Fish")); Specification spec = entitySpecification.specificationBuilder(List.of(criteriaOr), Animal.class); assertThat(spec).isNotNull(); + List results = animalRepository.findAll(spec); + assertThat(results).isNotEmpty().extracting("type").containsOnly("Fish"); + } + + @Test + void shouldThrowExceptionForInvalidFieldName() { + SearchCriteria criteria = new SearchCriteria(QueryOperator.EQ, "invalidField", List.of("value")); + assertThatThrownBy(() -> entitySpecification.specificationBuilder(List.of(criteria), Animal.class)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining( + "Unable to locate Attribute with the given name [invalidField] on this ManagedType [com.example.keysetpagination.entities.Animal]"); } } diff --git a/jpa/keyset-pagination/boot-data-window-pagination/src/test/java/com/example/keysetpagination/SchemaValidationTest.java b/jpa/keyset-pagination/boot-data-window-pagination/src/test/java/com/example/keysetpagination/repository/SchemaValidationTest.java similarity index 97% rename from jpa/keyset-pagination/boot-data-window-pagination/src/test/java/com/example/keysetpagination/SchemaValidationTest.java rename to jpa/keyset-pagination/boot-data-window-pagination/src/test/java/com/example/keysetpagination/repository/SchemaValidationTest.java index 008f8e68a..076f7a1a0 100644 --- a/jpa/keyset-pagination/boot-data-window-pagination/src/test/java/com/example/keysetpagination/SchemaValidationTest.java +++ b/jpa/keyset-pagination/boot-data-window-pagination/src/test/java/com/example/keysetpagination/repository/SchemaValidationTest.java @@ -1,4 +1,4 @@ -package com.example.keysetpagination; +package com.example.keysetpagination.repository; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; diff --git a/jpa/keyset-pagination/boot-data-window-pagination/src/test/java/com/example/keysetpagination/web/controllers/AnimalControllerIT.java b/jpa/keyset-pagination/boot-data-window-pagination/src/test/java/com/example/keysetpagination/web/controllers/AnimalControllerIT.java index 29a95d1aa..9d8de0017 100644 --- a/jpa/keyset-pagination/boot-data-window-pagination/src/test/java/com/example/keysetpagination/web/controllers/AnimalControllerIT.java +++ b/jpa/keyset-pagination/boot-data-window-pagination/src/test/java/com/example/keysetpagination/web/controllers/AnimalControllerIT.java @@ -149,6 +149,7 @@ void shouldReturnResultForNotEqualType() throws Exception { ] """)) .andExpect(status().isOk()) + // Total animals (10) - Mammals (3) - Birds (3) = 4 animals .andExpect(jsonPath("$.content", hasSize(4))) .andExpect(jsonPath("$.last", is(true))); } @@ -173,6 +174,7 @@ void shouldReturnEmptyResultForNonExistentType() throws Exception { """)) .andExpect(status().isOk()) .andExpect(jsonPath("$.content", hasSize(0))) + .andExpect(jsonPath("$.content").isArray()) .andExpect(jsonPath("$.last", is(true))); }