diff --git a/jpa/keyset-pagination/boot-data-window-pagination/src/main/java/com/example/keysetpagination/model/query/QueryOperator.java b/jpa/keyset-pagination/boot-data-window-pagination/src/main/java/com/example/keysetpagination/model/query/QueryOperator.java index 0f05b1596..d5e0e1e62 100644 --- a/jpa/keyset-pagination/boot-data-window-pagination/src/main/java/com/example/keysetpagination/model/query/QueryOperator.java +++ b/jpa/keyset-pagination/boot-data-window-pagination/src/main/java/com/example/keysetpagination/model/query/QueryOperator.java @@ -1,19 +1,66 @@ package com.example.keysetpagination.model.query; +/** + * Represents the supported operators for dynamic query specifications. + * These operators are used to build dynamic JPA queries through specifications. + */ public enum QueryOperator { + + // Comparison Operators + /** Equal to */ EQ, + /** Not equal to */ NE, + /** Less than */ LT, + /** Greater than */ GT, + /** Greater than or equal to */ GTE, + /** Less than or equal to */ LTE, + /** Between two values (inclusive) */ BETWEEN, + /** Is null check */ + IS_NULL, + /** Is not null check */ + IS_NOT_NULL, + + // Collection Operators + /** In a collection of values */ IN, + /** Not in a collection of values */ + NOT_IN, + + // String Operators + /** SQL LIKE operation */ LIKE, + /** Contains substring */ CONTAINS, + /** Starts with prefix */ STARTS_WITH, + /** Ends with suffix */ ENDS_WITH, + + // Logical Operators + /** Logical AND */ AND, - OR, - NOTIN + /** Logical OR */ + OR; + + /** + * Checks if the operator is applicable to string fields. + * @return true if the operator can be used with strings + */ + public boolean isStringOperator() { + return this == LIKE || this == CONTAINS || this == STARTS_WITH || this == ENDS_WITH; + } + + /** + * Checks if the operator is a logical operator. + * @return true if the operator is logical (AND, OR) + */ + public boolean isLogicalOperator() { + return this == AND || this == OR; + } } diff --git a/jpa/keyset-pagination/boot-data-window-pagination/src/main/java/com/example/keysetpagination/model/query/SearchCriteria.java b/jpa/keyset-pagination/boot-data-window-pagination/src/main/java/com/example/keysetpagination/model/query/SearchCriteria.java index 791710662..9cbeb73c5 100644 --- a/jpa/keyset-pagination/boot-data-window-pagination/src/main/java/com/example/keysetpagination/model/query/SearchCriteria.java +++ b/jpa/keyset-pagination/boot-data-window-pagination/src/main/java/com/example/keysetpagination/model/query/SearchCriteria.java @@ -1,6 +1,9 @@ package com.example.keysetpagination.model.query; +import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import java.util.List; import lombok.AllArgsConstructor; import lombok.Getter; @@ -13,11 +16,12 @@ @NoArgsConstructor public class SearchCriteria { - @NotBlank(message = "Operator cannot be null") - private QueryOperator queryOperator; + @NotNull(message = "Operator cannot be null") private QueryOperator queryOperator; @NotBlank(message = "Field name cannot be null or blank") private String field; + @NotNull(message = "Values list cannot be null") @Size(min = 1, message = "Values list cannot be empty") + @Valid private List values; } 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 250f06688..46a9ef172 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 @@ -6,7 +6,10 @@ import org.springframework.data.jpa.domain.Specification; /** - * Custom repository interface for efficient keyset pagination for any entities. + * Custom repository interface for efficient keyset pagination. + * + * @param the domain type the repository manages + * @see org.springframework.data.domain.Window */ public interface CustomRepository { diff --git a/jpa/keyset-pagination/boot-data-window-pagination/src/main/java/com/example/keysetpagination/repositories/CustomRepositoryImpl.java b/jpa/keyset-pagination/boot-data-window-pagination/src/main/java/com/example/keysetpagination/repositories/CustomRepositoryImpl.java index be32dbe81..36f488ebd 100644 --- a/jpa/keyset-pagination/boot-data-window-pagination/src/main/java/com/example/keysetpagination/repositories/CustomRepositoryImpl.java +++ b/jpa/keyset-pagination/boot-data-window-pagination/src/main/java/com/example/keysetpagination/repositories/CustomRepositoryImpl.java @@ -1,7 +1,6 @@ package com.example.keysetpagination.repositories; import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; import jakarta.persistence.TypedQuery; import jakarta.persistence.criteria.*; import java.util.ArrayList; @@ -17,7 +16,6 @@ @Repository public class CustomRepositoryImpl implements CustomRepository { - @PersistenceContext private final EntityManager entityManager; public CustomRepositoryImpl(EntityManager entityManager) { diff --git a/jpa/keyset-pagination/boot-data-window-pagination/src/main/java/com/example/keysetpagination/services/AnimalService.java b/jpa/keyset-pagination/boot-data-window-pagination/src/main/java/com/example/keysetpagination/services/AnimalService.java index f4e61bc41..d127a39c1 100644 --- a/jpa/keyset-pagination/boot-data-window-pagination/src/main/java/com/example/keysetpagination/services/AnimalService.java +++ b/jpa/keyset-pagination/boot-data-window-pagination/src/main/java/com/example/keysetpagination/services/AnimalService.java @@ -60,10 +60,10 @@ private Pageable createPageable(FindAnimalsQuery findAnimalsQuery) { return PageRequest.of(pageNo, findAnimalsQuery.pageSize(), sort); } - public Window searchAnimals(SearchCriteria[] searchCriteriaArray, int pageSize, Long scrollId) { + public Window searchAnimals(List searchCriteriaList, int pageSize, Long scrollId) { Specification specification = - animalEntitySpecification.specificationBuilder(searchCriteriaArray, Animal.class); + animalEntitySpecification.specificationBuilder(searchCriteriaList, Animal.class); // Create initial ScrollPosition or continue from the given scrollId ScrollPosition position = scrollId == null 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 0b3f517ea..29d9fd085 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,12 +8,14 @@ import jakarta.persistence.criteria.Root; import jakarta.persistence.metamodel.ManagedType; import jakarta.persistence.metamodel.Metamodel; +import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import java.util.function.BiFunction; import java.util.function.Function; -import java.util.stream.Stream; import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; @Service public class EntitySpecification { @@ -24,11 +26,11 @@ public EntitySpecification(EntityManager entityManager) { this.entityManager = entityManager; } - public Specification specificationBuilder(SearchCriteria[] searchCriteriaArray, Class entityClass) { - validateMetadata(searchCriteriaArray, entityClass); + public Specification specificationBuilder(List searchCriteriaList, Class entityClass) { + validateMetadata(searchCriteriaList, entityClass); return (root, query, criteriaBuilder) -> { // Dynamically build predicates based on filters - return Stream.of(searchCriteriaArray) + return searchCriteriaList.stream() .map(entry -> createPredicate(entry, root, criteriaBuilder)) .reduce(criteriaBuilder::and) .orElse(criteriaBuilder.conjunction()); @@ -40,49 +42,92 @@ private Predicate createPredicate(SearchCriteria searchCriteria, Root root, C QueryOperator operator = searchCriteria.getQueryOperator(); List values = searchCriteria.getValues(); - if (values == null || values.isEmpty() && operator != QueryOperator.IN && operator != QueryOperator.NOTIN) { + if ((CollectionUtils.isEmpty(values)) && operator != QueryOperator.IN && operator != QueryOperator.NOT_IN) { throw new IllegalArgumentException("Values cannot be null or empty for operator: " + operator); } + // Fetch the field type + Class fieldType = root.get(fieldName).getJavaType(); + + // Convert values to the appropriate type + List typedValues = + values.stream().map(value -> convertToType(value, fieldType)).toList(); + // Switch for building predicates return switch (operator) { case EQ -> combinePredicates( - values, value -> criteriaBuilder.equal(root.get(fieldName), value), criteriaBuilder::and); + typedValues, value -> criteriaBuilder.equal(root.get(fieldName), value), criteriaBuilder::and); case NE -> combinePredicates( - values, value -> criteriaBuilder.notEqual(root.get(fieldName), value), criteriaBuilder::and); + typedValues, value -> criteriaBuilder.notEqual(root.get(fieldName), value), criteriaBuilder::and); case GT -> combinePredicates( - values, value -> criteriaBuilder.greaterThan(root.get(fieldName), value), criteriaBuilder::and); + typedValues, + value -> criteriaBuilder.greaterThan(root.get(fieldName), (Comparable) value), + criteriaBuilder::and); case LT -> combinePredicates( - values, value -> criteriaBuilder.lessThan(root.get(fieldName), value), criteriaBuilder::and); + typedValues, + value -> criteriaBuilder.lessThan(root.get(fieldName), (Comparable) value), + criteriaBuilder::and); case GTE -> combinePredicates( - values, - value -> criteriaBuilder.greaterThanOrEqualTo(root.get(fieldName), value), + typedValues, + value -> criteriaBuilder.greaterThanOrEqualTo(root.get(fieldName), (Comparable) value), criteriaBuilder::and); case LTE -> combinePredicates( - values, - value -> criteriaBuilder.lessThanOrEqualTo(root.get(fieldName), value), + typedValues, + value -> criteriaBuilder.lessThanOrEqualTo(root.get(fieldName), (Comparable) value), criteriaBuilder::and); case LIKE, CONTAINS -> combinePredicates( - values, value -> criteriaBuilder.like(root.get(fieldName), "%" + value + "%"), criteriaBuilder::or); + typedValues, + value -> criteriaBuilder.like(root.get(fieldName), "%" + value + "%"), + criteriaBuilder::or); case STARTS_WITH -> combinePredicates( - values, value -> criteriaBuilder.like(root.get(fieldName), value + "%"), criteriaBuilder::and); + typedValues, value -> criteriaBuilder.like(root.get(fieldName), value + "%"), criteriaBuilder::and); case ENDS_WITH -> combinePredicates( - values, value -> criteriaBuilder.like(root.get(fieldName), "%" + value), criteriaBuilder::and); + typedValues, value -> criteriaBuilder.like(root.get(fieldName), "%" + value), criteriaBuilder::and); case BETWEEN -> { - if (values.size() != 2) { + if (typedValues.size() != 2) { throw new IllegalArgumentException("BETWEEN operator requires exactly two values"); } - yield criteriaBuilder.between(root.get(fieldName), values.get(0), values.get(1)); + yield criteriaBuilder.between( + root.get(fieldName), (Comparable) typedValues.get(0), (Comparable) typedValues.get(1)); } - case IN -> root.get(fieldName).in(values); - case NOTIN -> criteriaBuilder.not(root.get(fieldName).in(values)); + case IN -> root.get(fieldName).in(typedValues); + case NOT_IN -> criteriaBuilder.not(root.get(fieldName).in(typedValues)); + case OR -> criteriaBuilder.or(root.get(fieldName).in(typedValues)); + case AND -> criteriaBuilder.and(root.get(fieldName).in(typedValues)); default -> throw new IllegalArgumentException("Unsupported operator: " + operator); }; } + private Object convertToType(String value, Class fieldType) { + try { + if (fieldType.equals(String.class)) { + return 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)) { + return Long.valueOf(value); + } else if (fieldType.equals(Double.class) || fieldType.equals(double.class)) { + return Double.valueOf(value); + } else if (fieldType.equals(Boolean.class) || fieldType.equals(boolean.class)) { + return Boolean.valueOf(value); + } else if (fieldType.equals(LocalDate.class)) { + return LocalDate.parse(value); + } else if (fieldType.equals(LocalDateTime.class)) { + return LocalDateTime.parse(value); + } else if (Enum.class.isAssignableFrom(fieldType)) { + return Enum.valueOf((Class) fieldType, value); + } else { + throw new IllegalArgumentException("Unsupported field type: " + fieldType.getName()); + } + } catch (Exception e) { + throw new IllegalArgumentException( + "Failed to convert value '" + value + "' to type " + fieldType.getName(), e); + } + } + private Predicate combinePredicates( - List values, - Function predicateFunction, + List values, + Function predicateFunction, BiFunction combiner) { return values.stream() .map(predicateFunction) @@ -90,11 +135,11 @@ private Predicate combinePredicates( .orElseThrow(() -> new IllegalArgumentException("No predicates could be generated from values")); } - private void validateMetadata(SearchCriteria[] searchCriteriaArray, Class entityClass) { + private void validateMetadata(List searchCriteriaList, Class entityClass) { Metamodel metamodel = entityManager.getMetamodel(); ManagedType managedType = metamodel.managedType(entityClass); - for (SearchCriteria searchCriteria : searchCriteriaArray) { + for (SearchCriteria searchCriteria : searchCriteriaList) { String fieldName = searchCriteria.getField(); if (managedType.getAttribute(fieldName) == null) { throw new IllegalArgumentException("Invalid field: " + fieldName); diff --git a/jpa/keyset-pagination/boot-data-window-pagination/src/main/java/com/example/keysetpagination/web/controllers/AnimalController.java b/jpa/keyset-pagination/boot-data-window-pagination/src/main/java/com/example/keysetpagination/web/controllers/AnimalController.java index 12a6c76fe..72bf4ea75 100644 --- a/jpa/keyset-pagination/boot-data-window-pagination/src/main/java/com/example/keysetpagination/web/controllers/AnimalController.java +++ b/jpa/keyset-pagination/boot-data-window-pagination/src/main/java/com/example/keysetpagination/web/controllers/AnimalController.java @@ -16,6 +16,7 @@ import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import java.net.URI; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Window; import org.springframework.http.ResponseEntity; @@ -68,7 +69,7 @@ public Window searchAnimals( @Max(100) int pageSize, @Parameter(description = "Scroll ID for pagination") @RequestParam(required = false) Long scrollId, - @RequestBody SearchCriteria[] searchCriteria) { + @RequestBody @Valid List searchCriteria) { return animalService.searchAnimals(searchCriteria, pageSize, scrollId); } 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/services/EntitySpecificationIntTest.java new file mode 100644 index 000000000..160e04252 --- /dev/null +++ b/jpa/keyset-pagination/boot-data-window-pagination/src/test/java/com/example/keysetpagination/services/EntitySpecificationIntTest.java @@ -0,0 +1,137 @@ +package com.example.keysetpagination.services; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.example.keysetpagination.common.AbstractIntegrationTest; +import com.example.keysetpagination.entities.Animal; +import com.example.keysetpagination.model.query.QueryOperator; +import com.example.keysetpagination.model.query.SearchCriteria; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.springframework.data.jpa.domain.Specification; + +class EntitySpecificationIntTest extends AbstractIntegrationTest { + + private EntitySpecification entitySpecification; + + @PersistenceContext + private EntityManager entityManager; + + @BeforeEach + void setUp() { + entitySpecification = new EntitySpecification<>(entityManager); + } + + @Test + void shouldBuildSpecificationForEQOperator() { + SearchCriteria criteria = new SearchCriteria(QueryOperator.EQ, "type", List.of("Mammal")); + Specification spec = entitySpecification.specificationBuilder(List.of(criteria), Animal.class); + assertThat(spec).isNotNull(); + } + + @Test + void shouldBuildSpecificationForNEOperator() { + SearchCriteria criteria = new SearchCriteria(QueryOperator.NE, "type", List.of("Reptile")); + Specification spec = entitySpecification.specificationBuilder(List.of(criteria), Animal.class); + assertThat(spec).isNotNull(); + } + + @Test + void shouldBuildSpecificationForLTOperator() { + SearchCriteria criteria = new SearchCriteria(QueryOperator.LT, "id", List.of("5")); + Specification spec = entitySpecification.specificationBuilder(List.of(criteria), Animal.class); + assertThat(spec).isNotNull(); + } + + @Test + void shouldBuildSpecificationForGTOperator() { + SearchCriteria criteria = new SearchCriteria(QueryOperator.GT, "id", List.of("2")); + Specification spec = entitySpecification.specificationBuilder(List.of(criteria), Animal.class); + assertThat(spec).isNotNull(); + } + + @Test + void shouldBuildSpecificationForGTEOperator() { + SearchCriteria criteria = new SearchCriteria(QueryOperator.GTE, "id", List.of("3")); + Specification spec = entitySpecification.specificationBuilder(List.of(criteria), Animal.class); + assertThat(spec).isNotNull(); + } + + @Test + void shouldBuildSpecificationForLTEOperator() { + SearchCriteria criteria = new SearchCriteria(QueryOperator.LTE, "id", List.of("7")); + Specification spec = entitySpecification.specificationBuilder(List.of(criteria), Animal.class); + assertThat(spec).isNotNull(); + } + + @Test + 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(); + } + + @Test + 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(); + } + + @Test + 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(); + } + + @Test + void shouldBuildSpecificationForLIKEOperator() { + SearchCriteria criteria = new SearchCriteria(QueryOperator.LIKE, "name", List.of("%Lion%")); + Specification spec = entitySpecification.specificationBuilder(List.of(criteria), Animal.class); + assertThat(spec).isNotNull(); + } + + @Test + void shouldBuildSpecificationForCONTAINSOperator() { + SearchCriteria criteria = new SearchCriteria(QueryOperator.CONTAINS, "name", List.of("ar")); + Specification spec = entitySpecification.specificationBuilder(List.of(criteria), Animal.class); + assertThat(spec).isNotNull(); + } + + @Test + 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(); + } + + @Test + 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(); + } + + @Test + @Disabled("Not Implemented yet") + void shouldBuildSpecificationForANDOperator() { + SearchCriteria criteria1 = new SearchCriteria(QueryOperator.EQ, "type", List.of("Bird")); + SearchCriteria criteria2 = new SearchCriteria(QueryOperator.EQ, "habitat", List.of("Forest")); + SearchCriteria criteriaAnd = new SearchCriteria(QueryOperator.AND, null, null); + Specification spec = + entitySpecification.specificationBuilder(List.of(criteria1, criteriaAnd, criteria2), Animal.class); + assertThat(spec).isNotNull(); + } + + @Test + 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(); + } +} 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 95b123dd1..29a95d1aa 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 @@ -18,7 +18,6 @@ import com.example.keysetpagination.repositories.CustomWindow; import java.util.ArrayList; import java.util.List; -import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -101,14 +100,16 @@ void shouldSearchAnimals() throws Exception { .getResponse() .getContentAsString(); - CustomWindow> window = objectMapper.readValue(contentAsString, CustomWindow.class); - List> animalResponses = window.getContent(); - boolean windowLast = window.isLast(); - Map animalResponsesLast = animalResponses.getLast(); + CustomWindow window = objectMapper.readValue( + contentAsString, + objectMapper.getTypeFactory().constructParametricType(CustomWindow.class, Animal.class)); + List animalResponses = window.getContent(); this.mockMvc .perform(post("/api/animals/search") .param("pageSize", "2") - .param("scrollId", String.valueOf(animalResponsesLast.get("id"))) + .param( + "scrollId", + String.valueOf(animalResponses.getLast().getId())) .content( """ [ @@ -288,4 +289,258 @@ void shouldDeleteAnimal() throws Exception { // Verify animal is deleted this.mockMvc.perform(get("/api/animals/{id}", animalId)).andExpect(status().isNotFound()); } + + @Test + void shouldReturnResultForEqualOperator() throws Exception { + // Test for EQ operator + this.mockMvc + .perform( + post("/api/animals/search") + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + [ + { + "queryOperator": "EQ", + "field": "name", + "values": ["Lion"] + } + ] + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.size()", is(1))) + .andExpect(jsonPath("$.content[0].name", is("Lion"))) + .andExpect(jsonPath("$.last", is(true))); + } + + @Test + void shouldReturnResultForNotEqualOperator() throws Exception { + // Test for NE operator + this.mockMvc + .perform( + post("/api/animals/search") + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + [ + { + "queryOperator": "NE", + "field": "type", + "values": ["Mammal"] + } + ] + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.size()", is(7))) // Total 10 animals - 3 mammals + .andExpect(jsonPath("$.last", is(true))); + } + + @Test + void shouldReturnResultForInOperator() throws Exception { + // Test for IN operator + this.mockMvc + .perform( + post("/api/animals/search") + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + [ + { + "queryOperator": "IN", + "field": "type", + "values": ["Bird", "Fish"] + } + ] + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.size()", is(4))) // "Parrot", "Penguin", "Shark", "Eagle" + .andExpect(jsonPath("$.last", is(true))); + } + + @Test + void shouldReturnResultForNotInOperator() throws Exception { + // Test for NOTIN operator + this.mockMvc + .perform( + post("/api/animals/search") + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + [ + { + "queryOperator": "NOT_IN", + "field": "type", + "values": ["Bird", "Fish"] + } + ] + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.size()", is(6))) + .andExpect(jsonPath("$.last", is(true))); + } + + @Test + void shouldReturnResultForLikeOperator() throws Exception { + // Test for LIKE operator + this.mockMvc + .perform( + post("/api/animals/search") + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + [ + { + "queryOperator": "LIKE", + "field": "name", + "values": ["%e%"] + } + ] + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath( + "$.content.size()", is(6))) // "Elephant", "Penguin", "Crocodile", ""Eagle"", "Whale", "Snake" + .andExpect(jsonPath("$.last", is(true))); + } + + @Test + void shouldReturnResultForContainsOperator() throws Exception { + // Test for CONTAINS operator + this.mockMvc + .perform( + post("/api/animals/search") + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + [ + { + "queryOperator": "CONTAINS", + "field": "name", + "values": ["ar"] + } + ] + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.size()", is(2))) // "Parrot", "Shark" + .andExpect(jsonPath("$.last", is(true))); + } + + @Test + void shouldReturnResultForStartsWithOperator() throws Exception { + // Test for STARTS_WITH operator + this.mockMvc + .perform( + post("/api/animals/search") + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + [ + { + "queryOperator": "STARTS_WITH", + "field": "name", + "values": ["P"] + } + ] + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.size()", is(2))) // "Parrot", "Penguin" + .andExpect(jsonPath("$.last", is(true))); + } + + @Test + void shouldReturnResultForEndsWithOperator() throws Exception { + // Test for ENDS_WITH operator + this.mockMvc + .perform( + post("/api/animals/search") + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + [ + { + "queryOperator": "ENDS_WITH", + "field": "name", + "values": ["g"] + } + ] + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.size()", is(1))) // "Frog" + .andExpect(jsonPath("$.content[0].name", is("Frog"))) + .andExpect(jsonPath("$.last", is(true))); + } + + @Test + void shouldReturnResultForBetweenOperator() throws Exception { + // Since 'Animal' doesn't have a numeric field, we'll use 'id' for BETWEEN operator + Long minId = animalList.get(0).getId(); + Long maxId = animalList.get(4).getId(); + + this.mockMvc + .perform(post("/api/animals/search") + .contentType(MediaType.APPLICATION_JSON) + .content(String.format( + """ + [ + { + "queryOperator": "BETWEEN", + "field": "id", + "values": ["%d", "%d"] + } + ] + """, + minId, maxId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.size()", is(5))) // Animals with IDs between minId and maxId + .andExpect(jsonPath("$.last", is(true))); + } + + @Test + void shouldReturnResultForAndOperator() throws Exception { + // Test for AND operator + this.mockMvc + .perform( + post("/api/animals/search") + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + [ + { + "queryOperator": "EQ", + "field": "type", + "values": ["Bird"] + }, + { + "queryOperator": "EQ", + "field": "habitat", + "values": ["Rainforest"] + } + ] + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.size()", is(1))) // "Parrot" + .andExpect(jsonPath("$.content[0].name", is("Parrot"))) + .andExpect(jsonPath("$.last", is(true))); + } + + @Test + void shouldReturnResultForOrOperator() throws Exception { + // Test for OR operator + this.mockMvc + .perform( + post("/api/animals/search") + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + [ + { + "queryOperator": "OR", + "field": "name", + "values": ["Shark", "Eagle"] + } + ] + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.size()", is(2))) // "Shark" and "Eagle" + .andExpect(jsonPath("$.last", is(true))); + } }