From 3077707f66f579c4507737b36df72b1f4a3460a7 Mon Sep 17 00:00:00 2001 From: evebrnd Date: Fri, 29 May 2026 12:14:53 +0200 Subject: [PATCH 1/7] feat(api): add POST endpoint for searching entities --- docker-compose.yml | 2 + scripts/init-extensions.sql | 3 + .../domain/constant/FilterConstraints.java | 15 + .../domain/constant/SearchConstraints.java | 27 + .../domain/constant/ValidationMessages.java | 38 +- .../exception/InvalidQueryDslException.java | 12 - .../filter/InvalidFilterDslException.java | 13 + .../search/InvalidSearchQueryException.java | 13 + .../model/entity/RawSearchFilterNode.java | 36 + .../domain/model/entity/SearchFilterNode.java | 49 ++ .../domain/model/enums/LogicalConnector.java | 11 + .../domain/model/enums/SearchOperator.java | 27 + .../domain/port/EntityRepositoryPort.java | 7 +- .../service/EntityQueryParserService.java | 282 ------- .../domain/service/entity/EntityService.java | 82 +- .../service/filter/EntityFilterDslParser.java | 276 ++++++ .../service/search/SearchFilterParser.java | 111 +++ .../search/SearchFilterValidationService.java | 166 ++++ .../api/configuration/SwaggerDescription.java | 26 +- .../api/controller/EntityController.java | 136 +-- .../api/dto/in/EntitySearchRequestDtoIn.java | 67 ++ .../adapters/api/dto/in/FilterNodeDtoIn.java | 33 + .../api/handler/ApiExceptionHandler.java | 23 +- .../api/mapper/entity/SearchFilterMapper.java | 39 + .../persistence/PostgresEntityAdapter.java | 25 +- .../EntityFilterSpecification.java | 212 +++++ .../EntitySearchSpecification.java | 307 +++++++ .../specification/EntitySpecification.java | 217 ----- .../specification/JpaPredicateBuilder.java | 116 +++ .../V3_5__add_search_performance_indexes.sql | 75 ++ .../idp_core/AbstractIntegrationTest.java | 3 +- .../service/EntityQueryParserServiceTest.java | 522 ------------ .../service/entity/EntityServiceTest.java | 93 +- .../filter/EntityFilterDslParserTest.java | 528 ++++++++++++ .../search/SearchFilterParserTest.java | 218 +++++ .../SearchFilterValidationServiceTest.java | 409 +++++++++ .../api/controller/EntityControllerTest.java | 795 ++++++++++++++++++ .../EntityFilterSpecificationTest.java | 12 + .../EntitySearchSpecificationTest.java | 257 ++++++ .../EntitySpecificationTest.java | 67 -- .../JpaPredicateBuilderTest.java | 70 ++ .../resources/db/init/init-extensions.sql | 3 + ...chEntities_200_byRelationNameContains.json | 32 + .../searchEntities_200_byRelationNameEq.json | 20 + ...earchEntities_200_byRelationsAsTarget.json | 25 + ...ities_200_byRelationsAsTargetPresence.json | 17 + ...rchEntities_200_byTemplateAndProperty.json | 38 + .../entity/v1/searchEntities_200_neq.json | 17 + .../v1/searchEntities_200_orTemplates.json | 25 + .../v1/searchEntities_200_startsWith.json | 20 + 50 files changed, 4423 insertions(+), 1194 deletions(-) create mode 100644 scripts/init-extensions.sql create mode 100644 src/main/java/com/decathlon/idp_core/domain/constant/FilterConstraints.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/constant/SearchConstraints.java delete mode 100644 src/main/java/com/decathlon/idp_core/domain/exception/InvalidQueryDslException.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/exception/filter/InvalidFilterDslException.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/exception/search/InvalidSearchQueryException.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/model/entity/RawSearchFilterNode.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/model/entity/SearchFilterNode.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/model/enums/LogicalConnector.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/model/enums/SearchOperator.java delete mode 100644 src/main/java/com/decathlon/idp_core/domain/service/EntityQueryParserService.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/service/filter/EntityFilterDslParser.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/service/search/SearchFilterParser.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/service/search/SearchFilterValidationService.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntitySearchRequestDtoIn.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/FilterNodeDtoIn.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/SearchFilterMapper.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntityFilterSpecification.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecification.java delete mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySpecification.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/JpaPredicateBuilder.java create mode 100644 src/main/resources/db/migration/V3_5__add_search_performance_indexes.sql delete mode 100644 src/test/java/com/decathlon/idp_core/domain/service/EntityQueryParserServiceTest.java create mode 100644 src/test/java/com/decathlon/idp_core/domain/service/filter/EntityFilterDslParserTest.java create mode 100644 src/test/java/com/decathlon/idp_core/domain/service/search/SearchFilterParserTest.java create mode 100644 src/test/java/com/decathlon/idp_core/domain/service/search/SearchFilterValidationServiceTest.java create mode 100644 src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntityFilterSpecificationTest.java create mode 100644 src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecificationTest.java delete mode 100644 src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySpecificationTest.java create mode 100644 src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/JpaPredicateBuilderTest.java create mode 100644 src/test/resources/db/init/init-extensions.sql create mode 100644 src/test/resources/integration_test/json/entity/v1/searchEntities_200_byRelationNameContains.json create mode 100644 src/test/resources/integration_test/json/entity/v1/searchEntities_200_byRelationNameEq.json create mode 100644 src/test/resources/integration_test/json/entity/v1/searchEntities_200_byRelationsAsTarget.json create mode 100644 src/test/resources/integration_test/json/entity/v1/searchEntities_200_byRelationsAsTargetPresence.json create mode 100644 src/test/resources/integration_test/json/entity/v1/searchEntities_200_byTemplateAndProperty.json create mode 100644 src/test/resources/integration_test/json/entity/v1/searchEntities_200_neq.json create mode 100644 src/test/resources/integration_test/json/entity/v1/searchEntities_200_orTemplates.json create mode 100644 src/test/resources/integration_test/json/entity/v1/searchEntities_200_startsWith.json diff --git a/docker-compose.yml b/docker-compose.yml index 139089d2..13f77845 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,8 @@ services: POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-idpcore_password} ports: - "5437:5432" + volumes: + - ./scripts/init-extensions.sql:/docker-entrypoint-initdb.d/01-init-extensions.sql:ro networks: - postgres restart: unless-stopped diff --git a/scripts/init-extensions.sql b/scripts/init-extensions.sql new file mode 100644 index 00000000..131be460 --- /dev/null +++ b/scripts/init-extensions.sql @@ -0,0 +1,3 @@ +-- Initialize PostgreSQL extensions required by the application. +-- This script runs once on first container startup (docker-entrypoint-initdb.d). +CREATE EXTENSION IF NOT EXISTS pg_trgm; diff --git a/src/main/java/com/decathlon/idp_core/domain/constant/FilterConstraints.java b/src/main/java/com/decathlon/idp_core/domain/constant/FilterConstraints.java new file mode 100644 index 00000000..926c3d65 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/constant/FilterConstraints.java @@ -0,0 +1,15 @@ +package com.decathlon.idp_core.domain.constant; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/// Domain constants for the entity filter query DSL safety limits. +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class FilterConstraints { + + /// Maximum number of filter criteria per `q` query string (DoS prevention). + public static final int MAX_CRITERIA_COUNT = 10; + + /// Maximum length (in characters) of a key or value in a single filter criterion. + public static final int MAX_KEY_VALUE_LENGTH = 255; +} diff --git a/src/main/java/com/decathlon/idp_core/domain/constant/SearchConstraints.java b/src/main/java/com/decathlon/idp_core/domain/constant/SearchConstraints.java new file mode 100644 index 00000000..75f4ae2f --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/constant/SearchConstraints.java @@ -0,0 +1,27 @@ +package com.decathlon.idp_core.domain.constant; + +import java.util.Set; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/// Domain constants for search and filter query safety limits. +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class SearchConstraints { + + /// Maximum number of entities returned per page in a search request. + public static final int MAX_PAGE_SIZE = 500; + + /// Maximum length (in characters) of the free-text `query` parameter. + public static final int MAX_QUERY_LENGTH = 255; + + /// Maximum nesting depth of a [com.decathlon.idp_core.domain.model.entity.SearchFilterNode] tree. + public static final int MAX_NESTING_DEPTH = 5; + + /// Maximum total number of criterion nodes across a + /// [com.decathlon.idp_core.domain.model.entity.SearchFilterNode] tree. + public static final int MAX_TOTAL_CRITERIA = 50; + + /// Fields on which search results may be sorted. + public static final Set ALLOWED_SORT_FIELDS = Set.of("identifier", "name", "templateIdentifier"); +} diff --git a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java index 219b42b3..c49d6dd5 100644 --- a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java +++ b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java @@ -83,12 +83,34 @@ public static String minMaxConstraintViolated(String constraint) { return PROPERTY_RULES_MIN_MAX_CONSTRAINT_VIOLATED.replace("{constraint}", constraint); } - // Filter query validation messages - public static final String FILTER_TOO_MANY_CRITERIA = "Filter query exceeds maximum of %d criteria"; - public static final String FILTER_VALUE_TOO_LONG = "Filter value must not exceed %d characters in criterion '%s'"; - public static final String FILTER_KEY_TOO_LONG = "Filter key must not exceed %d characters in criterion '%s'"; - public static final String FILTER_INVALID_FORMAT = "Invalid query format, expected field:operator:value"; - public static final String FILTER_DUPLICATE_CRITERION = "Multiple filters for the same property are not supported"; - public static final String FILTER_TYPE_MISMATCH = "Operation '%s' is not applicable for field '%s'."; - public static final String FILTER_PROPERTY_TYPE_NOT_NUMERIC = "Operation '%s' is not applicable for property '%s': only NUMBER properties support comparison operators."; + // Filter query validation messages + public static final String FILTER_TOO_MANY_CRITERIA = "Filter query exceeds maximum of %d criteria"; + public static final String FILTER_VALUE_TOO_LONG = "Filter value must not exceed %d characters in criterion '%s'"; + public static final String FILTER_KEY_TOO_LONG = "Filter key must not exceed %d characters in criterion '%s'"; + public static final String FILTER_INVALID_FORMAT = "Invalid query format, expected field:operator:value"; + public static final String FILTER_DUPLICATE_CRITERION = "Multiple filters for the same property are not supported"; + public static final String FILTER_TYPE_MISMATCH = "Operation '%s' is not applicable for field '%s'."; + public static final String FILTER_PROPERTY_TYPE_NOT_NUMERIC = "Operation '%s' is not applicable for property '%s': only NUMBER properties support comparison operators."; + + // Search filter validation messages + public static final String SEARCH_INVALID_CONNECTOR = "Invalid connector '%s'. Supported values: AND, OR"; + public static final String SEARCH_INVALID_OPERATOR = "Invalid operation '%s'. Supported values: EQ, NEQ, CONTAINS, NOT_CONTAINS, STARTS_WITH, ENDS_WITH, GT, GTE, LT, LTE"; + public static final String SEARCH_INVALID_FIELD = "Unknown field '%s'. Supported fields: template, identifier, name, relation, property.{name}, relation.{name}, relation.{name}.identifier, relation.{name}.name, relations_as_target, relations_as_target.{name}.identifier, relations_as_target.{name}.name"; + public static final String SEARCH_TOO_MANY_CRITERIA = "Search filter exceeds maximum of %d total criteria"; + public static final String SEARCH_NESTING_TOO_DEEP = "Search filter exceeds maximum nesting depth of %d"; + public static final String SEARCH_CRITERION_MISSING_FIELD = "A criterion node must have a non-blank 'field'"; + public static final String SEARCH_CRITERION_MISSING_OPERATION = "A criterion node must have a non-blank 'operation'"; + public static final String SEARCH_CRITERION_MISSING_VALUE = "A criterion node must have a non-blank 'value'"; + public static final String SEARCH_GROUP_MISSING_CONNECTOR = "A group node must have a non-blank 'connector'"; + public static final String SEARCH_GROUP_MISSING_CRITERIA = "A group node must have a non-empty 'criteria' list"; + public static final String SEARCH_QUERY_TOO_LONG = "Search query must not exceed %d characters"; + public static final String SEARCH_NUMERIC_OPERATOR_REQUIRES_PROPERTY = "Operator '%s' is only valid for property.{name} fields"; + public static final String SEARCH_INVALID_SORT_FIELD = "Invalid sort field '%s'. Supported fields: identifier, name, templateIdentifier"; + public static final String SEARCH_INVALID_SORT_FORMAT = "Invalid sort expression '%s'. Expected format: field or field:asc|desc"; + public static final String SEARCH_PAGE_SIZE_TOO_LARGE = "Page size must not exceed %d"; + public static final String SEARCH_PAGE_INVALID = "Page index must be 0 or greater"; + public static final String SEARCH_SIZE_INVALID = "Page size must be greater than 0"; + public static final String SEARCH_NUMERIC_OPERATOR_INVALID_VALUE = "Value '%s' is not a valid number for operator '%s'"; + public static final String SEARCH_NUMERIC_OPERATOR_PROPERTY_TYPE_MISMATCH = "Property '%s' in template '%s' is of type %s; operators GT, GTE, LT, LTE require type NUMBER"; + } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/InvalidQueryDslException.java b/src/main/java/com/decathlon/idp_core/domain/exception/InvalidQueryDslException.java deleted file mode 100644 index 943d6e41..00000000 --- a/src/main/java/com/decathlon/idp_core/domain/exception/InvalidQueryDslException.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.decathlon.idp_core.domain.exception; - -/// Domain exception thrown when the `q` filter query string contains invalid syntax. -/// -/// **Business semantics:** Signals that the caller provided a malformed filter query. -/// This exception should be mapped to HTTP 400 Bad Request by the infrastructure layer. -public class InvalidQueryDslException extends RuntimeException { - - public InvalidQueryDslException(String message) { - super(message); - } -} diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/filter/InvalidFilterDslException.java b/src/main/java/com/decathlon/idp_core/domain/exception/filter/InvalidFilterDslException.java new file mode 100644 index 00000000..aacd82ce --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/filter/InvalidFilterDslException.java @@ -0,0 +1,13 @@ +package com.decathlon.idp_core.domain.exception.filter; + +/// Domain exception thrown when the `q` filter query string contains invalid syntax. +/// +/// **Business semantics:** Signals that the caller provided a malformed filter query +/// for the `GET /api/v1/entities/{template}?q=` endpoint. This exception should be +/// mapped to HTTP 400 Bad Request by the infrastructure layer. +public class InvalidFilterDslException extends RuntimeException { + + public InvalidFilterDslException(String message) { + super(message); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/search/InvalidSearchQueryException.java b/src/main/java/com/decathlon/idp_core/domain/exception/search/InvalidSearchQueryException.java new file mode 100644 index 00000000..3506f855 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/search/InvalidSearchQueryException.java @@ -0,0 +1,13 @@ +package com.decathlon.idp_core.domain.exception.search; + +/// Domain exception thrown when a search filter tree or free-text query contains invalid syntax. +/// +/// **Business semantics:** Signals that the caller provided a malformed search request +/// for the `POST /api/v1/entities/search` endpoint. This exception should be mapped to +/// HTTP 400 Bad Request by the infrastructure layer. +public class InvalidSearchQueryException extends RuntimeException { + + public InvalidSearchQueryException(String message) { + super(message); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/RawSearchFilterNode.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/RawSearchFilterNode.java new file mode 100644 index 00000000..46553473 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/RawSearchFilterNode.java @@ -0,0 +1,36 @@ +package com.decathlon.idp_core.domain.model.entity; + +import java.util.List; + +/// A domain-native raw node of the search filter tree, produced by the infrastructure mapper +/// before domain parsing and validation. +/// +/// **Responsibility:** Carries the unvalidated, string-only representation of a filter tree +/// from the API adapter into the domain parser. All fields are raw strings — no enums, +/// no framework types. Structural conversion from [com.decathlon.idp_core.infrastructure.adapters.api.dto.in.FilterNodeDtoIn] +/// to this type is handled by the infrastructure mapper; validation and enum resolution +/// are handled by the domain parser. +/// +/// **Nodes:** +/// - [Group] — a logical group with a raw connector string and child nodes +/// - [Criterion] — a leaf predicate with raw field, operation, and value strings +public sealed interface RawSearchFilterNode { + + /// A logical group combining multiple child [RawSearchFilterNode]s. + /// + /// @param connector raw connector string (e.g. "AND", "OR"); may be null or blank until validated + /// @param nodes child nodes; may be null until validated + record Group(String connector, List nodes) implements RawSearchFilterNode { + public Group { + nodes = nodes != null ? List.copyOf(nodes) : List.of(); + } + } + + /// A leaf predicate in the filter tree. + /// + /// @param field raw field name (e.g. "template", "property.language"); may be null until validated + /// @param operation raw operation string (e.g. "EQ", "CONTAINS"); may be null until validated + /// @param value raw value string; may be null until validated + record Criterion(String field, String operation, String value) implements RawSearchFilterNode { + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/SearchFilterNode.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/SearchFilterNode.java new file mode 100644 index 00000000..2aee81bc --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/SearchFilterNode.java @@ -0,0 +1,49 @@ +package com.decathlon.idp_core.domain.model.entity; + +import java.util.List; + +import com.decathlon.idp_core.domain.model.enums.LogicalConnector; +import com.decathlon.idp_core.domain.model.enums.SearchOperator; + +/// A node in the search filter tree for entity search queries. +/// +/// **Business semantics:** A filter tree is composed of two types of nodes: +/// - [Group] — a logical group that combines child nodes with a [LogicalConnector] +/// (AND / OR / IN). Children may themselves be groups or leaf criteria, allowing +/// arbitrarily deep nesting. +/// - [Criterion] — a leaf predicate: field value. +/// +/// The root of the tree must be either a [Group] or a single [Criterion]. +/// An empty [Group] matches all entities. +/// +/// **Supported fields for [Criterion]:** +/// - `template` — filters by the entity template identifier +/// - `identifier` — filters by the entity identifier +/// - `name` — filters by the entity name +/// - `property.{name}` — filters by a named property value +/// - `relation.{name}` — filters by target entity identifier of a named relation +/// - `relation.{name}.identifier` — explicit form of the above +/// - `relation.{name}.name` — filters by target entity name of a named relation +/// - `relations_as_target` — filters by the presence or absence of any reverse relation by name +/// - `relations_as_target.{name}.identifier` — filters by source entity identifier in a reverse relation +/// - `relations_as_target.{name}.name` — filters by source entity name in a reverse relation +public sealed interface SearchFilterNode { + + /// A logical group combining multiple child [SearchFilterNode]s with a connector. + /// + /// @param connector how child nodes are logically combined + /// @param nodes child nodes; an empty list matches all entities + record Group(LogicalConnector connector, List nodes) implements SearchFilterNode { + public Group { + nodes = nodes != null ? List.copyOf(nodes) : List.of(); + } + } + + /// A leaf predicate in the search filter tree. + /// + /// @param field the entity field to filter on (see [SearchFilterNode] for supported fields) + /// @param operation the comparison operator to apply + /// @param value the value to compare against + record Criterion(String field, SearchOperator operation, String value) implements SearchFilterNode { + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/enums/LogicalConnector.java b/src/main/java/com/decathlon/idp_core/domain/model/enums/LogicalConnector.java new file mode 100644 index 00000000..b167f3ac --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/enums/LogicalConnector.java @@ -0,0 +1,11 @@ +package com.decathlon.idp_core.domain.model.enums; + +/// Logical connectors for combining multiple filter nodes in a search query. +/// +/// **Business semantics:** +/// - [AND] — all child nodes must match +/// - [OR] — at least one child node must match +public enum LogicalConnector { + AND, + OR +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/enums/SearchOperator.java b/src/main/java/com/decathlon/idp_core/domain/model/enums/SearchOperator.java new file mode 100644 index 00000000..6b861c0f --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/enums/SearchOperator.java @@ -0,0 +1,27 @@ +package com.decathlon.idp_core.domain.model.enums; + +/// Operators supported by the entity search query DSL. +/// +/// **Business semantics:** +/// - [EQ] requires exact match (case-insensitive) +/// - [NEQ] requires the field to not exactly match (case-insensitive) +/// - [CONTAINS] requires the field to contain the value (case-insensitive substring) +/// - [NOT_CONTAINS] requires the field to not contain the value +/// - [STARTS_WITH] requires the field to start with the value (case-insensitive) +/// - [ENDS_WITH] requires the field to end with the value (case-insensitive) +/// - [GT] requires the field to be strictly greater than the value +/// - [GTE] requires the field to be greater than or equal to the value +/// - [LT] requires the field to be strictly less than the value +/// - [LTE] requires the field to be less than or equal to the value +public enum SearchOperator { + EQ, + NEQ, + CONTAINS, + NOT_CONTAINS, + STARTS_WITH, + ENDS_WITH, + GT, + GTE, + LT, + LTE +} diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java index 0718ea94..9856a4f7 100644 --- a/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java @@ -11,6 +11,7 @@ import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntityFilter; import com.decathlon.idp_core.domain.model.entity.EntitySummary; +import com.decathlon.idp_core.domain.model.entity.SearchFilterNode; /// Driven port defining the contract for [Entity] persistence operations. /// @@ -24,6 +25,7 @@ /// - `findByRelationIdIn()` enables reverse relationship navigation /// - `deletePropertiesByTemplateIdentifierAndPropertyName()` must remove all property instances matching the given names for entities of the specified template /// - `deleteRelationsByTemplateIdentifierAndRelationName()` must remove all relation instances matching the given names for entities of the specified template +/// - `search()` searches for entities across all templates using a nested filter tree and optional free-text query. /// /// **Transaction behavior:** Implementations should handle transaction boundaries /// appropriately for the underlying persistence technology. @@ -50,6 +52,7 @@ Page findByTemplateIdentifierWithFilter(String templateIdentifier, Entit void deletePropertiesByTemplateIdentifierAndPropertyName(String templateIdentifier, Collection propertyNames); - void deleteRelationsByTemplateIdentifierAndRelationName(String templateIdentifier, - Collection relationNames); + void deleteRelationsByTemplateIdentifierAndRelationName(String templateIdentifier, Collection relationNames); + + Page search(SearchFilterNode filter, String query, Pageable pageable); } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/EntityQueryParserService.java b/src/main/java/com/decathlon/idp_core/domain/service/EntityQueryParserService.java deleted file mode 100644 index 59ec1717..00000000 --- a/src/main/java/com/decathlon/idp_core/domain/service/EntityQueryParserService.java +++ /dev/null @@ -1,282 +0,0 @@ -package com.decathlon.idp_core.domain.service; - -import java.util.HashSet; -import java.util.List; -import java.util.OptionalInt; -import java.util.Set; -import java.util.stream.Stream; - -import org.springframework.stereotype.Service; - -import com.decathlon.idp_core.domain.constant.ValidationMessages; -import com.decathlon.idp_core.domain.exception.InvalidQueryDslException; -import com.decathlon.idp_core.domain.model.entity.EntityFilter; -import com.decathlon.idp_core.domain.model.entity.FilterCriterion; -import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; -import com.decathlon.idp_core.domain.model.enums.FilterKeyType; -import com.decathlon.idp_core.domain.model.enums.FilterOperator; -import com.decathlon.idp_core.domain.model.enums.PropertyType; - -/// Parses the entity filter query string `q` into an [EntityFilter]. -/// -/// **Query syntax:** -/// - Criteria are separated by `;` and combined with implicit AND logic. -/// - Each criterion has the form `` -/// - Supported operators: `=` (equals), `:` (contains), `<` (less than), `>` (greater than) -/// - Key types: -/// - `identifier` or `name` - attribute filters -/// - `property.` - property value filter -/// - `relation` - filter by relation name (e.g., `relation=api-link`) -/// - `relation.` - relation target entity identifier filter -/// - `relation..` - relation property filter; `` must be `identifier` or `name` -/// (e.g., `relation.api-link.identifier=microservice-1`) -/// - `relations_as_target..` - filter by a property of the source entity -/// in a reverse relation; `` must be `identifier` or `name` -/// (e.g. `relations_as_target.api-link.name:microservice`) -/// -/// **Security constraints:** -/// - Maximum MAX_CRITERIA_COUNT criteria per query (DoS prevention) -/// - Key names and values limited to MAX_KEY_VALUE_LENGTH characters -/// -/// **Example:** `name:API;property.language=JAVA;relation=api-link;relation.database=my-db;relation.api-link.identifier=microservice-1` -@Service -public class EntityQueryParserService { - - private static final String RELATION = "relation"; - private static final String RELATIONS_AS_TARGET = "relations_as_target"; - private static final String PROPERTY_PREFIX = "property."; - private static final String RELATION_PREFIX = "relation."; - private static final String RELATIONS_AS_TARGET_PREFIX = "relations_as_target."; - private static final Set VALID_ATTRIBUTE_NAMES = Set.of("identifier", "name"); - - private static final Set COMPARISON_INCOMPATIBLE_TYPES = Set.of( - FilterKeyType.ATTRIBUTE, FilterKeyType.RELATION_NAME, FilterKeyType.RELATION_ENTITY, - FilterKeyType.RELATION_PROPERTY, FilterKeyType.RELATIONS_AS_TARGET_NAME, - FilterKeyType.RELATIONS_AS_TARGET_PROPERTY); - - static final int MAX_CRITERIA_COUNT = 10; - static final int MAX_KEY_VALUE_LENGTH = 255; - - /// Parses a query string into an [EntityFilter]. - /// - /// @param query the raw `q` parameter value; may be null or blank - /// @return an [EntityFilter] with parsed criteria, or [EntityFilter#empty()] - /// when query is blank - /// @throws InvalidQueryDslException when the query string is malformed or - /// exceeds safety limits - public EntityFilter parse(String query) { - if (query == null || query.isBlank()) { - return EntityFilter.empty(); - } - - List criteria = Stream.of(query.split(";")).filter(token -> !token.isBlank()) - .map(token -> parseCriterion(token.trim())).toList(); - - if (criteria.size() > MAX_CRITERIA_COUNT) { - throw new InvalidQueryDslException( - ValidationMessages.FILTER_TOO_MANY_CRITERIA.formatted(MAX_CRITERIA_COUNT)); - } - - validateNoDuplicates(criteria); - - return new EntityFilter(criteria); - } - - private FilterCriterion parseCriterion(String token) { - int operatorIndex = findOperatorIndex(token) - .orElseThrow(() -> new InvalidQueryDslException(ValidationMessages.FILTER_INVALID_FORMAT)); - - var rawKey = token.substring(0, operatorIndex); - var operatorChar = token.charAt(operatorIndex); - var value = token.substring(operatorIndex + 1); - - validateKey(rawKey, token); - validateValue(value, token); - validateLength(rawKey, value, token); - - var operator = toOperator(operatorChar); - var criterion = buildCriterion(rawKey, operator, value, token); - validateOperatorCompatibility(criterion.keyType(), operator, rawKey); - return criterion; - } - - private OptionalInt findOperatorIndex(String token) { - for (int i = 0; i < token.length(); i++) { - char c = token.charAt(i); - if (c == '=' || c == ':' || c == '<' || c == '>') { - return OptionalInt.of(i); - } - } - return OptionalInt.empty(); - } - - private FilterOperator toOperator(char c) { - return switch (c) { - case '=' -> FilterOperator.EQUALS; - case ':' -> FilterOperator.CONTAINS; - case '<' -> FilterOperator.LESS_THAN; - case '>' -> FilterOperator.GREATER_THAN; - default -> throw new InvalidQueryDslException("Unknown operator character: " + c); - }; - } - - private FilterCriterion buildCriterion(String rawKey, FilterOperator operator, String value, - String token) { - // Direct attribute filters (relation=X means filter by relation name) - if (RELATION.equals(rawKey)) { - validateKeyName(value, token); - return new FilterCriterion(FilterKeyType.RELATION_NAME, "", operator, value); - } - - if (RELATIONS_AS_TARGET.equals(rawKey)) { - validateKeyName(value, token); - return new FilterCriterion(FilterKeyType.RELATIONS_AS_TARGET_NAME, "", operator, value); - } - - if (rawKey.startsWith(PROPERTY_PREFIX)) { - var keyName = rawKey.substring(PROPERTY_PREFIX.length()); - validateKeyName(keyName, token); - return new FilterCriterion(FilterKeyType.PROPERTY, keyName, operator, value); - } - - if (rawKey.startsWith(RELATIONS_AS_TARGET_PREFIX)) { - var relationPart = rawKey.substring(RELATIONS_AS_TARGET_PREFIX.length()); - validateKey(relationPart, token); - return buildRelationsAsTargetCriterion(relationPart, operator, value, token); - } - - if (rawKey.startsWith(RELATION_PREFIX)) { - var relationPart = rawKey.substring(RELATION_PREFIX.length()); - validateKey(relationPart, token); - return buildRelationCriterion(relationPart, operator, value, token); - } - - if (!VALID_ATTRIBUTE_NAMES.contains(rawKey)) { - throw new InvalidQueryDslException( - "Unknown attribute '%s' in filter criterion '%s'. Valid attributes: %s".formatted(rawKey, - token, VALID_ATTRIBUTE_NAMES)); - } - return new FilterCriterion(FilterKeyType.ATTRIBUTE, rawKey, operator, value); - } - - private FilterCriterion buildRelationsAsTargetCriterion(String relationPart, - FilterOperator operator, String value, String token) { - int dotIndex = relationPart.indexOf('.'); - if (dotIndex <= 0) { - throw new InvalidQueryDslException( - "Invalid filter criterion '%s': relations_as_target requires the form 'relations_as_target..'" - .formatted(token)); - } - - var relationName = relationPart.substring(0, dotIndex); - var propertyName = relationPart.substring(dotIndex + 1); - validateKeyName(relationName, token); - validatePropertyName(propertyName, RELATIONS_AS_TARGET, token); - var compositeKey = relationName + "." + propertyName; - return new FilterCriterion(FilterKeyType.RELATIONS_AS_TARGET_PROPERTY, compositeKey, operator, - value); - } - - private FilterCriterion buildRelationCriterion(String relationPart, FilterOperator operator, - String value, String token) { - int dotIndex = relationPart.indexOf('.'); - if (dotIndex > 0) { - var relationName = relationPart.substring(0, dotIndex); - var propertyName = relationPart.substring(dotIndex + 1); - validateKeyName(relationName, token); - validatePropertyName(propertyName, RELATION, token); - var compositeKey = relationName + "." + propertyName; - return new FilterCriterion(FilterKeyType.RELATION_PROPERTY, compositeKey, operator, value); - } - - // Default: relation entity filter - validateKeyName(relationPart, token); - return new FilterCriterion(FilterKeyType.RELATION_ENTITY, relationPart, operator, value); - } - - private void validateNoDuplicates(List criteria) { - Set seen = new HashSet<>(); - for (FilterCriterion criterion : criteria) { - String dedupeKey = criterion.keyType().name() + ":" + criterion.key(); - if (!seen.add(dedupeKey)) { - throw new InvalidQueryDslException(ValidationMessages.FILTER_DUPLICATE_CRITERION); - } - } - } - - private void validateOperatorCompatibility(FilterKeyType keyType, FilterOperator operator, - String rawKey) { - if (COMPARISON_INCOMPATIBLE_TYPES.contains(keyType) - && (operator == FilterOperator.LESS_THAN || operator == FilterOperator.GREATER_THAN)) { - var opSymbol = operator == FilterOperator.LESS_THAN ? "<" : ">"; - throw new InvalidQueryDslException( - ValidationMessages.FILTER_TYPE_MISMATCH.formatted(opSymbol, rawKey)); - } - } - - /// Validates that all PROPERTY criteria using `<` or `>` operators - /// correspond to a NUMBER-typed property in the given template. - /// - /// This is a semantic check that requires the template to be available (i.e., - /// it - /// cannot be performed in [#parse] which has no template context). - /// - /// @param filter the parsed query filter - /// @param template the entity template providing property type information - /// @throws InvalidQueryDslException when a comparison operator is used on a - /// non-NUMBER property - public void validateFilterPropertyTypes(EntityFilter filter, EntityTemplate template) { - filter.criteria().stream().filter(c -> c.keyType() == FilterKeyType.PROPERTY) - .filter(c -> c.operator() == FilterOperator.LESS_THAN - || c.operator() == FilterOperator.GREATER_THAN) - .forEach(c -> { - var propertyDef = template.propertiesDefinitions().stream() - .filter(p -> p.name().equals(c.key())).findFirst(); - if (propertyDef.isEmpty() || propertyDef.get().type() != PropertyType.NUMBER) { - var opSymbol = c.operator() == FilterOperator.LESS_THAN ? "<" : ">"; - throw new InvalidQueryDslException( - ValidationMessages.FILTER_PROPERTY_TYPE_NOT_NUMERIC.formatted(opSymbol, c.key())); - } - }); - } - - private void validateKey(String key, String token) { - if (key.isBlank()) { - throw new InvalidQueryDslException( - "Invalid filter criterion '%s': key must not be blank".formatted(token)); - } - } - - private void validateKeyName(String keyName, String token) { - if (keyName.isBlank()) { - throw new InvalidQueryDslException( - "Invalid filter criterion '%s': key name must not be blank".formatted(token)); - } - } - - private void validateValue(String value, String token) { - if (value.isBlank()) { - throw new InvalidQueryDslException( - "Invalid filter criterion '%s': value must not be blank".formatted(token)); - } - } - - private void validatePropertyName(String propertyName, String contextType, String token) { - if (!VALID_ATTRIBUTE_NAMES.contains(propertyName)) { - throw new InvalidQueryDslException( - "Invalid property '%s' in criterion '%s': only 'identifier' and 'name' are supported for %s" - .formatted(propertyName, token, contextType)); - } - } - - private void validateLength(String rawKey, String value, String token) { - if (rawKey.length() > MAX_KEY_VALUE_LENGTH) { - throw new InvalidQueryDslException( - ValidationMessages.FILTER_KEY_TOO_LONG.formatted(MAX_KEY_VALUE_LENGTH, token)); - } - if (value.length() > MAX_KEY_VALUE_LENGTH) { - throw new InvalidQueryDslException( - ValidationMessages.FILTER_VALUE_TOO_LONG.formatted(MAX_KEY_VALUE_LENGTH, token)); - } - } -} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java index efb1de25..92c6c07f 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java @@ -6,20 +6,27 @@ import jakarta.validation.Valid; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; +import com.decathlon.idp_core.domain.constant.SearchConstraints; +import com.decathlon.idp_core.domain.constant.ValidationMessages; import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.search.InvalidSearchQueryException; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntityFilter; import com.decathlon.idp_core.domain.model.entity.EntitySummary; +import com.decathlon.idp_core.domain.model.entity.SearchFilterNode; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; -import com.decathlon.idp_core.domain.service.EntityQueryParserService; +import com.decathlon.idp_core.domain.service.filter.EntityFilterDslParser; +import com.decathlon.idp_core.domain.service.search.SearchFilterValidationService; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; @@ -41,11 +48,12 @@ @Validated @RequiredArgsConstructor public class EntityService { - private final EntityRepositoryPort entityRepository; - private final EntityValidationService entityValidationService; - private final EntityTemplateValidationService entityTemplateValidationService; - private final EntityTemplateService entityTemplateService; - private final EntityQueryParserService entityQueryParserService; + private final EntityRepositoryPort entityRepository; + private final EntityValidationService entityValidationService; + private final EntityTemplateValidationService entityTemplateValidationService; + private final EntityTemplateService entityTemplateService; + private final EntityFilterDslParser entityQueryParserService; + private final SearchFilterValidationService searchFilterValidationService; /// Retrieves entities filtered by template with optional query filter. /// @@ -159,4 +167,66 @@ public Entity updateEntity(String templateIdentifier, String entityIdentifier, return entityRepository.save(entityToSave); } + /// Searches for entities across all templates using a nested filter tree and optional free-text query. + /// + /// **Contract:** Executes a global entity search using the provided filter tree and optional text query. + /// Not scoped to a single template; include a template criterion in the filter + /// to scope the result to a specific template. Validates and builds pagination internally. + /// + /// @param filter root node of the search filter tree; an empty group returns all entities + /// @param query optional free-text string searched across identifier, name, templateIdentifier, + /// and all property values; null means no text restriction + /// @param page zero-based page index; must be 0 or greater + /// @param size number of items per page; must be between 1 and [SearchConstraints#MAX_PAGE_SIZE] + /// @param sort optional sort expression in the form `field` or `field:asc|desc; + /// null or blank means default ordering + /// @return paginated entities matching the filter and query + /// @throws InvalidSearchQueryException when page, size, or sort parameters are invalid + @Transactional + public Page searchEntities(SearchFilterNode filter, String query, int page, int size, String sort) { + searchFilterValidationService.validate(filter, query); + Pageable pageable = buildPageable(page, size, sort); + return entityRepository.search(filter, query, pageable); + } + + private Pageable buildPageable(int page, int size, String sort) { + if (page < 0) { + throw new InvalidSearchQueryException(ValidationMessages.SEARCH_PAGE_INVALID); + } + if (size <= 0) { + throw new InvalidSearchQueryException(ValidationMessages.SEARCH_SIZE_INVALID); + } + if (size > SearchConstraints.MAX_PAGE_SIZE) { + throw new InvalidSearchQueryException( + ValidationMessages.SEARCH_PAGE_SIZE_TOO_LARGE.formatted(SearchConstraints.MAX_PAGE_SIZE)); + } + if (sort == null || sort.isBlank()) { + return PageRequest.of(page, size); + } + return PageRequest.of(page, size, parseSortExpression(sort)); + } + + private Sort parseSortExpression(String sortExpression) { + String[] parts = sortExpression.split(":"); + if (parts.length > 2) { + throw new InvalidSearchQueryException( + ValidationMessages.SEARCH_INVALID_SORT_FORMAT.formatted(sortExpression)); + } + String property = parts[0].trim(); + if (!SearchConstraints.ALLOWED_SORT_FIELDS.contains(property)) { + throw new InvalidSearchQueryException( + ValidationMessages.SEARCH_INVALID_SORT_FIELD.formatted(property)); + } + if (parts.length == 1) { + return Sort.by(Sort.Direction.ASC, property); + } + String direction = parts[1].trim().toLowerCase(); + return switch (direction) { + case "asc" -> Sort.by(Sort.Direction.ASC, property); + case "desc" -> Sort.by(Sort.Direction.DESC, property); + default -> throw new InvalidSearchQueryException( + ValidationMessages.SEARCH_INVALID_SORT_FORMAT.formatted(sortExpression)); + }; + } + } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/filter/EntityFilterDslParser.java b/src/main/java/com/decathlon/idp_core/domain/service/filter/EntityFilterDslParser.java new file mode 100644 index 00000000..dd3a618a --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/filter/EntityFilterDslParser.java @@ -0,0 +1,276 @@ +package com.decathlon.idp_core.domain.service.filter; + +import java.util.HashSet; +import java.util.List; +import java.util.OptionalInt; +import java.util.Set; +import java.util.stream.Stream; + +import com.decathlon.idp_core.domain.exception.filter.InvalidFilterDslException; +import org.springframework.stereotype.Service; + +import com.decathlon.idp_core.domain.constant.FilterConstraints; +import com.decathlon.idp_core.domain.constant.ValidationMessages; +import com.decathlon.idp_core.domain.model.entity.EntityFilter; +import com.decathlon.idp_core.domain.model.entity.FilterCriterion; +import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; +import com.decathlon.idp_core.domain.model.enums.FilterKeyType; +import com.decathlon.idp_core.domain.model.enums.FilterOperator; +import com.decathlon.idp_core.domain.model.enums.PropertyType; + +/// Parses the entity filter query string `q` into an [EntityFilter]. +/// +/// **Query syntax:** +/// - Criteria are separated by `;` and combined with implicit AND logic. +/// - Each criterion has the form `` +/// - Supported operators: `=` (equals), `:` (contains), `<` (less than), `>` (greater than) +/// - Key types: +/// - `identifier` or `name` - attribute filters +/// - `property.` - property value filter +/// - `relation` - filter by relation name (e.g., `relation=api-link`) +/// - `relation.` - relation target entity identifier filter +/// - `relation..` - relation property filter; `` must be `identifier` or `name` +/// (e.g., `relation.api-link.identifier=microservice-1`) +/// - `relations_as_target..` - filter by a property of the source entity +/// in a reverse relation; `` must be `identifier` or `name` +/// (e.g. `relations_as_target.api-link.name:microservice`) +/// +/// **Security constraints:** +/// - Maximum [FilterConstraints#MAX_CRITERIA_COUNT] criteria per query (DoS prevention) +/// - Key names and values limited to [FilterConstraints#MAX_KEY_VALUE_LENGTH] characters +/// +/// **Example:** `name:API;property.language=JAVA;relation=api-link;relation.database=my-db;relation.api-link.identifier=microservice-1` +@Service +public class EntityFilterDslParser { + + private static final String RELATION = "relation"; + private static final String RELATIONS_AS_TARGET = "relations_as_target"; + private static final String PROPERTY_PREFIX = "property."; + private static final String RELATION_PREFIX = "relation."; + private static final String RELATIONS_AS_TARGET_PREFIX = "relations_as_target."; + private static final Set VALID_ATTRIBUTE_NAMES = Set.of("identifier", "name"); + + private static final Set COMPARISON_INCOMPATIBLE_TYPES = Set.of( + FilterKeyType.ATTRIBUTE, + FilterKeyType.RELATION_NAME, + FilterKeyType.RELATION_ENTITY, + FilterKeyType.RELATION_PROPERTY, + FilterKeyType.RELATIONS_AS_TARGET_NAME, + FilterKeyType.RELATIONS_AS_TARGET_PROPERTY); + + /// Parses a query string into an [EntityFilter]. + /// + /// @param query the raw `q` parameter value; may be null or blank + /// @return an [EntityFilter] with parsed criteria, or [EntityFilter#empty()] when query is blank + /// @throws InvalidFilterDslException when the query string is malformed or exceeds safety limits + public EntityFilter parse(String query) { + if (query == null || query.isBlank()) { + return EntityFilter.empty(); + } + + List criteria = Stream.of(query.split(";")) + .filter(token -> !token.isBlank()) + .map(token -> parseCriterion(token.trim())) + .toList(); + + if (criteria.size() > FilterConstraints.MAX_CRITERIA_COUNT) { + throw new InvalidFilterDslException( + ValidationMessages.FILTER_TOO_MANY_CRITERIA.formatted(FilterConstraints.MAX_CRITERIA_COUNT)); + } + + validateNoDuplicates(criteria); + + return new EntityFilter(criteria); + } + + private FilterCriterion parseCriterion(String token) { + int operatorIndex = findOperatorIndex(token) + .orElseThrow(() -> new InvalidFilterDslException(ValidationMessages.FILTER_INVALID_FORMAT)); + + var rawKey = token.substring(0, operatorIndex); + var operatorChar = token.charAt(operatorIndex); + var value = token.substring(operatorIndex + 1); + + validateKey(rawKey, token); + validateValue(value, token); + validateLength(rawKey, value, token); + + var operator = toOperator(operatorChar); + var criterion = buildCriterion(rawKey, operator, value, token); + validateOperatorCompatibility(criterion.keyType(), operator, rawKey); + return criterion; + } + + private OptionalInt findOperatorIndex(String token) { + for (int i = 0; i < token.length(); i++) { + char c = token.charAt(i); + if (c == '=' || c == ':' || c == '<' || c == '>') { + return OptionalInt.of(i); + } + } + return OptionalInt.empty(); + } + + private FilterOperator toOperator(char c) { + return switch (c) { + case '=' -> FilterOperator.EQUALS; + case ':' -> FilterOperator.CONTAINS; + case '<' -> FilterOperator.LESS_THAN; + case '>' -> FilterOperator.GREATER_THAN; + default -> throw new InvalidFilterDslException("Unknown operator character: " + c); + }; + } + + private FilterCriterion buildCriterion(String rawKey, FilterOperator operator, String value, String token) { + // Direct attribute filters (relation=X means filter by relation name) + if (RELATION.equals(rawKey)) { + validateKeyName(value, token); + return new FilterCriterion(FilterKeyType.RELATION_NAME, "", operator, value); + } + + if (RELATIONS_AS_TARGET.equals(rawKey)) { + validateKeyName(value, token); + return new FilterCriterion(FilterKeyType.RELATIONS_AS_TARGET_NAME, "", operator, value); + } + + if (rawKey.startsWith(PROPERTY_PREFIX)) { + var keyName = rawKey.substring(PROPERTY_PREFIX.length()); + validateKeyName(keyName, token); + return new FilterCriterion(FilterKeyType.PROPERTY, keyName, operator, value); + } + + if (rawKey.startsWith(RELATIONS_AS_TARGET_PREFIX)) { + var relationPart = rawKey.substring(RELATIONS_AS_TARGET_PREFIX.length()); + validateKey(relationPart, token); + return buildRelationsAsTargetCriterion(relationPart, operator, value, token); + } + + if (rawKey.startsWith(RELATION_PREFIX)) { + var relationPart = rawKey.substring(RELATION_PREFIX.length()); + validateKey(relationPart, token); + return buildRelationCriterion(relationPart, operator, value, token); + } + + if (!VALID_ATTRIBUTE_NAMES.contains(rawKey)) { + throw new InvalidFilterDslException( + "Unknown attribute '%s' in filter criterion '%s'. Valid attributes: %s" + .formatted(rawKey, token, VALID_ATTRIBUTE_NAMES)); + } + return new FilterCriterion(FilterKeyType.ATTRIBUTE, rawKey, operator, value); + } + + private FilterCriterion buildRelationsAsTargetCriterion(String relationPart, FilterOperator operator, String value, String token) { + int dotIndex = relationPart.indexOf('.'); + if (dotIndex <= 0) { + throw new InvalidFilterDslException( + "Invalid filter criterion '%s': relations_as_target requires the form 'relations_as_target..'" + .formatted(token)); + } + + var relationName = relationPart.substring(0, dotIndex); + var propertyName = relationPart.substring(dotIndex + 1); + validateKeyName(relationName, token); + validatePropertyName(propertyName, RELATIONS_AS_TARGET, token); + var compositeKey = relationName + "." + propertyName; + return new FilterCriterion(FilterKeyType.RELATIONS_AS_TARGET_PROPERTY, compositeKey, operator, value); + } + + private FilterCriterion buildRelationCriterion(String relationPart, FilterOperator operator, String value, String token) { + int dotIndex = relationPart.indexOf('.'); + if (dotIndex > 0) { + var relationName = relationPart.substring(0, dotIndex); + var propertyName = relationPart.substring(dotIndex + 1); + validateKeyName(relationName, token); + validatePropertyName(propertyName, RELATION, token); + var compositeKey = relationName + "." + propertyName; + return new FilterCriterion(FilterKeyType.RELATION_PROPERTY, compositeKey, operator, value); + } + + // Default: relation entity filter + validateKeyName(relationPart, token); + return new FilterCriterion(FilterKeyType.RELATION_ENTITY, relationPart, operator, value); + } + + private void validateNoDuplicates(List criteria) { + Set seen = new HashSet<>(); + for (FilterCriterion criterion : criteria) { + String dedupeKey = criterion.keyType().name() + ":" + criterion.key(); + if (!seen.add(dedupeKey)) { + throw new InvalidFilterDslException(ValidationMessages.FILTER_DUPLICATE_CRITERION); + } + } + } + + private void validateOperatorCompatibility(FilterKeyType keyType, FilterOperator operator, String rawKey) { + if (COMPARISON_INCOMPATIBLE_TYPES.contains(keyType) && + (operator == FilterOperator.LESS_THAN || operator == FilterOperator.GREATER_THAN)) { + var opSymbol = operator == FilterOperator.LESS_THAN ? "<" : ">"; + throw new InvalidFilterDslException(ValidationMessages.FILTER_TYPE_MISMATCH.formatted(opSymbol, rawKey)); + } + } + + /// Validates that all PROPERTY criteria using `<` or `>` operators + /// correspond to a NUMBER-typed property in the given template. + /// + /// This is a semantic check that requires the template to be available (i.e., it + /// cannot be performed in [#parse] which has no template context). + /// + /// @param filter the parsed query filter + /// @param template the entity template providing property type information + /// @throws InvalidFilterDslException when a comparison operator is used on a non-NUMBER property + public void validateFilterPropertyTypes(EntityFilter filter, EntityTemplate template) { + filter.criteria().stream() + .filter(c -> c.keyType() == FilterKeyType.PROPERTY) + .filter(c -> c.operator() == FilterOperator.LESS_THAN || c.operator() == FilterOperator.GREATER_THAN) + .forEach(c -> { + var propertyDef = template.propertiesDefinitions().stream() + .filter(p -> p.name().equals(c.key())) + .findFirst(); + if (propertyDef.isEmpty() || propertyDef.get().type() != PropertyType.NUMBER) { + var opSymbol = c.operator() == FilterOperator.LESS_THAN ? "<" : ">"; + throw new InvalidFilterDslException( + ValidationMessages.FILTER_PROPERTY_TYPE_NOT_NUMERIC.formatted(opSymbol, c.key())); + } + }); + } + + private void validateKey(String key, String token) { + if (key.isBlank()) { + throw new InvalidFilterDslException( + "Invalid filter criterion '%s': key must not be blank".formatted(token)); + } + } + + private void validateKeyName(String keyName, String token) { + if (keyName.isBlank()) { + throw new InvalidFilterDslException( + "Invalid filter criterion '%s': key name must not be blank".formatted(token)); + } + } + + private void validateValue(String value, String token) { + if (value.isBlank()) { + throw new InvalidFilterDslException( + "Invalid filter criterion '%s': value must not be blank".formatted(token)); + } + } + + private void validatePropertyName(String propertyName, String contextType, String token) { + if (!VALID_ATTRIBUTE_NAMES.contains(propertyName)) { + throw new InvalidFilterDslException( + "Invalid property '%s' in criterion '%s': only 'identifier' and 'name' are supported for %s" + .formatted(propertyName, token, contextType)); + } + } + + private void validateLength(String rawKey, String value, String token) { + if (rawKey.length() > FilterConstraints.MAX_KEY_VALUE_LENGTH) { + throw new InvalidFilterDslException( + ValidationMessages.FILTER_KEY_TOO_LONG.formatted(FilterConstraints.MAX_KEY_VALUE_LENGTH, token)); + } + if (value.length() > FilterConstraints.MAX_KEY_VALUE_LENGTH) { + throw new InvalidFilterDslException( + ValidationMessages.FILTER_VALUE_TOO_LONG.formatted(FilterConstraints.MAX_KEY_VALUE_LENGTH, token)); + } + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/search/SearchFilterParser.java b/src/main/java/com/decathlon/idp_core/domain/service/search/SearchFilterParser.java new file mode 100644 index 00000000..f061e30b --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/search/SearchFilterParser.java @@ -0,0 +1,111 @@ +package com.decathlon.idp_core.domain.service.search; + +import java.util.List; + +import org.springframework.stereotype.Service; + +import com.decathlon.idp_core.domain.constant.SearchConstraints; +import com.decathlon.idp_core.domain.constant.ValidationMessages; +import com.decathlon.idp_core.domain.exception.search.InvalidSearchQueryException; +import com.decathlon.idp_core.domain.model.entity.RawSearchFilterNode; +import com.decathlon.idp_core.domain.model.entity.SearchFilterNode; +import com.decathlon.idp_core.domain.model.enums.LogicalConnector; +import com.decathlon.idp_core.domain.model.enums.SearchOperator; + +/// Domain service that converts a [RawSearchFilterNode] tree into a validated [SearchFilterNode] tree. +/// +/// **Responsibility:** Parses the raw string representation produced by the infrastructure mapper and +/// enforces all structural and safety business rules: +///
    +///
  • Maximum nesting depth ([SearchConstraints#MAX_NESTING_DEPTH])
  • +///
  • Maximum total criteria count ([SearchConstraints#MAX_TOTAL_CRITERIA])
  • +///
  • Required fields on criterion nodes (field, operation, value)
  • +///
  • Required fields on group nodes (connector, non-empty criteria)
  • +///
  • Valid enum values for connectors and operators
  • +///
+/// +/// Semantic validation (field name grammar, numeric operator constraints, query length) is +/// handled separately by [SearchFilterValidationService]. +/// +/// Throws [InvalidSearchQueryException] for all structural and safety violations. +@Service +public class SearchFilterParser { + + /// Parses and validates a [RawSearchFilterNode] tree into a typed [SearchFilterNode] tree. + /// + /// @param raw the root of the raw filter tree; null is treated as "no filter" and returns an empty AND group + /// @return the validated, type-safe domain filter tree + /// @throws InvalidSearchQueryException when the raw tree contains structural errors or exceeds safety limits + public SearchFilterNode parse(RawSearchFilterNode raw) { + if (raw == null) { + return new SearchFilterNode.Group(LogicalConnector.AND, List.of()); + } + return convertNode(raw, 0, new int[]{0}); + } + + private SearchFilterNode convertNode(RawSearchFilterNode raw, int depth, int[] criteriaCounter) { + if (depth > SearchConstraints.MAX_NESTING_DEPTH) { + throw new InvalidSearchQueryException( + ValidationMessages.SEARCH_NESTING_TOO_DEEP.formatted(SearchConstraints.MAX_NESTING_DEPTH)); + } + return switch (raw) { + case RawSearchFilterNode.Group group -> convertGroup(group, depth, criteriaCounter); + case RawSearchFilterNode.Criterion criterion -> convertCriterion(criterion, criteriaCounter); + }; + } + + private SearchFilterNode.Group convertGroup(RawSearchFilterNode.Group raw, int depth, int[] criteriaCounter) { + if (raw.connector() == null || raw.connector().isBlank()) { + throw new InvalidSearchQueryException(ValidationMessages.SEARCH_GROUP_MISSING_CONNECTOR); + } + if (raw.nodes() == null || raw.nodes().isEmpty()) { + throw new InvalidSearchQueryException(ValidationMessages.SEARCH_GROUP_MISSING_CRITERIA); + } + + var connector = parseConnector(raw.connector()); + List children = raw.nodes().stream() + .map(child -> convertNode(child, depth + 1, criteriaCounter)) + .toList(); + + return new SearchFilterNode.Group(connector, children); + } + + private SearchFilterNode.Criterion convertCriterion(RawSearchFilterNode.Criterion raw, int[] criteriaCounter) { + if (raw.field() == null || raw.field().isBlank()) { + throw new InvalidSearchQueryException(ValidationMessages.SEARCH_CRITERION_MISSING_FIELD); + } + if (raw.operation() == null || raw.operation().isBlank()) { + throw new InvalidSearchQueryException(ValidationMessages.SEARCH_CRITERION_MISSING_OPERATION); + } + if (raw.value() == null || raw.value().isBlank()) { + throw new InvalidSearchQueryException(ValidationMessages.SEARCH_CRITERION_MISSING_VALUE); + } + + criteriaCounter[0]++; + if (criteriaCounter[0] > SearchConstraints.MAX_TOTAL_CRITERIA) { + throw new InvalidSearchQueryException( + ValidationMessages.SEARCH_TOO_MANY_CRITERIA.formatted(SearchConstraints.MAX_TOTAL_CRITERIA)); + } + + var operator = parseOperator(raw.operation()); + return new SearchFilterNode.Criterion(raw.field(), operator, raw.value()); + } + + private LogicalConnector parseConnector(String raw) { + try { + return LogicalConnector.valueOf(raw.toUpperCase()); + } catch (IllegalArgumentException _) { + throw new InvalidSearchQueryException( + ValidationMessages.SEARCH_INVALID_CONNECTOR.formatted(raw)); + } + } + + private SearchOperator parseOperator(String raw) { + try { + return SearchOperator.valueOf(raw.toUpperCase()); + } catch (IllegalArgumentException _) { + throw new InvalidSearchQueryException( + ValidationMessages.SEARCH_INVALID_OPERATOR.formatted(raw)); + } + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/search/SearchFilterValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/search/SearchFilterValidationService.java new file mode 100644 index 00000000..d08bf98f --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/search/SearchFilterValidationService.java @@ -0,0 +1,166 @@ +package com.decathlon.idp_core.domain.service.search; + +import java.math.BigDecimal; +import java.util.HashSet; +import java.util.Set; +import java.util.stream.Stream; + +import org.springframework.stereotype.Service; + +import com.decathlon.idp_core.domain.constant.SearchConstraints; +import com.decathlon.idp_core.domain.constant.ValidationMessages; +import com.decathlon.idp_core.domain.exception.search.InvalidSearchQueryException; +import com.decathlon.idp_core.domain.model.entity.SearchFilterNode; +import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; +import com.decathlon.idp_core.domain.model.enums.PropertyType; +import com.decathlon.idp_core.domain.model.enums.SearchOperator; +import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; + +import lombok.AllArgsConstructor; + +/// Validates a [SearchFilterNode] tree and its accompanying free-text query string. +/// +/// **Responsibilities (in order):** +/// 1. Query-string length — rejects query strings longer than [SearchConstraints#MAX_QUERY_LENGTH]. +/// 2. Field name grammar — rejects unknown or malformed field names in each criterion. +/// 3. Numeric operator constraints — numeric operators (GT, GTE, LT, LTE) must target a +/// `property.{name}` field and the value must be parseable as a [java.math.BigDecimal]. +/// 4. Template-scoped property-type check — when the filter contains a `template EQ ` +/// criterion, verifies that any numeric-operator property field is defined as +/// [PropertyType#NUMBER] in that template. +/// +/// **Error handling:** Throws [InvalidSearchQueryException] (HTTP 400) for all validation failures. +@Service +@AllArgsConstructor +public class SearchFilterValidationService { + + private static final Set NUMERIC_OPERATORS = + Set.of(SearchOperator.GT, SearchOperator.GTE, SearchOperator.LT, SearchOperator.LTE); + private static final Set SIMPLE_FIELDS = + Set.of("template", "identifier", "name", "relation", "relations_as_target"); + private static final String PROPERTY_PREFIX = "property."; + private static final String RELATION_PREFIX = "relation."; + private static final String RELATIONS_AS_TARGET_PREFIX = "relations_as_target."; + private static final String TEMPLATE_FIELD = "template"; + + private final EntityTemplateRepositoryPort entityTemplateRepository; + + /// Validates the filter tree and query string for semantic correctness. + /// + /// @param filter the root of the search filter tree to validate + /// @param query optional free-text query string; may be null (no-op) + /// @throws InvalidSearchQueryException when any validation rule is violated + public void validate(SearchFilterNode filter, String query) { + validateQuery(query); + collectCriteria(filter).forEach(this::validateCriterion); + validateTemplatePropertyTypes(filter); + } + + private void validateQuery(String query) { + if (query != null && query.length() > SearchConstraints.MAX_QUERY_LENGTH) { + throw new InvalidSearchQueryException( + ValidationMessages.SEARCH_QUERY_TOO_LONG.formatted(SearchConstraints.MAX_QUERY_LENGTH)); + } + } + + private void validateCriterion(SearchFilterNode.Criterion criterion) { + validateField(criterion.field()); + validateNumericConstraints(criterion.operation(), criterion.field(), criterion.value()); + } + + private void validateField(String field) { + if (SIMPLE_FIELDS.contains(field)) { + return; + } + if (field.startsWith(PROPERTY_PREFIX) && field.length() > PROPERTY_PREFIX.length()) { + return; + } + if (field.startsWith(RELATIONS_AS_TARGET_PREFIX)) { + validateRelationsAsTargetField(field); + return; + } + if (field.startsWith(RELATION_PREFIX) && field.length() > RELATION_PREFIX.length()) { + return; + } + throw new InvalidSearchQueryException(ValidationMessages.SEARCH_INVALID_FIELD.formatted(field)); + } + + private void validateRelationsAsTargetField(String field) { + String rest = field.substring(RELATIONS_AS_TARGET_PREFIX.length()); + int dot = rest.indexOf('.'); + if (dot <= 0 || dot == rest.length() - 1) { + throw new InvalidSearchQueryException(ValidationMessages.SEARCH_INVALID_FIELD.formatted(field)); + } + } + + private void validateNumericConstraints(SearchOperator operator, String field, String value) { + if (!NUMERIC_OPERATORS.contains(operator)) { + return; + } + if (!field.startsWith(PROPERTY_PREFIX) || field.length() <= PROPERTY_PREFIX.length()) { + throw new InvalidSearchQueryException( + ValidationMessages.SEARCH_NUMERIC_OPERATOR_REQUIRES_PROPERTY.formatted(operator)); + } + try { + new BigDecimal(value); + } catch (NumberFormatException _) { + throw new InvalidSearchQueryException( + ValidationMessages.SEARCH_NUMERIC_OPERATOR_INVALID_VALUE.formatted(value, operator)); + } + } + + private void validateTemplatePropertyTypes(SearchFilterNode filter) { + Set numericPropertyNames = collectNumericPropertyCriteria(filter); + if (numericPropertyNames.isEmpty()) { + return; + } + + Set templateIdentifiers = collectTemplateIdentifiers(filter); + if (templateIdentifiers.isEmpty()) { + return; + } + + for (String templateIdentifier : templateIdentifiers) { + entityTemplateRepository.findByIdentifier(templateIdentifier) + .ifPresent(template -> checkPropertyTypes(numericPropertyNames, template)); + } + } + + private void checkPropertyTypes(Set numericPropertyNames, EntityTemplate template) { + template.propertiesDefinitions().stream() + .filter(pd -> numericPropertyNames.contains(pd.name())) + .filter(pd -> pd.type() != PropertyType.NUMBER) + .findFirst() + .ifPresent(pd -> { + throw new InvalidSearchQueryException( + ValidationMessages.SEARCH_NUMERIC_OPERATOR_PROPERTY_TYPE_MISMATCH + .formatted(pd.name(), template.identifier(), pd.type())); + }); + } + + private Set collectNumericPropertyCriteria(SearchFilterNode filter) { + Set names = new HashSet<>(); + collectCriteria(filter) + .filter(c -> NUMERIC_OPERATORS.contains(c.operation())) + .filter(c -> c.field().startsWith(PROPERTY_PREFIX)) + .map(c -> c.field().substring(PROPERTY_PREFIX.length())) + .forEach(names::add); + return names; + } + + private Set collectTemplateIdentifiers(SearchFilterNode filter) { + Set identifiers = new HashSet<>(); + collectCriteria(filter) + .filter(c -> TEMPLATE_FIELD.equals(c.field()) && c.operation() == SearchOperator.EQ) + .map(SearchFilterNode.Criterion::value) + .forEach(identifiers::add); + return identifiers; + } + + private Stream collectCriteria(SearchFilterNode node) { + return switch (node) { + case SearchFilterNode.Criterion c -> Stream.of(c); + case SearchFilterNode.Group g -> g.nodes().stream().flatMap(this::collectCriteria); + }; + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java index 4404f8ae..94cc65c0 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java @@ -147,12 +147,22 @@ public class SwaggerDescription { public static final String FIELD_RELATION_REQUIRED = "Whether this relation is required"; public static final String FIELD_RELATION_TO_MANY = "Whether this relation can have multiple targets"; - // --- Pagination and sorting parameter descriptions --- - public static final String PARAM_PAGE_DESCRIPTION = "Page number for pagination. Defaults to 0."; - public static final String PARAM_SIZE_DESCRIPTION = "Number of items per page. Defaults to 20."; - public static final String PARAM_SORT_DESCRIPTION = "Sorting criteria in the format: property(,asc|desc). Defaults to identifier,asc."; - public static final String PARAM_QUERY_DESCRIPTION = """ - Optional filter query using a simple expression language. See more details in the API documentation. Example: `name:idp` for entities with names containing 'idp'. - """; - public static final String RESPONSE_INVALID_QUERY = "Invalid filter query syntax"; + // --- Pagination and sorting parameter descriptions --- + public static final String PARAM_PAGE_DESCRIPTION = "Page number for pagination. Defaults to 0."; + public static final String PARAM_SIZE_DESCRIPTION = "Number of items per page. Defaults to 20."; + public static final String PARAM_SORT_DESCRIPTION = "Sorting criteria in the format: property(,asc|desc). Defaults to identifier,asc."; + public static final String PARAM_QUERY_DESCRIPTION = """ + Optional filter query using a simple expression language. See more details in the API documentation. Example: `name:idp` for entities with names containing 'idp'. + """; + public static final String RESPONSE_INVALID_QUERY = "Invalid filter query syntax"; + + /// Search API endpoint constants + public static final String ENDPOINT_POST_SEARCH_SUMMARY = "Search entities"; + public static final String ENDPOINT_POST_SEARCH_DESCRIPTION = """ + Search for entities across all templates using nested filter queries. \ + Supports complex logical compositions (AND / OR / IN) of filter criteria on \ + template, identifier, name, properties, relations, and reverse relations."""; + public static final String RESPONSE_SEARCH_SUCCESS = "Entities retrieved successfully"; + public static final String RESPONSE_INVALID_SEARCH_QUERY = "Invalid search filter"; + } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java index f46ee69a..178f2821 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java @@ -11,6 +11,8 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_POST_ENTITY_SUMMARY; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_PUT_ENTITY_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_PUT_ENTITY_SUMMARY; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_POST_SEARCH_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_POST_SEARCH_SUMMARY; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FORBIDDEN_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.INTERNAL_SERVER_ERROR_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.NOT_FOUND_CODE; @@ -29,12 +31,19 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_INVALID_ENTITY_DATA; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_INVALID_PAGINATION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_INVALID_QUERY; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_INVALID_SEARCH_QUERY; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_SEARCH_SUCCESS; +import static org.springframework.http.HttpStatus.CREATED; +import static org.springframework.http.HttpStatus.OK; + +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_UNAUTHORIZED; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_UNEXPECTED_SERVER_ERROR; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.UNAUTHORIZED_CODE; -import static org.springframework.http.HttpStatus.CREATED; -import static org.springframework.http.HttpStatus.OK; + +import lombok.RequiredArgsConstructor; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; @@ -55,25 +64,27 @@ import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntityFilter; -import com.decathlon.idp_core.domain.service.EntityQueryParserService; +import com.decathlon.idp_core.domain.model.entity.RawSearchFilterNode; +import com.decathlon.idp_core.domain.model.entity.SearchFilterNode; +import com.decathlon.idp_core.domain.service.filter.EntityFilterDslParser; import com.decathlon.idp_core.domain.service.entity.EntityService; +import com.decathlon.idp_core.domain.service.search.SearchFilterParser; import com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerConfiguration.EntityPageResponse; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityCreateDtoIn; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityUpdateDtoIn; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntitySearchRequestDtoIn; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityDtoOut; import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler; import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse; import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.EntityDtoInMapper; import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.EntityDtoOutMapper; +import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.SearchFilterMapper; import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; /// REST API adapter providing entity management endpoints. /// @@ -90,47 +101,46 @@ @RequiredArgsConstructor public class EntityController { - private final EntityService entityService; - private final EntityDtoOutMapper entityDtoOutMapper; - private final EntityDtoInMapper entityDtoInMapper; - private final EntityQueryParserService entityQueryParserService; + private final EntityService entityService; + private final EntityDtoOutMapper entityDtoOutMapper; + private final EntityDtoInMapper entityDtoInMapper; + private final EntityFilterDslParser entityFilterDslParser; + private final SearchFilterMapper searchFilterMapper; + private final SearchFilterParser searchFilterParser; - /// Returns paginated entities filtered by template with HTTP pagination - /// support. - /// - /// **API contract:** Provides paginated entity listings for template-specific - /// views. - /// Supports standard REST pagination parameters and an optional `q` filter - /// query. - /// Template validation is handled by the domain service layer. - /// - /// @param page zero-based page index for pagination navigation - /// @param size number of entities per page for response size control - /// @param templateIdentifier template filter for entity scope limitation - /// @param q optional filter query string (e.g. - /// `name:API;property.language=JAVA`) - /// @return paginated entity DTOs matching the template and optional filter - @Operation(summary = ENDPOINT_GET_ENTITIES_SUMMARY, description = ENDPOINT_GET_ENTITIES_PAGINATED_DESCRIPTION) - @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITIES_PAGINATED_SUCCESS, content = @Content(schema = @Schema(implementation = EntityPageResponse.class))) - @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_PAGINATION, content = { - @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) - @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_QUERY, content = { - @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) - @Parameter(name = "page", description = PARAM_PAGE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "0"))) - @Parameter(name = "size", description = PARAM_SIZE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "20"))) - @Parameter(name = "sort", description = PARAM_SORT_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string", defaultValue = "identifier,asc"))) - @Parameter(name = "q", description = PARAM_QUERY_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string"))) - @ResponseStatus(OK) - @GetMapping("/{templateIdentifier}") - public Page getEntities(@RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size, @PathVariable String templateIdentifier, - @RequestParam(required = false) String q) { - Pageable pageable = PageRequest.of(page, size); - EntityFilter filter = entityQueryParserService.parse(q); - Page entities = entityService.getEntitiesByTemplateIdentifier(pageable, - templateIdentifier, filter); - return entityDtoOutMapper.fromEntitiesPageToDtoPage(entities, templateIdentifier); - } + /// Returns paginated entities filtered by template with HTTP pagination support. + /// + /// **API contract:** Provides paginated entity listings for template-specific views. + /// Supports standard REST pagination parameters and an optional `q` filter query. + /// Template validation is handled by the domain service layer. + /// + /// @param page zero-based page index for pagination navigation + /// @param size number of entities per page for response size control + /// @param templateIdentifier template filter for entity scope limitation + /// @param q optional filter query string (e.g. `name:API;property.language=JAVA`) + /// @return paginated entity DTOs matching the template and optional filter + @Operation(summary = ENDPOINT_GET_ENTITIES_SUMMARY, description = ENDPOINT_GET_ENTITIES_PAGINATED_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITIES_PAGINATED_SUCCESS, content = @Content(schema = @Schema(implementation = EntityPageResponse.class))) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_PAGINATION, content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_QUERY, content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @Parameter(name = "page", description = PARAM_PAGE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "0"))) + @Parameter(name = "size", description = PARAM_SIZE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "20"))) + @Parameter(name = "sort", description = PARAM_SORT_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string", defaultValue = "identifier,asc"))) + @Parameter(name = "q", description = PARAM_QUERY_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string"))) + @ResponseStatus(OK) + @GetMapping("/{templateIdentifier}") + public Page getEntities( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @PathVariable String templateIdentifier, + @RequestParam(required = false) String q) { + Pageable pageable = PageRequest.of(page, size); + EntityFilter filter = entityFilterDslParser.parse(q); + Page entities = entityService.getEntitiesByTemplateIdentifier(pageable, templateIdentifier, filter); + return entityDtoOutMapper.fromEntitiesPageToDtoPage(entities, templateIdentifier); + } /// Retrieves a single entity by template and entity identifiers. /// @@ -187,13 +197,12 @@ public EntityDtoOut getEntity(@PathVariable String templateIdentifier, public EntityDtoOut createEntity(@NotBlank @PathVariable String templateIdentifier, @Valid @RequestBody EntityCreateDtoIn entityCreateDtoIn) { - Entity entity = entityDtoInMapper.fromPostEntityDtoInToEntity(entityCreateDtoIn, - templateIdentifier); - Entity savedEntity = entityService.createEntity(entity); - return entityDtoOutMapper.fromEntity(savedEntity); - } + Entity entity = entityDtoInMapper.fromEntityDtoInToEntity(entityDtoIn, templateIdentifier); + Entity savedEntity = entityService.createEntity(entity); + return entityDtoOutMapper.fromEntity(savedEntity); + } - /// Updates an existing entity for the specified template. + /// Updates an existing entity for the specified template. /// /// **API contract:** Accepts entity update payload and returns updated entity. /// Validates @@ -229,4 +238,27 @@ public EntityDtoOut updateEntity(@NotBlank @PathVariable String templateIdentifi Entity updatedEntity = entityService.updateEntity(templateIdentifier, entityIdentifier, entity); return entityDtoOutMapper.fromEntity(updatedEntity); } + + /// Searches for entities across all templates using a nested filter query. + /// + /// **API contract:** Accepts a JSON body with a nested filter tree, pagination, and + /// sorting parameters. Returns a paginated list of entities matching the filter. + /// No template scoping is applied by default; include a template criterion + /// in the filter to scope results to a specific template. + /// + /// @param searchRequest the search request body with filter, page, size, and sort + /// @return paginated entity DTOs matching the filter + @Operation(summary = ENDPOINT_POST_SEARCH_SUMMARY, description = ENDPOINT_POST_SEARCH_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_SEARCH_SUCCESS, content = @Content(schema = @Schema(implementation = EntityPageResponse.class))) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_SEARCH_QUERY, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class)) }) + @PostMapping("/search") + @ResponseStatus(OK) + public Page searchEntities(@RequestBody EntitySearchRequestDtoIn searchRequest) { + RawSearchFilterNode rawFilter = searchFilterMapper.toRaw(searchRequest.filter()); + SearchFilterNode filter = searchFilterParser.parse(rawFilter); + Page entities = entityService.searchEntities( + filter, searchRequest.query(), searchRequest.page(), searchRequest.size(), searchRequest.sort()); + return entityDtoOutMapper.fromEntitiesSearchPageToDtoPage(entities); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntitySearchRequestDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntitySearchRequestDtoIn.java new file mode 100644 index 00000000..3ea06791 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntitySearchRequestDtoIn.java @@ -0,0 +1,67 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.in; + +import io.swagger.v3.oas.annotations.media.Schema; + +/// Request body for the `POST /api/v1/entities/search` endpoint. +/// +/// Supports two complementary search modes that can be combined: +///
    +///
  • {@code query} — a free-text string searched across identifier, name, +/// templateIdentifier, and all property values (case-insensitive CONTAINS).
  • +///
  • {@code filter} — a structured, nested filter tree for precise queries.
  • +///
+/// When both are provided the results must satisfy both (AND semantics). +/// +///

Free-text search example

+///
{@code
+/// { "query": "checkout", "page": 0, "size": 20 }
+/// }
+/// +///

Structured filter example

+///
{@code
+/// {
+///   "filter": {
+///     "connector": "AND",
+///     "criteria": [
+///       { "field": "template",  "operation": "EQ", "value": "microservice" },
+///       { "field": "property.language", "operation": "EQ", "value": "JAVA" }
+///     ]
+///   },
+///   "page": 0,
+///   "size": 20,
+///   "sort": "identifier:asc"
+/// }
+/// }
+@Schema(description = "Request body for the POST /api/v1/entities/search endpoint") +public record EntitySearchRequestDtoIn( + + @Schema(description = "Free-text search string. When present, returns entities whose identifier, name, templateIdentifier, or any property value contains this string (case-insensitive). Can be combined with filter.", example = "checkout") + String query, + + @Schema(description = "Root node of the search filter tree. May be omitted or null to return all entities.") + FilterNodeDtoIn filter, + + @Schema(description = "Zero-based page index. Defaults to 0.", defaultValue = "0", example = "0") + Integer page, + + @Schema(description = "Number of entities per page. Defaults to 20.", defaultValue = "20", example = "20") + Integer size, + + @Schema(description = "Sort expression in the form field:asc|desc, e.g. identifier:asc.", example = "identifier:asc") + String sort +) { + public EntitySearchRequestDtoIn { + if (size == null || size <= 0) { + size = 20; + } + if (page == null || page < 0) { + page = 0; + } + if (query != null) { + query = query.strip(); + if (query.isBlank()) { + query = null; + } + } + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/FilterNodeDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/FilterNodeDtoIn.java new file mode 100644 index 00000000..047bf2d0 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/FilterNodeDtoIn.java @@ -0,0 +1,33 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.in; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +/// A node in the search filter tree, used in the request body of +/// `POST /api/v1/entities/search`. +/// +/// Each node is either a **group** or a **criterion** (leaf node): +/// A **group** must have a `connector` (AND/OR) and a non-empty `criteria` list. +/// A **criterion** must have a `field`, an `operation`, and a `value`. +/// +/// Both types share the same JSON object shape; unused fields should be omitted or set to null. +@Schema(description = "A node in the search filter tree. Either a logical group (connector + criteria) or a leaf criterion (field + operation + value).") +public record FilterNodeDtoIn( + + @Schema(description = "Logical connector for a group node. One of: AND, OR. Required for group nodes.", example = "AND") + String connector, + + @Schema(description = "Child filter nodes for a group node. Required for group nodes (must be non-empty).") + List criteria, + + @Schema(description = "Field to filter on for a criterion node. Required for leaf nodes. Examples: template, identifier, name, relation, property.language, relation.api-link, relation.api-link.identifier, relations_as_target.api-link.name", example = "template") + String field, + + @Schema(description = "Filter operation for a criterion node. One of: EQ, NEQ, CONTAINS, NOT_CONTAINS, STARTS_WITH, ENDS_WITH, GT, GTE, LT, LTE. Required for leaf nodes.", example = "EQ") + String operation, + + @Schema(description = "Value to compare against for a criterion node. Required for leaf nodes.", example = "microservice") + String value +) { +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java index 75929d6f..5b289402 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java @@ -10,6 +10,7 @@ import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; +import com.decathlon.idp_core.domain.exception.filter.InvalidFilterDslException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; @@ -18,7 +19,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.method.annotation.HandlerMethodValidationException; -import com.decathlon.idp_core.domain.exception.InvalidQueryDslException; +import com.decathlon.idp_core.domain.exception.search.InvalidSearchQueryException; import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; @@ -73,17 +74,27 @@ public ResponseEntity handleTemplateNotFoundException( return ResponseEntity.status(NOT_FOUND).body(errorResponse); } - /// Handles domain exception for malformed filter query strings. + /// Handles domain exception for malformed filter query strings (`q=` DSL). /// - /// **HTTP mapping:** Maps domain [InvalidQueryDslException] to HTTP 400 Bad - /// Request + /// **HTTP mapping:** Maps domain [InvalidFilterDslException] to HTTP 400 Bad Request /// so API consumers receive clear feedback about invalid `q` parameter syntax. - @ExceptionHandler(InvalidQueryDslException.class) - public ResponseEntity handleInvalidQueryDslException(InvalidQueryDslException ex) { + @ExceptionHandler(InvalidFilterDslException.class) + public ResponseEntity handleInvalidFilterDslException(InvalidFilterDslException ex) { log.warn("Invalid filter query: {}", ex.getMessage()); return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); } + /// Handles domain exception for malformed search filter trees or free-text query strings. + /// + /// **HTTP mapping:** Maps domain [InvalidSearchQueryException] to HTTP 400 Bad Request + /// so API consumers receive clear feedback about invalid search request syntax. + @ExceptionHandler(InvalidSearchQueryException.class) + public ResponseEntity handleInvalidSearchQueryException( + InvalidSearchQueryException ex) { + log.warn("Invalid search query: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + /// Handles domain exception when entity templates already exist. /// /// **HTTP mapping:** Maps domain EntityTemplateAlreadyExistsException to HTTP diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/SearchFilterMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/SearchFilterMapper.java new file mode 100644 index 00000000..a5cbe5a0 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/SearchFilterMapper.java @@ -0,0 +1,39 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.decathlon.idp_core.domain.model.entity.RawSearchFilterNode; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.FilterNodeDtoIn; + +/// Converts a [FilterNodeDtoIn] tree into a [RawSearchFilterNode] tree. +/// +/// **Responsibility:** Pure structural adapter — copies DTO fields to domain-native raw types +/// with no validation, no enum parsing, and no business logic. All validation and type resolution +/// are handled downstream by the domain parser +/// ([com.decathlon.idp_core.domain.service.search.SearchFilterParser]). +@Component +public class SearchFilterMapper { + + /// Converts a nullable [FilterNodeDtoIn] to a [RawSearchFilterNode]. + /// + /// @param dto the root node DTO; may be null, in which case null is returned (the domain parser + /// treats null as "no filter") + /// @return the raw domain tree, or null when dto is null + public RawSearchFilterNode toRaw(FilterNodeDtoIn dto) { + if (dto == null) { + return null; + } + return convertNode(dto); + } + + private RawSearchFilterNode convertNode(FilterNodeDtoIn dto) { + if (dto.connector() != null || dto.criteria() != null) { + List children = dto.criteria() == null ? List.of() + : dto.criteria().stream().map(this::convertNode).toList(); + return new RawSearchFilterNode.Group(dto.connector(), children); + } + return new RawSearchFilterNode.Criterion(dto.field(), dto.operation(), dto.value()); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java index 4cc14a22..347d5271 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java @@ -13,11 +13,13 @@ import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntityFilter; import com.decathlon.idp_core.domain.model.entity.EntitySummary; +import com.decathlon.idp_core.domain.model.entity.SearchFilterNode; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.EntityPersistenceMapper; import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.EntityJpaEntity; import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.JpaEntityRepository; -import com.decathlon.idp_core.infrastructure.adapters.persistence.specification.EntitySpecification; +import com.decathlon.idp_core.infrastructure.adapters.persistence.specification.EntityFilterSpecification; +import com.decathlon.idp_core.infrastructure.adapters.persistence.specification.EntitySearchSpecification; import lombok.RequiredArgsConstructor; @@ -61,7 +63,7 @@ public Page findByTemplateIdentifier(String templateIdentifier, Pageable @Override public Page findByTemplateIdentifierWithFilter(String templateIdentifier, EntityFilter filter, Pageable pageable) { - Specification spec = EntitySpecification.of(templateIdentifier, filter); + Specification spec = EntityFilterSpecification.of(templateIdentifier, filter); return jpaEntityRepository.findAll(spec, pageable).map(mapper::toDomain); } @@ -82,10 +84,17 @@ public void deletePropertiesByTemplateIdentifierAndPropertyName(String templateI propertyNames); } - @Override - public void deleteRelationsByTemplateIdentifierAndRelationName(String templateIdentifier, - Collection relationNames) { - jpaEntityRepository.deleteRelationsByTemplateIdentifierAndRelationName(templateIdentifier, - relationNames); - } + @Override + public void deleteRelationsByTemplateIdentifierAndRelationName(String templateIdentifier, Collection relationNames) { + jpaEntityRepository.deleteRelationsByTemplateIdentifierAndRelationName(templateIdentifier, relationNames); + } + + @Override + public Page search(SearchFilterNode filter, String query, Pageable pageable) { + Specification spec = EntitySearchSpecification.of(filter); + if (query != null && !query.isBlank()) { + spec = spec.and(EntitySearchSpecification.globalTextSearch(query)); + } + return jpaEntityRepository.findAll(spec, pageable).map(mapper::toDomain); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntityFilterSpecification.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntityFilterSpecification.java new file mode 100644 index 00000000..4c477568 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntityFilterSpecification.java @@ -0,0 +1,212 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.specification; + +import java.util.stream.Stream; + +import org.springframework.data.jpa.domain.Specification; + +import com.decathlon.idp_core.domain.model.entity.EntityFilter; +import com.decathlon.idp_core.domain.model.entity.FilterCriterion; +import com.decathlon.idp_core.domain.model.enums.FilterOperator; +import com.decathlon.idp_core.domain.model.enums.SearchOperator; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.EntityJpaEntity; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.PropertyJpaEntity; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.RelationJpaEntity; + +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.Expression; +import jakarta.persistence.criteria.Join; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; +import jakarta.persistence.criteria.Subquery; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/// Builds a JPA [Specification] for [EntityJpaEntity] from an [EntityFilter]. +/// +/// **Query strategy:** +/// - Attribute criteria use direct predicates on the entity root. +/// - Property criteria use an INNER JOIN on the `properties` collection. +/// - Relation name criteria filter entities that have a relation with a specific name. +/// - Relation entity criteria use an INNER JOIN on the `relations` collection and +/// then on the `targetEntityIdentifiers` element collection. +/// - Relation property criteria use an INNER JOIN on the `relations` collection and +/// filter on the specified property (e.g., `name`, `identifier`). +/// - Relations as target name criteria find entities where they are targets of relations +/// with a specific name (requires joining relations and checking targetEntityIdentifiers). +/// - Join-based criteria call `query.distinct(true)` to prevent duplicate rows from +/// - All criteria are combined with AND logic via [Specification#allOf]. +/// +/// **Security:** The CONTAINS operator escapes SQL LIKE wildcards (`%`, `_`) in +/// user-supplied values to prevent unintended pattern matching. +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class EntityFilterSpecification { + + private static final String NAME = "name"; + private static final String IDENTIFIER = "identifier"; + private static final String RELATIONS = "relations"; + private static final String TARGET_ENTITY_IDENTIFIERS = "targetEntityIdentifiers"; + + /// Builds a [Specification] that matches entities belonging to the given template identifier + /// and satisfying all criteria in the given filter. + /// + /// @param templateIdentifier the template to scope the query to + /// @param filter the filter to apply; may be empty (no additional predicates) + /// @return a composed [Specification] combining template scope and all filter criteria + public static Specification of(String templateIdentifier, EntityFilter filter) { + var criteriaSpecs = filter.criteria().stream() + .map(EntityFilterSpecification::fromCriterion); + + return Stream.concat( + Stream.of(hasTemplateIdentifier(templateIdentifier)), + criteriaSpecs + ).reduce(Specification::and).orElse(hasTemplateIdentifier(templateIdentifier)); + } + + private static Specification hasTemplateIdentifier(String templateIdentifier) { + return (root, query, cb) -> cb.equal(root.get("templateIdentifier"), templateIdentifier); + } + + private static Specification fromCriterion(FilterCriterion criterion) { + return switch (criterion.keyType()) { + case ATTRIBUTE -> attributeSpec(criterion); + case PROPERTY -> propertySpec(criterion); + case RELATION_NAME -> relationNameSpec(criterion); + case RELATION_ENTITY -> relationEntitySpec(criterion); + case RELATION_PROPERTY -> relationPropertySpec(criterion); + case RELATIONS_AS_TARGET_NAME -> relationsAsTargetNameSpec(criterion); + case RELATIONS_AS_TARGET_PROPERTY -> relationsAsTargetPropertySpec(criterion); + }; + } + + private static Specification attributeSpec(FilterCriterion criterion) { + return (root, query, cb) -> + buildPredicate(cb, root.get(criterion.key()), criterion.operator(), criterion.value()); + } + + private static Specification propertySpec(FilterCriterion criterion) { + return (root, query, cb) -> { + query.distinct(true); + Join propJoin = root.join("properties"); + return cb.and( + cb.equal(propJoin.get(NAME), criterion.key()), + buildPredicate(cb, propJoin.get("value"), criterion.operator(), criterion.value()) + ); + }; + } + + private static Specification relationEntitySpec(FilterCriterion criterion) { + return (root, query, cb) -> { + query.distinct(true); + Join relJoin = root.join(RELATIONS); + Join targetJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); + return cb.and( + cb.equal(relJoin.get(NAME), criterion.key()), + buildPredicate(cb, targetJoin, criterion.operator(), criterion.value()) + ); + }; + } + + private static Specification relationPropertySpec(FilterCriterion criterion) { + return (root, query, cb) -> { + query.distinct(true); + Join relJoin = root.join(RELATIONS); + + String compositeKey = criterion.key(); + int dotIndex = compositeKey.indexOf('.'); + if (dotIndex < 0) { + throw new IllegalArgumentException("Invalid composite key format: " + compositeKey); + } + String relationName = compositeKey.substring(0, dotIndex); + String propertyName = compositeKey.substring(dotIndex + 1); + + // Check if the property is a target entity property (identifier, name) + if (IDENTIFIER.equals(propertyName) || NAME.equals(propertyName)) { + // Join to target entity identifiers first + Join targetIdJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); + // Create a subquery to find the actual target entities and filter by their properties + var subquery = query.subquery(String.class); + var subRoot = subquery.from(EntityJpaEntity.class); + subquery.select(subRoot.get(IDENTIFIER)) + .where(buildPredicate(cb, subRoot.get(propertyName), criterion.operator(), criterion.value())); + + return cb.and( + cb.equal(relJoin.get(NAME), relationName), + cb.in(targetIdJoin).value(subquery) + ); + } else { + // Direct relation property (shouldn't happen normally as RelationJpaEntity has limited properties) + return cb.and( + cb.equal(relJoin.get(NAME), relationName), + buildPredicate(cb, relJoin.get(propertyName), criterion.operator(), criterion.value()) + ); + } + }; + } + + private static Predicate buildPredicate( + CriteriaBuilder cb, + Expression field, + FilterOperator operator, + String value) { + return switch (operator) { + case EQUALS -> JpaPredicateBuilder.buildPredicate(cb, field, SearchOperator.EQ, value); + case CONTAINS -> JpaPredicateBuilder.buildPredicate(cb, field, SearchOperator.CONTAINS, value); + // LESS_THAN / GREATER_THAN keep lexicographic string comparison (System A semantics). + case LESS_THAN -> cb.lessThan(field.as(String.class), value); + case GREATER_THAN -> cb.greaterThan(field.as(String.class), value); + }; + } + + private static Specification relationNameSpec(FilterCriterion criterion) { + return (root, query, cb) -> { + query.distinct(true); + Join relJoin = root.join(RELATIONS); + return buildPredicate(cb, relJoin.get(NAME), criterion.operator(), criterion.value()); + }; + } + + private static Specification relationsAsTargetNameSpec(FilterCriterion criterion) { + return (root, query, cb) -> { + // Find entities whose identifier appears as a target in any relation whose name matches. + // Uses a correlated subquery to avoid joining through the entity's own outgoing relations. + Subquery subquery = query.subquery(String.class); + Root relRoot = subquery.from(RelationJpaEntity.class); + Join targetJoin = relRoot.join(TARGET_ENTITY_IDENTIFIERS); + subquery.select(targetJoin) + .where(buildPredicate(cb, relRoot.get(NAME), criterion.operator(), criterion.value())); + return cb.in(root.get(IDENTIFIER)).value(subquery); + }; + } + + /// Finds entities whose `identifier` appears as a `targetEntityIdentifier` in any + /// relation whose **source entity** property matches the criterion. + /// + /// Example: `relations_as_target.api-link.name:microservice` returns entities that + /// are targeted by a `api-link` relation originating from an entity whose name + /// contains "microservice". + private static Specification relationsAsTargetPropertySpec(FilterCriterion criterion) { + return (root, query, cb) -> { + String compositeKey = criterion.key(); + int dotIndex = compositeKey.indexOf('.'); + if (dotIndex < 0) { + throw new IllegalArgumentException("Invalid composite key format: " + compositeKey); + } + String relationName = compositeKey.substring(0, dotIndex); + String propertyName = compositeKey.substring(dotIndex + 1); // "identifier" or "name" + + // Subquery: collect all target identifiers from relations named + // that originate from source entities whose matches. + Subquery subquery = query.subquery(String.class); + Root sourceRoot = subquery.from(EntityJpaEntity.class); + Join relJoin = sourceRoot.join(RELATIONS); + Join targetJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); + subquery.select(targetJoin) + .where( + cb.equal(relJoin.get(NAME), relationName), + buildPredicate(cb, sourceRoot.get(propertyName), criterion.operator(), criterion.value()) + ); + return cb.in(root.get(IDENTIFIER)).value(subquery); + }; + } + +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecification.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecification.java new file mode 100644 index 00000000..659bb5bd --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecification.java @@ -0,0 +1,307 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.specification; + +import java.util.List; + +import org.hibernate.query.criteria.HibernateCriteriaBuilder; +import org.springframework.data.jpa.domain.Specification; + +import com.decathlon.idp_core.domain.model.entity.SearchFilterNode; +import com.decathlon.idp_core.domain.model.enums.SearchOperator; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.EntityJpaEntity; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.RelationJpaEntity; + +import static com.decathlon.idp_core.infrastructure.adapters.persistence.specification.JpaPredicateBuilder.buildPredicate; +import static com.decathlon.idp_core.infrastructure.adapters.persistence.specification.JpaPredicateBuilder.escapeLikeWildcards; + +import jakarta.persistence.criteria.Join; +import jakarta.persistence.criteria.Root; +import jakarta.persistence.criteria.Subquery; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/// Builds a JPA [Specification] for [EntityJpaEntity] from a [SearchFilterNode] tree. +/// +/// **Query strategy:** +/// - [SearchFilterNode.Group] nodes are translated recursively: AND → Specification::and, +/// OR → Specification::or. +/// - [SearchFilterNode.Criterion] nodes are translated based on the field prefix: +/// - `template` → direct predicate on templateIdentifier +/// - `identifier` / `name` → direct predicates on the entity root +/// - `property.{name}` → correlated EXISTS subquery on the `properties` collection +/// - `relation.{name}` / `relation.{name}.identifier|name` → correlated EXISTS subquery +/// on `relations` with optional nested IN subquery for target entity properties +/// - `relations_as_target.{name}.identifier|name` → correlated IN subquery +/// that finds entities targeted by qualifying reverse relations +/// +/// **Performance:** All collection-based filters use EXISTS subqueries instead of JOINs. +/// This eliminates row multiplication (an entity with N properties and M relations would +/// otherwise produce N×M rows requiring DISTINCT), making pagination and count queries +/// significantly cheaper. +/// +/// **Security:** LIKE-based operators ([SearchOperator#CONTAINS], [SearchOperator#NOT_CONTAINS], +/// [SearchOperator#STARTS_WITH], [SearchOperator#ENDS_WITH]) use PostgreSQL `ILIKE` for +/// case-insensitive matching, allowing GIN trigram indexes (V3_5) to be leveraged. +/// SQL wildcards (`%` and `_`) in user-supplied values are escaped to prevent unintended +/// pattern matching. EQ and NEQ use `LOWER()` with functional btree indexes (V3_4). +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class EntitySearchSpecification { + + private static final String TEMPLATE_IDENTIFIER = "templateIdentifier"; + private static final String IDENTIFIER = "identifier"; + private static final String NAME = "name"; + private static final String RELATION = "relation"; + private static final String RELATIONS = "relations"; + private static final String RELATIONS_AS_TARGET = "relations_as_target"; + private static final String TARGET_ENTITY_IDENTIFIERS = "targetEntityIdentifiers"; + private static final String PROPERTY_PREFIX = "property."; + private static final String RELATION_PREFIX = "relation."; + private static final String RELATIONS_AS_TARGET_PREFIX = "relations_as_target."; + + /// Builds a [Specification] from the root [SearchFilterNode]. + /// + /// @param filter the root of the search filter tree + /// @return a composed [Specification] matching the filter tree + public static Specification of(SearchFilterNode filter) { + return build(filter); + } + + /// Builds a global free-text search [Specification] that matches entities whose + /// `identifier`, `name`, `templateIdentifier`, or any property value contains the given string (case-insensitive). + /// + /// The four conditions are combined with OR so that a match on any field is sufficient. + /// The "any property" branch uses a correlated EXISTS subquery to avoid row multiplication. + /// All comparisons use `ILIKE` so that GIN trigram indexes (V3_5) can be leveraged. + /// + /// @param query the search string; must be non-null and non-blank + /// @return a [Specification] implementing the global text search + public static Specification globalTextSearch(String query) { + // No toLowerCase() needed — ILIKE is inherently case-insensitive. + String escaped = escapeLikeWildcards(query); + String pattern = "%" + escaped + "%"; + + Specification byIdentifier = + (root, q, cb) -> ((HibernateCriteriaBuilder) cb).ilike(root.get(IDENTIFIER), pattern, JpaPredicateBuilder.LIKE_ESCAPE_CHAR); + + Specification byName = + (root, q, cb) -> ((HibernateCriteriaBuilder) cb).ilike(root.get(NAME), pattern, JpaPredicateBuilder.LIKE_ESCAPE_CHAR); + + Specification byTemplate = + (root, q, cb) -> ((HibernateCriteriaBuilder) cb).ilike(root.get(TEMPLATE_IDENTIFIER), pattern, JpaPredicateBuilder.LIKE_ESCAPE_CHAR); + + Specification byAnyProperty = (root, queryCtx, cb) -> { + // Correlated EXISTS: does this entity have at least one property whose value matches? + var sub = queryCtx.subquery(Integer.class); + var subRoot = sub.from(EntityJpaEntity.class); + var propJoin = subRoot.join("properties"); + sub.select(cb.literal(1)) + .where( + cb.equal(subRoot.get("id"), root.get("id")), + ((HibernateCriteriaBuilder) cb).ilike(propJoin.get("value").as(String.class), pattern, JpaPredicateBuilder.LIKE_ESCAPE_CHAR) + ); + return cb.exists(sub); + }; + + return byIdentifier.or(byName).or(byTemplate).or(byAnyProperty); + } + + private static Specification build(SearchFilterNode node) { + return switch (node) { + case SearchFilterNode.Group g -> buildGroup(g); + case SearchFilterNode.Criterion c -> buildCriterion(c); + }; + } + + private static Specification buildGroup(SearchFilterNode.Group group) { + var nodes = group.nodes(); + if (nodes.isEmpty()) { + return (root, query, cb) -> cb.conjunction(); // empty group matches all + } + + List> specs = nodes.stream().map(EntitySearchSpecification::build).toList(); + + return switch (group.connector()) { + case AND -> specs.stream().reduce(Specification::and).orElseThrow(); + case OR -> specs.stream().reduce(Specification::or).orElseThrow(); + }; + } + + // --- Field-based criterion dispatch --- + + private static Specification buildCriterion(SearchFilterNode.Criterion c) { + var field = c.field(); + if ("template".equals(field)) { + return (root, query, cb) -> buildPredicate(cb, root.get(TEMPLATE_IDENTIFIER), c.operation(), c.value()); + } + if (IDENTIFIER.equals(field)) { + return (root, query, cb) -> buildPredicate(cb, root.get(IDENTIFIER), c.operation(), c.value()); + } + if (NAME.equals(field)) { + return (root, query, cb) -> buildPredicate(cb, root.get(NAME), c.operation(), c.value()); + } + if (field.startsWith(PROPERTY_PREFIX)) { + return propertySpec(c, field.substring(PROPERTY_PREFIX.length())); + } + if (field.startsWith(RELATIONS_AS_TARGET_PREFIX)) { + return relationsAsTargetSpec(c, field.substring(RELATIONS_AS_TARGET_PREFIX.length())); + } + if (RELATIONS_AS_TARGET.equals(field)) { + return relationsAsTargetNameSpec(c); + } + if (RELATION.equals(field)) { + return relationNameSpec(c); + } + if (field.startsWith(RELATION_PREFIX)) { + return relationSpec(c, field.substring(RELATION_PREFIX.length())); + } + throw new IllegalArgumentException("Unknown search field: " + field); + } + + // --- Property spec --- + + private static Specification propertySpec(SearchFilterNode.Criterion c, String propertyName) { + return (root, query, cb) -> { + // Correlated EXISTS: does this entity have a property with the given name and value? + // Using EXISTS instead of JOIN avoids row multiplication and removes the need for DISTINCT. + var sub = query.subquery(Integer.class); + var subRoot = sub.from(EntityJpaEntity.class); + var propJoin = subRoot.join("properties"); + sub.select(cb.literal(1)) + .where( + cb.equal(subRoot.get("id"), root.get("id")), + cb.equal(propJoin.get(NAME), propertyName), + buildPredicate(cb, propJoin.get("value"), c.operation(), c.value()) + ); + return cb.exists(sub); + }; + } + + // --- Relation specs --- + + private static Specification relationNameSpec(SearchFilterNode.Criterion c) { + return (root, query, cb) -> { + // Correlated EXISTS: does this entity have at least one relation whose name matches? + var sub = query.subquery(Integer.class); + var subRoot = sub.from(EntityJpaEntity.class); + var relJoin = subRoot.join(RELATIONS); + sub.select(cb.literal(1)) + .where( + cb.equal(subRoot.get("id"), root.get("id")), + buildPredicate(cb, relJoin.get(NAME), c.operation(), c.value()) + ); + return cb.exists(sub); + }; + } + + private static Specification relationSpec(SearchFilterNode.Criterion c, String relationPart) { + int dotIndex = relationPart.indexOf('.'); + if (dotIndex > 0) { + // relation.{name}.{identifier|name} → filter by target entity property with a subquery + String relationName = relationPart.substring(0, dotIndex); + String property = relationPart.substring(dotIndex + 1); + return relationPropertySpec(c, relationName, property); + } + // relation.{name} → filter by target entity identifier + return relationEntitySpec(c, relationPart); + } + + private static Specification relationEntitySpec(SearchFilterNode.Criterion c, String relationName) { + return (root, query, cb) -> { + // Correlated EXISTS: does this entity have a relation named + // whose target entity identifier matches the criterion? + var sub = query.subquery(Integer.class); + var subRoot = sub.from(EntityJpaEntity.class); + var relJoin = subRoot.join(RELATIONS); + var targetJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); + sub.select(cb.literal(1)) + .where( + cb.equal(subRoot.get("id"), root.get("id")), + cb.equal(relJoin.get(NAME), relationName), + buildPredicate(cb, targetJoin, c.operation(), c.value()) + ); + return cb.exists(sub); + }; + } + + private static Specification relationPropertySpec( + SearchFilterNode.Criterion c, String relationName, String property) { + return (root, query, cb) -> { + // Correlated EXISTS: does this entity have a relation named + // whose target identifier appears in the set of entity identifiers + // whose matches the criterion? + var sub = query.subquery(Integer.class); + var subRoot = sub.from(EntityJpaEntity.class); + var relJoin = subRoot.join(RELATIONS); + var targetIdJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); + + // Inner scalar subquery: entity identifiers whose identifier/name satisfies the criterion. + var innerSubquery = query.subquery(String.class); + var innerRoot = innerSubquery.from(EntityJpaEntity.class); + innerSubquery.select(innerRoot.get(IDENTIFIER)) + .where(buildPredicate(cb, innerRoot.get(property), c.operation(), c.value())); + + sub.select(cb.literal(1)) + .where( + cb.equal(subRoot.get("id"), root.get("id")), + cb.equal(relJoin.get(NAME), relationName), + cb.in(targetIdJoin).value(innerSubquery) + ); + return cb.exists(sub); + }; + } + + // --- Relations-as-target specs --- + + private static Specification relationsAsTargetNameSpec(SearchFilterNode.Criterion c) { + return (root, query, cb) -> { + // Subquery: collect all target entity identifiers from relations whose name matches. + // For NOT_CONTAINS / NEQ (negative operators): use NOT IN with the positive equivalent + // predicate so that the result means "not targeted by any matching reverse relation", + // which is the natural set-membership interpretation of "does not contain". + SearchOperator effectiveOp = switch (c.operation()) { + case NOT_CONTAINS -> SearchOperator.CONTAINS; + case NEQ -> SearchOperator.EQ; + default -> c.operation(); + }; + + Subquery subquery = query.subquery(String.class); + Root sourceRoot = subquery.from(EntityJpaEntity.class); + Join relJoin = sourceRoot.join(RELATIONS); + Join targetJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); + subquery.select(targetJoin) + .where(buildPredicate(cb, relJoin.get(NAME), effectiveOp, c.value())); + + boolean isNegated = c.operation() == SearchOperator.NOT_CONTAINS + || c.operation() == SearchOperator.NEQ; + var membership = cb.in(root.get(IDENTIFIER)).value(subquery); + return isNegated ? cb.not(membership) : membership; + }; + } + + private static Specification relationsAsTargetSpec( + SearchFilterNode.Criterion c, String relPart) { + int dotIndex = relPart.indexOf('.'); + if (dotIndex <= 0) { + throw new IllegalArgumentException( + "Invalid field 'relations_as_target." + relPart + + "': expected form relations_as_target.{relationName}.{identifier|name}"); + } + String relationName = relPart.substring(0, dotIndex); + String property = relPart.substring(dotIndex + 1); // identifier or name + + return (root, query, cb) -> { + // Subquery: collect target identifiers from relations named + // whose source entity's matches the criterion. + Subquery subquery = query.subquery(String.class); + Root sourceRoot = subquery.from(EntityJpaEntity.class); + Join relJoin = sourceRoot.join(RELATIONS); + Join targetJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); + subquery.select(targetJoin) + .where( + cb.equal(relJoin.get(NAME), relationName), + buildPredicate(cb, sourceRoot.get(property), c.operation(), c.value()) + ); + return cb.in(root.get(IDENTIFIER)).value(subquery); + }; + } + +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySpecification.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySpecification.java deleted file mode 100644 index 8423e9e2..00000000 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySpecification.java +++ /dev/null @@ -1,217 +0,0 @@ -package com.decathlon.idp_core.infrastructure.adapters.persistence.specification; - -import java.util.stream.Stream; - -import jakarta.persistence.criteria.CriteriaBuilder; -import jakarta.persistence.criteria.Expression; -import jakarta.persistence.criteria.Join; -import jakarta.persistence.criteria.Predicate; -import jakarta.persistence.criteria.Root; -import jakarta.persistence.criteria.Subquery; - -import org.springframework.data.jpa.domain.Specification; - -import com.decathlon.idp_core.domain.model.entity.EntityFilter; -import com.decathlon.idp_core.domain.model.entity.FilterCriterion; -import com.decathlon.idp_core.domain.model.enums.FilterOperator; -import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.EntityJpaEntity; -import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.PropertyJpaEntity; -import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.RelationJpaEntity; - -import lombok.AccessLevel; -import lombok.NoArgsConstructor; - -/// Builds a JPA [Specification] for [EntityJpaEntity] from an [EntityFilter]. -/// -/// **Query strategy:** -/// - Attribute criteria use direct predicates on the entity root. -/// - Property criteria use an INNER JOIN on the `properties` collection. -/// - Relation name criteria filter entities that have a relation with a specific name. -/// - Relation entity criteria use an INNER JOIN on the `relations` collection and -/// then on the `targetEntityIdentifiers` element collection. -/// - Relation property criteria use an INNER JOIN on the `relations` collection and -/// filter on the specified property (e.g., `name`, `identifier`). -/// - Relations as target name criteria find entities where they are targets of relations -/// with a specific name (requires joining relations and checking targetEntityIdentifiers). -/// - Join-based criteria call `query.distinct(true)` to prevent duplicate rows from -/// - All criteria are combined with AND logic via [Specification#allOf]. -/// -/// **Security:** The CONTAINS operator escapes SQL LIKE wildcards (`%`, `_`) in -/// user-supplied values to prevent unintended pattern matching. -@NoArgsConstructor(access = AccessLevel.PRIVATE) -public final class EntitySpecification { - - private static final char LIKE_ESCAPE_CHAR = '\\'; - private static final String NAME = "name"; - private static final String IDENTIFIER = "identifier"; - private static final String RELATIONS = "relations"; - private static final String TARGET_ENTITY_IDENTIFIERS = "targetEntityIdentifiers"; - - /// Builds a [Specification] that matches entities belonging to the given - /// template identifier - /// and satisfying all criteria in the given filter. - /// - /// @param templateIdentifier the template to scope the query to - /// @param filter the filter to apply; may be empty (no additional predicates) - /// @return a composed [Specification] combining template scope and all filter - /// criteria - public static Specification of(String templateIdentifier, EntityFilter filter) { - var criteriaSpecs = filter.criteria().stream().map(EntitySpecification::fromCriterion); - - return Stream.concat(Stream.of(hasTemplateIdentifier(templateIdentifier)), criteriaSpecs) - .reduce(Specification::and).orElse(hasTemplateIdentifier(templateIdentifier)); - } - - private static Specification hasTemplateIdentifier(String templateIdentifier) { - return (root, query, cb) -> cb.equal(root.get("templateIdentifier"), templateIdentifier); - } - - private static Specification fromCriterion(FilterCriterion criterion) { - return switch (criterion.keyType()) { - case ATTRIBUTE -> attributeSpec(criterion); - case PROPERTY -> propertySpec(criterion); - case RELATION_NAME -> relationNameSpec(criterion); - case RELATION_ENTITY -> relationEntitySpec(criterion); - case RELATION_PROPERTY -> relationPropertySpec(criterion); - case RELATIONS_AS_TARGET_NAME -> relationsAsTargetNameSpec(criterion); - case RELATIONS_AS_TARGET_PROPERTY -> relationsAsTargetPropertySpec(criterion); - }; - } - - private static Specification attributeSpec(FilterCriterion criterion) { - return (root, query, cb) -> buildPredicate(cb, root.get(criterion.key()), criterion.operator(), - criterion.value()); - } - - private static Specification propertySpec(FilterCriterion criterion) { - return (root, query, cb) -> { - query.distinct(true); - Join propJoin = root.join("properties"); - return cb.and(cb.equal(propJoin.get(NAME), criterion.key()), - buildPredicate(cb, propJoin.get("value"), criterion.operator(), criterion.value())); - }; - } - - private static Specification relationEntitySpec(FilterCriterion criterion) { - return (root, query, cb) -> { - query.distinct(true); - Join relJoin = root.join(RELATIONS); - Join targetJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); - return cb.and(cb.equal(relJoin.get(NAME), criterion.key()), - buildPredicate(cb, targetJoin, criterion.operator(), criterion.value())); - }; - } - - private static Specification relationPropertySpec(FilterCriterion criterion) { - return (root, query, cb) -> { - query.distinct(true); - Join relJoin = root.join(RELATIONS); - - String compositeKey = criterion.key(); - int dotIndex = compositeKey.indexOf('.'); - if (dotIndex < 0) { - throw new IllegalArgumentException("Invalid composite key format: " + compositeKey); - } - String relationName = compositeKey.substring(0, dotIndex); - String propertyName = compositeKey.substring(dotIndex + 1); - - // Check if the property is a target entity property (identifier, name) - if (IDENTIFIER.equals(propertyName) || NAME.equals(propertyName)) { - // Join to target entity identifiers first - Join targetIdJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); - // Create a subquery to find the actual target entities and filter by their - // properties - var subquery = query.subquery(String.class); - var subRoot = subquery.from(EntityJpaEntity.class); - subquery.select(subRoot.get(IDENTIFIER)).where( - buildPredicate(cb, subRoot.get(propertyName), criterion.operator(), criterion.value())); - - return cb.and(cb.equal(relJoin.get(NAME), relationName), - cb.in(targetIdJoin).value(subquery)); - } else { - // Direct relation property (shouldn't happen normally as RelationJpaEntity has - // limited properties) - return cb.and(cb.equal(relJoin.get(NAME), relationName), - buildPredicate(cb, relJoin.get(propertyName), criterion.operator(), criterion.value())); - } - }; - } - - private static Predicate buildPredicate(CriteriaBuilder cb, Expression field, - FilterOperator operator, String value) { - Expression stringField = field.as(String.class); - return switch (operator) { - case EQUALS -> cb.equal(cb.lower(stringField), value.toLowerCase()); - case CONTAINS -> { - String escaped = escapeLikeWildcards(value.toLowerCase()); - yield cb.like(cb.lower(stringField), "%" + escaped + "%", LIKE_ESCAPE_CHAR); - } - case LESS_THAN -> cb.lessThan(stringField, value); - case GREATER_THAN -> cb.greaterThan(stringField, value); - }; - } - - private static Specification relationNameSpec(FilterCriterion criterion) { - return (root, query, cb) -> { - query.distinct(true); - Join relJoin = root.join(RELATIONS); - return buildPredicate(cb, relJoin.get(NAME), criterion.operator(), criterion.value()); - }; - } - - private static Specification relationsAsTargetNameSpec( - FilterCriterion criterion) { - return (root, query, cb) -> { - // Find entities whose identifier appears as a target in any relation whose name - // matches. - // Uses a correlated subquery to avoid joining through the entity's own outgoing - // relations. - Subquery subquery = query.subquery(String.class); - Root relRoot = subquery.from(RelationJpaEntity.class); - Join targetJoin = relRoot.join(TARGET_ENTITY_IDENTIFIERS); - subquery.select(targetJoin) - .where(buildPredicate(cb, relRoot.get(NAME), criterion.operator(), criterion.value())); - return cb.in(root.get(IDENTIFIER)).value(subquery); - }; - } - - /// Finds entities whose `identifier` appears as a `targetEntityIdentifier` in - /// any - /// relation whose **source entity** property matches the criterion. - /// - /// Example: `relations_as_target.api-link.name:microservice` returns entities - /// that - /// are targeted by a `api-link` relation originating from an entity whose name - /// contains "microservice". - private static Specification relationsAsTargetPropertySpec( - FilterCriterion criterion) { - return (root, query, cb) -> { - String compositeKey = criterion.key(); - int dotIndex = compositeKey.indexOf('.'); - if (dotIndex < 0) { - throw new IllegalArgumentException("Invalid composite key format: " + compositeKey); - } - String relationName = compositeKey.substring(0, dotIndex); - String propertyName = compositeKey.substring(dotIndex + 1); // "identifier" or "name" - - // Subquery: collect all target identifiers from relations named - // that originate from source entities whose matches. - Subquery subquery = query.subquery(String.class); - Root sourceRoot = subquery.from(EntityJpaEntity.class); - Join relJoin = sourceRoot.join(RELATIONS); - Join targetJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); - subquery.select(targetJoin).where(cb.equal(relJoin.get(NAME), relationName), buildPredicate( - cb, sourceRoot.get(propertyName), criterion.operator(), criterion.value())); - return cb.in(root.get(IDENTIFIER)).value(subquery); - }; - } - - /// Escapes SQL LIKE wildcards (`%` and `_`) in the given value so they are - /// treated as literal characters rather than pattern metacharacters. - static String escapeLikeWildcards(String value) { - return value - .replace(String.valueOf(LIKE_ESCAPE_CHAR), - LIKE_ESCAPE_CHAR + String.valueOf(LIKE_ESCAPE_CHAR)) - .replace("%", LIKE_ESCAPE_CHAR + "%").replace("_", LIKE_ESCAPE_CHAR + "_"); - } -} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/JpaPredicateBuilder.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/JpaPredicateBuilder.java new file mode 100644 index 00000000..b197c1cc --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/JpaPredicateBuilder.java @@ -0,0 +1,116 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.specification; + +import java.math.BigDecimal; + +import org.hibernate.query.criteria.HibernateCriteriaBuilder; + +import com.decathlon.idp_core.domain.model.enums.SearchOperator; + +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.Expression; +import jakarta.persistence.criteria.Predicate; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/// Shared predicate-building utilities used by [EntitySpecification] and [EntitySearchSpecification]. +/// +/// **Responsibilities:** +/// - LIKE wildcard escaping for user-supplied values +/// - String-comparison predicates using `ILIKE` (case-insensitive, GIN-index-friendly) for +/// [SearchOperator#CONTAINS], [SearchOperator#NOT_CONTAINS], [SearchOperator#STARTS_WITH], +/// [SearchOperator#ENDS_WITH], and `LOWER()` equality for [SearchOperator#EQ] / [SearchOperator#NEQ] +/// - Numeric-comparison predicates (GT, GTE, LT, LTE) via explicit SQL `CAST(field AS NUMERIC)` +/// so that VARCHAR property-value columns can be compared to numeric literals without +/// PostgreSQL rejecting the query. +/// +/// **Why Hibernate-specific?** The codebase already targets PostgreSQL through Hibernate. +/// `ILIKE` requires [HibernateCriteriaBuilder] which is a Hibernate extension; this is +/// intentional and consistent with the rest of the specification layer. +@NoArgsConstructor(access = AccessLevel.PRIVATE) +final class JpaPredicateBuilder { + + static final char LIKE_ESCAPE_CHAR = '\\'; + + /// Builds a [Predicate] for the given [SearchOperator] against the provided field expression. + /// + /// - EQ / NEQ use `LOWER(field) = LOWER(value)` to leverage functional btree indexes. + /// - CONTAINS / NOT_CONTAINS / STARTS_WITH / ENDS_WITH use `ILIKE` to leverage GIN + /// trigram indexes and avoid redundant client-side lowercasing. + /// - GT / GTE / LT / LTE cast the field to `NUMERIC` before comparing. + /// + /// @param cb the JPA criteria builder (must be a [HibernateCriteriaBuilder] at runtime) + /// @param field the expression to filter on + /// @param operator the comparison operator + /// @param value the user-supplied value (not yet escaped or lowercased) + /// @return a [Predicate] representing the comparison + static Predicate buildPredicate( + CriteriaBuilder cb, + Expression field, + SearchOperator operator, + String value) { + if (isNumericOperator(operator)) { + return buildNumericPredicate(cb, field, operator, new BigDecimal(value)); + } + HibernateCriteriaBuilder hcb = (HibernateCriteriaBuilder) cb; + Expression stringField = field.as(String.class); + return switch (operator) { + case EQ -> cb.equal(cb.lower(stringField), value.toLowerCase()); + case NEQ -> cb.notEqual(cb.lower(stringField), value.toLowerCase()); + case CONTAINS -> { + String escaped = escapeLikeWildcards(value); + yield hcb.ilike(stringField, "%" + escaped + "%", LIKE_ESCAPE_CHAR); + } + case NOT_CONTAINS -> { + String escaped = escapeLikeWildcards(value); + yield hcb.notIlike(stringField, "%" + escaped + "%", LIKE_ESCAPE_CHAR); + } + case STARTS_WITH -> { + String escaped = escapeLikeWildcards(value); + yield hcb.ilike(stringField, escaped + "%", LIKE_ESCAPE_CHAR); + } + case ENDS_WITH -> { + String escaped = escapeLikeWildcards(value); + yield hcb.ilike(stringField, "%" + escaped, LIKE_ESCAPE_CHAR); + } + default -> throw new IllegalStateException("Unhandled operator: " + operator); + }; + } + + static boolean isNumericOperator(SearchOperator operator) { + return switch (operator) { + case GT, GTE, LT, LTE -> true; + default -> false; + }; + } + + static Predicate buildNumericPredicate( + CriteriaBuilder cb, + Expression field, + SearchOperator operator, + BigDecimal numericValue) { + // Explicit SQL CAST(field AS NUMERIC): the property value column is VARCHAR; without + // this cast PostgreSQL would reject the comparison with a numeric literal. + Expression numericField = + ((HibernateCriteriaBuilder) cb).cast( + (org.hibernate.query.criteria.JpaExpression) field, BigDecimal.class); + return switch (operator) { + case GT -> cb.greaterThan(numericField, numericValue); + case GTE -> cb.greaterThanOrEqualTo(numericField, numericValue); + case LT -> cb.lessThan(numericField, numericValue); + case LTE -> cb.lessThanOrEqualTo(numericField, numericValue); + default -> throw new IllegalStateException("Not a numeric operator: " + operator); + }; + } + + /// Escapes SQL LIKE wildcards (`%` and `_`) in the given value so they are treated as + /// literal characters rather than pattern metacharacters. + /// + /// Used by all ILIKE-based operators. The value does not need to be pre-lowercased + /// because `ILIKE` handles case-insensitivity natively. + static String escapeLikeWildcards(String value) { + return value + .replace(String.valueOf(LIKE_ESCAPE_CHAR), LIKE_ESCAPE_CHAR + String.valueOf(LIKE_ESCAPE_CHAR)) + .replace("%", LIKE_ESCAPE_CHAR + "%") + .replace("_", LIKE_ESCAPE_CHAR + "_"); + } +} diff --git a/src/main/resources/db/migration/V3_5__add_search_performance_indexes.sql b/src/main/resources/db/migration/V3_5__add_search_performance_indexes.sql new file mode 100644 index 00000000..21aa28ef --- /dev/null +++ b/src/main/resources/db/migration/V3_5__add_search_performance_indexes.sql @@ -0,0 +1,75 @@ +-- Flyway migration script: add search performance indexes +-- Purpose: Accelerate the search endpoint with GIN trigram indexes (ILIKE pattern matching) +-- and functional btree indexes (EQ/NEQ equality matching). +-- +-- Strategy: +-- - GIN trigram indexes (public.gin_trgm_ops) on raw columns → ILIKE with CONTAINS, ENDS_WITH, +-- STARTS_WITH, NOT_CONTAINS operators and globalTextSearch. Operator class is schema-qualified +-- because the application connection uses search_path = idp_core which does not include public. +-- - Functional btree lower(col) indexes → EQ / NEQ comparisons using LOWER(col) +-- - Btree indexes on relation columns → exact equality lookups in EXISTS subqueries +-- - The pg_trgm extension is managed by infrastructure — no CREATE EXTENSION here. + +-- ========================================================================= +-- Relation Indexes +-- ========================================================================= + +-- Exact equality on relation name (used in all relation EXISTS subqueries) +CREATE INDEX idx_relation_name + ON relation (name); + +COMMENT ON INDEX idx_relation_name IS 'Supports exact relation name equality in EXISTS subqueries'; + +-- Reverse-relation lookup: target entity identifier in relationsAsTargetSpec +CREATE INDEX idx_relation_target_entities_identifier + ON relation_target_entities (target_entity_identifier); + +COMMENT ON INDEX idx_relation_target_entities_identifier IS 'Supports reverse relation lookups by target entity identifier'; + +-- GIN trigram index for ILIKE-based relation name searches (CONTAINS, STARTS_WITH, ENDS_WITH) +CREATE INDEX idx_relation_name_trgm + ON relation USING GIN (name public.gin_trgm_ops); + +COMMENT ON INDEX idx_relation_name_trgm IS 'GIN trigram index for ILIKE pattern matching on relation name'; + +-- ========================================================================= +-- Entity Indexes +-- ========================================================================= + +-- Functional btree indexes for EQ / NEQ which use LOWER(col) +CREATE INDEX idx_entity_name_lower + ON entity (lower(name)); + +CREATE INDEX idx_entity_identifier_lower + ON entity (lower(identifier)); + +CREATE INDEX idx_entity_template_identifier_lower + ON entity (lower(template_identifier)); + +COMMENT ON INDEX idx_entity_name_lower IS 'Supports LOWER(name) comparisons for EQ and NEQ operators'; +COMMENT ON INDEX idx_entity_identifier_lower IS 'Supports LOWER(identifier) comparisons for EQ and NEQ operators'; +COMMENT ON INDEX idx_entity_template_identifier_lower IS 'Supports LOWER(template_identifier) comparisons for EQ and NEQ operators'; + +-- GIN trigram indexes for ILIKE-based entity field searches +CREATE INDEX idx_entity_name_trgm + ON entity USING GIN (name public.gin_trgm_ops); + +CREATE INDEX idx_entity_identifier_trgm + ON entity USING GIN (identifier public.gin_trgm_ops); + +CREATE INDEX idx_entity_template_identifier_trgm + ON entity USING GIN (template_identifier public.gin_trgm_ops); + +COMMENT ON INDEX idx_entity_name_trgm IS 'GIN trigram index for ILIKE pattern matching on entity name'; +COMMENT ON INDEX idx_entity_identifier_trgm IS 'GIN trigram index for ILIKE pattern matching on entity identifier'; +COMMENT ON INDEX idx_entity_template_identifier_trgm IS 'GIN trigram index for ILIKE pattern matching on entity template identifier'; + +-- ========================================================================= +-- Property Indexes +-- ========================================================================= + +-- GIN trigram index for ILIKE-based property value searches +CREATE INDEX idx_property_value_trgm + ON property USING GIN (value public.gin_trgm_ops); + +COMMENT ON INDEX idx_property_value_trgm IS 'GIN trigram index for ILIKE pattern matching on property value'; diff --git a/src/test/java/com/decathlon/idp_core/AbstractIntegrationTest.java b/src/test/java/com/decathlon/idp_core/AbstractIntegrationTest.java index ff890dd7..8a7adc13 100644 --- a/src/test/java/com/decathlon/idp_core/AbstractIntegrationTest.java +++ b/src/test/java/com/decathlon/idp_core/AbstractIntegrationTest.java @@ -92,7 +92,7 @@ protected AbstractIntegrationTest() { @SuppressWarnings("rawtypes") private static final JdbcDatabaseContainer postgres = new PostgreSQLContainer( "postgres:18-alpine").withDatabaseName("idp-core").withUsername("idp-core") - .withPassword("idp-core"); + .withPassword("idp-core").withInitScript("db/init/init-extensions.sql"); @DynamicPropertySource static void postgresProperties(DynamicPropertyRegistry registry) { @@ -157,6 +157,7 @@ private static HttpRequest getRequestDefinition(HttpMethod httpMethod, String pa return requestDefinition; } + @SneakyThrows public static String getJsonTestFileContent(String path) { try (var inputStream = new ClassPathResource(path).getInputStream()) { diff --git a/src/test/java/com/decathlon/idp_core/domain/service/EntityQueryParserServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/EntityQueryParserServiceTest.java deleted file mode 100644 index 24477373..00000000 --- a/src/test/java/com/decathlon/idp_core/domain/service/EntityQueryParserServiceTest.java +++ /dev/null @@ -1,522 +0,0 @@ -package com.decathlon.idp_core.domain.service; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import java.util.stream.Stream; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; -import org.junit.jupiter.params.provider.ValueSource; - -import com.decathlon.idp_core.domain.constant.ValidationMessages; -import com.decathlon.idp_core.domain.exception.InvalidQueryDslException; -import com.decathlon.idp_core.domain.model.entity.EntityFilter; -import com.decathlon.idp_core.domain.model.entity.FilterCriterion; -import com.decathlon.idp_core.domain.model.enums.FilterKeyType; -import com.decathlon.idp_core.domain.model.enums.FilterOperator; - -@DisplayName("EntityQueryParserService") -@SuppressWarnings("java:S2187") -class EntityQueryParserServiceTest { - - private final EntityQueryParserService parser = new EntityQueryParserService(); - - private void assertSingleCriterion(EntityFilter result, FilterKeyType expectedKeyType, - String expectedKeyName, FilterOperator expectedOperator, String expectedValue) { - assertThat(result.criteria()).hasSize(1); - assertCriterion(result.criteria().getFirst(), expectedKeyType, expectedKeyName, - expectedOperator, expectedValue); - } - - private void assertCriterion(FilterCriterion criterion, FilterKeyType expectedKeyType, - String expectedKeyName, FilterOperator expectedOperator, String expectedValue) { - assertThat(criterion.keyType()).isEqualTo(expectedKeyType); - assertThat(criterion.key()).isEqualTo(expectedKeyName); - assertThat(criterion.operator()).isEqualTo(expectedOperator); - assertThat(criterion.value()).isEqualTo(expectedValue); - } - - @Nested - @DisplayName("Attribute filters") - class AttributeFilterTests { - - @Test - @DisplayName("identifier equals") - void parse_attributeIdentifierEquals() { - var result = parser.parse("identifier=web-api-1"); - assertSingleCriterion(result, FilterKeyType.ATTRIBUTE, "identifier", FilterOperator.EQUALS, - "web-api-1"); - } - - @Test - @DisplayName("name contains") - void parse_attributeNameContains() { - var result = parser.parse("name:API"); - assertSingleCriterion(result, FilterKeyType.ATTRIBUTE, "name", FilterOperator.CONTAINS, - "API"); - } - } - - @Nested - @DisplayName("Property filters") - class PropertyFilterTests { - - @Test - @DisplayName("property equals") - void parse_propertyEquals() { - var result = parser.parse("property.language=JAVA"); - assertSingleCriterion(result, FilterKeyType.PROPERTY, "language", FilterOperator.EQUALS, - "JAVA"); - } - - @Test - @DisplayName("property contains") - void parse_propertyContains() { - var result = parser.parse("property.version:1.0"); - assertSingleCriterion(result, FilterKeyType.PROPERTY, "version", FilterOperator.CONTAINS, - "1.0"); - } - - @Test - @DisplayName("property less than") - void parse_propertyLessThan() { - var result = parser.parse("property.port<9000"); - assertSingleCriterion(result, FilterKeyType.PROPERTY, "port", FilterOperator.LESS_THAN, - "9000"); - } - - @Test - @DisplayName("property greater than") - void parse_propertyGreaterThan() { - var result = parser.parse("property.port>1000"); - assertSingleCriterion(result, FilterKeyType.PROPERTY, "port", FilterOperator.GREATER_THAN, - "1000"); - } - } - - @Nested - @DisplayName("Relation name filters") - class RelationNameFilterTests { - - @Test - @DisplayName("relation name equals") - void parse_relationNameEquals() { - var result = parser.parse("relation=api-link"); - assertSingleCriterion(result, FilterKeyType.RELATION_NAME, "", FilterOperator.EQUALS, - "api-link"); - } - - @Test - @DisplayName("relation name contains") - void parse_relationNameContains() { - var result = parser.parse("relation:rover"); - assertSingleCriterion(result, FilterKeyType.RELATION_NAME, "", FilterOperator.CONTAINS, - "rover"); - } - } - - @Nested - @DisplayName("Relation entity filters") - class RelationEntityFilterTests { - - @Test - @DisplayName("relation entity equals") - void parse_relationEntityEquals() { - var result = parser.parse("relation.database=my-db"); - assertSingleCriterion(result, FilterKeyType.RELATION_ENTITY, "database", - FilterOperator.EQUALS, "my-db"); - } - - @Test - @DisplayName("relation entity contains") - void parse_relationEntityContains() { - var result = parser.parse("relation.database:my"); - assertSingleCriterion(result, FilterKeyType.RELATION_ENTITY, "database", - FilterOperator.CONTAINS, "my"); - } - } - - @Nested - @DisplayName("Relation property filters") - class RelationPropertyFilterTests { - - @Test - @DisplayName("relation property equals") - void parse_relationPropertyEquals() { - var result = parser.parse("relation.api-link.identifier=microservice-1"); - assertSingleCriterion(result, FilterKeyType.RELATION_PROPERTY, "api-link.identifier", - FilterOperator.EQUALS, "microservice-1"); - } - - @Test - @DisplayName("relation property contains") - void parse_relationPropertyContains() { - var result = parser.parse("relation.api-link.name:microservice"); - assertSingleCriterion(result, FilterKeyType.RELATION_PROPERTY, "api-link.name", - FilterOperator.CONTAINS, "microservice"); - } - - @Test - @DisplayName("throws InvalidQueryDslException for unsupported property in relation (custom-prop is not identifier or name)") - void parse_relationPropertyUnsupported_throwsException() { - assertThatThrownBy(() -> parser.parse("relation.my-link.custom-prop=value")) - .isInstanceOf(InvalidQueryDslException.class).hasMessageContaining("custom-prop") - .hasMessageContaining("identifier").hasMessageContaining("name"); - } - } - - @Nested - @DisplayName("Relations as target filters") - class RelationsAsTargetFilterTests { - - @Test - @DisplayName("relations_as_target name equals") - void parse_relationsAsTargetNameEquals() { - var result = parser.parse("relations_as_target=api-link"); - assertSingleCriterion(result, FilterKeyType.RELATIONS_AS_TARGET_NAME, "", - FilterOperator.EQUALS, "api-link"); - } - - @Test - @DisplayName("relations_as_target name contains") - void parse_relationsAsTargetNameContains() { - var result = parser.parse("relations_as_target:rover"); - assertSingleCriterion(result, FilterKeyType.RELATIONS_AS_TARGET_NAME, "", - FilterOperator.CONTAINS, "rover"); - } - - @Test - @DisplayName("relations_as_target property identifier equals") - void parse_relationsAsTargetPropertyIdentifierEquals() { - var result = parser.parse("relations_as_target.api-link.identifier=web-api-1"); - assertSingleCriterion(result, FilterKeyType.RELATIONS_AS_TARGET_PROPERTY, - "api-link.identifier", FilterOperator.EQUALS, "web-api-1"); - } - - @Test - @DisplayName("relations_as_target property name contains") - void parse_relationsAsTargetPropertyNameContains() { - var result = parser.parse("relations_as_target.api-link.name:microservice"); - assertSingleCriterion(result, FilterKeyType.RELATIONS_AS_TARGET_PROPERTY, "api-link.name", - FilterOperator.CONTAINS, "microservice"); - } - - @Test - @DisplayName("throws exception for unsupported property in relations_as_target") - void parse_relationsAsTargetInvalidProperty_throwsException() { - assertThatThrownBy(() -> parser.parse("relations_as_target.api-link.language=JAVA")) - .isInstanceOf(InvalidQueryDslException.class) - .hasMessageContaining("only 'identifier' and 'name' are supported"); - } - - @Test - @DisplayName("throws exception for relations_as_target without property") - void parse_relationsAsTargetWithoutProperty_throwsException() { - assertThatThrownBy(() -> parser.parse("relations_as_target.api-link=web-api-1")) - .isInstanceOf(InvalidQueryDslException.class) - .hasMessageContaining("relations_as_target requires the form"); - } - } - - @Nested - @DisplayName("Combined AND criteria") - class CombinedCriteriaTests { - - @Test - @DisplayName("two criteria separated by semicolon") - void parse_twoCriteriaWithSemicolon() { - var result = parser.parse("name:API;property.language=JAVA"); - assertThat(result.criteria()).hasSize(2); - assertCriterion(result.criteria().get(0), FilterKeyType.ATTRIBUTE, "name", - FilterOperator.CONTAINS, "API"); - assertCriterion(result.criteria().get(1), FilterKeyType.PROPERTY, "language", - FilterOperator.EQUALS, "JAVA"); - } - - @Test - @DisplayName("four criteria of different key types") - void parse_fourCriteria() { - var result = parser.parse( - "name:API;property.language=JAVA;relation.database=my-db;relation.api-link.identifier=service-1"); - assertThat(result.criteria()).hasSize(4); - assertCriterion(result.criteria().get(0), FilterKeyType.ATTRIBUTE, "name", - FilterOperator.CONTAINS, "API"); - assertCriterion(result.criteria().get(1), FilterKeyType.PROPERTY, "language", - FilterOperator.EQUALS, "JAVA"); - assertCriterion(result.criteria().get(2), FilterKeyType.RELATION_ENTITY, "database", - FilterOperator.EQUALS, "my-db"); - assertCriterion(result.criteria().get(3), FilterKeyType.RELATION_PROPERTY, - "api-link.identifier", FilterOperator.EQUALS, "service-1"); - } - - @Test - @DisplayName("five criteria including relation property and reverse relation") - void parse_fiveCriteriaWithRelationProperty() { - var result = parser.parse( - "name:API;property.language=JAVA;relation.database=my-db;relation.api-link.identifier=service-1;relations_as_target.owned_by.name:platform"); - assertThat(result.criteria()).hasSize(5); - assertCriterion(result.criteria().get(0), FilterKeyType.ATTRIBUTE, "name", - FilterOperator.CONTAINS, "API"); - assertCriterion(result.criteria().get(1), FilterKeyType.PROPERTY, "language", - FilterOperator.EQUALS, "JAVA"); - assertCriterion(result.criteria().get(2), FilterKeyType.RELATION_ENTITY, "database", - FilterOperator.EQUALS, "my-db"); - assertCriterion(result.criteria().get(3), FilterKeyType.RELATION_PROPERTY, - "api-link.identifier", FilterOperator.EQUALS, "service-1"); - assertCriterion(result.criteria().get(4), FilterKeyType.RELATIONS_AS_TARGET_PROPERTY, - "owned_by.name", FilterOperator.CONTAINS, "platform"); - } - } - - @Nested - @DisplayName("Invalid query syntax") - class InvalidQueryTests { - - @ParameterizedTest(name = "missing operator in: ''{0}''") - @ValueSource(strings = {"noOperatorHere", "property.lang", "relation.db"}) - @DisplayName("throws InvalidQueryDslException when operator is missing") - void parse_missingOperator_throwsException(String query) { - assertThatThrownBy(() -> parser.parse(query)).isInstanceOf(InvalidQueryDslException.class) - .hasMessage(ValidationMessages.FILTER_INVALID_FORMAT); - } - - @Test - @DisplayName("throws InvalidQueryDslException for unknown attribute") - void parse_unknownAttribute_throwsException() { - assertThatThrownBy(() -> parser.parse("unknownField=value")) - .isInstanceOf(InvalidQueryDslException.class).hasMessageContaining("Unknown attribute"); - } - - @Test - @DisplayName("throws InvalidQueryDslException for blank value") - void parse_blankValue_throwsException() { - assertThatThrownBy(() -> parser.parse("name=")).isInstanceOf(InvalidQueryDslException.class) - .hasMessageContaining("value must not be blank"); - } - - @Test - @DisplayName("throws InvalidQueryDslException for blank key") - void parse_blankKey_throwsException() { - assertThatThrownBy(() -> parser.parse("=value")).isInstanceOf(InvalidQueryDslException.class) - .hasMessageContaining("key must not be blank"); - } - - @Test - @DisplayName("throws InvalidQueryDslException for blank property name after prefix") - void parse_blankPropertyName_throwsException() { - assertThatThrownBy(() -> parser.parse("property.=JAVA")) - .isInstanceOf(InvalidQueryDslException.class) - .hasMessageContaining("key name must not be blank"); - } - } - - @Nested - @DisplayName("Security constraints") - class SecurityConstraintTests { - - @Test - @DisplayName("throws InvalidQueryDslException when criteria count exceeds limit") - void parse_tooManyCriteria_throwsException() { - var query = "property.a=1;property.b=2;property.c=3;property.d=4;property.e=5;" - + "property.f=6;property.g=7;property.h=8;property.i=9;property.j=10;" + "property.k=11"; - assertThatThrownBy(() -> parser.parse(query)).isInstanceOf(InvalidQueryDslException.class) - .hasMessageContaining( - "maximum of %d".formatted(EntityQueryParserService.MAX_CRITERIA_COUNT)); - } - - @Test - @DisplayName("accepts exactly the maximum number of criteria") - void parse_exactlyMaxCriteria_succeeds() { - var query = "property.a=1;property.b=2;property.c=3;property.d=4;property.e=5;" - + "property.f=6;property.g=7;property.h=8;property.i=9;property.j=10"; - var result = parser.parse(query); - assertThat(result.criteria()).hasSize(EntityQueryParserService.MAX_CRITERIA_COUNT); - } - - @Test - @DisplayName("throws InvalidQueryDslException when value exceeds max length") - void parse_valueTooLong_throwsException() { - var longValue = "a".repeat(EntityQueryParserService.MAX_KEY_VALUE_LENGTH + 1); - assertThatThrownBy(() -> parser.parse("name=" + longValue)) - .isInstanceOf(InvalidQueryDslException.class).hasMessageContaining( - "must not exceed %d".formatted(EntityQueryParserService.MAX_KEY_VALUE_LENGTH)); - } - - @Test - @DisplayName("throws InvalidQueryDslException when key exceeds max length") - void parse_keyTooLong_throwsException() { - var longKey = "property." + "a".repeat(EntityQueryParserService.MAX_KEY_VALUE_LENGTH); - assertThatThrownBy(() -> parser.parse(longKey + "=value")) - .isInstanceOf(InvalidQueryDslException.class).hasMessageContaining( - "must not exceed %d".formatted(EntityQueryParserService.MAX_KEY_VALUE_LENGTH)); - } - - @ParameterizedTest(name = "valid key name: ''{0}''") - @ValueSource(strings = {"property.language=JAVA", "property.my-key=value", - "property.my_key=value", "property.key123=value", "property.lang@ge=JAVA", - "property.my key=JAVA", "property.lang/age=JAVA", "relation.database=my-db", - "relation.db$name=my-db", "relation.my-cache.identifier=redis-1"}) - @DisplayName("accepts valid key name characters") - void parse_validKeyNameChars_succeeds(String query) { - var result = parser.parse(query); - assertThat(result.criteria()).hasSize(1); - } - } - - @Nested - @DisplayName("Duplicate criterion detection") - class DuplicateCriterionTests { - - @ParameterizedTest(name = "duplicate criterion in: ''{0}''") - @ValueSource(strings = {"name=A;name=B", "property.language=JAVA;property.language=PYTHON", - "relation=api-link;relation=database"}) - @DisplayName("throws InvalidQueryDslException for duplicate criteria") - void parse_duplicateCriterion_throwsException(String query) { - assertThatThrownBy(() -> parser.parse(query)).isInstanceOf(InvalidQueryDslException.class) - .hasMessage(ValidationMessages.FILTER_DUPLICATE_CRITERION); - } - - @Test - @DisplayName("accepts distinct attribute criteria") - void parse_distinctAttributeCriteria_succeeds() { - var result = parser.parse("identifier=web-api-1;name=Web API 1"); - assertThat(result.criteria()).hasSize(2); - } - - @Test - @DisplayName("accepts distinct property criteria") - void parse_distinctPropertyCriteria_succeeds() { - var result = parser.parse("property.language=JAVA;property.environment=PROD"); - assertThat(result.criteria()).hasSize(2); - } - } - - @Nested - @DisplayName("Type mismatch validation") - class TypeMismatchTests { - - @ParameterizedTest(name = "comparison operator on: ''{0}''") - @ValueSource(strings = {"relationapi-link"}) - @DisplayName("throws InvalidQueryDslException for less/greater than on relation name") - void parse_comparisonOnRelationName_throwsException(String query) { - assertThatThrownBy(() -> parser.parse(query)).isInstanceOf(InvalidQueryDslException.class) - .hasMessageContaining("is not applicable for field"); - } - - @ParameterizedTest(name = "comparison operator on: ''{0}''") - @ValueSource(strings = {"relation.databasemy-db"}) - @DisplayName("throws InvalidQueryDslException for less/greater than on relation entity") - void parse_comparisonOnRelationEntity_throwsException(String query) { - assertThatThrownBy(() -> parser.parse(query)).isInstanceOf(InvalidQueryDslException.class) - .hasMessageContaining("is not applicable for field"); - } - - @ParameterizedTest(name = "comparison operator on: ''{0}''") - @ValueSource(strings = {"relation.database.templatepostgresql"}) - @DisplayName("throws InvalidQueryDslException for unsupported property on relation (template is not a valid relation property)") - void parse_comparisonOnRelationTemplate_throwsException(String query) { - assertThatThrownBy(() -> parser.parse(query)).isInstanceOf(InvalidQueryDslException.class) - .hasMessageContaining("template"); - } - - @Test - @DisplayName("throws InvalidQueryDslException for unsupported property on relation with equals operator") - void parse_equalsOnRelationTemplate_throwsException() { - assertThatThrownBy(() -> parser.parse("relation.database.template=postgresql")) - .isInstanceOf(InvalidQueryDslException.class).hasMessageContaining("template") - .hasMessageContaining("identifier").hasMessageContaining("name"); - } - - @ParameterizedTest(name = "comparison operator on: ''{0}''") - @ValueSource(strings = {"relation.api-link.identifiermicroservice-1"}) - @DisplayName("throws InvalidQueryDslException for less/greater than on relation property") - void parse_comparisonOnRelationProperty_throwsException(String query) { - assertThatThrownBy(() -> parser.parse(query)).isInstanceOf(InvalidQueryDslException.class) - .hasMessageContaining("is not applicable for field"); - } - - @ParameterizedTest(name = "comparison operator on: ''{0}''") - @ValueSource(strings = {"relations_as_target.api-link.namemicroservice"}) - @DisplayName("throws InvalidQueryDslException for less/greater than on relations_as_target property") - void parse_comparisonOnRelationsAsTargetProperty_throwsException(String query) { - assertThatThrownBy(() -> parser.parse(query)).isInstanceOf(InvalidQueryDslException.class) - .hasMessageContaining("is not applicable for field"); - } - - @ParameterizedTest(name = "comparison operator on: ''{0}''") - @ValueSource(strings = {"nameA", "identifier parser.parse(query)).isInstanceOf(InvalidQueryDslException.class) - .hasMessageContaining("is not applicable for field"); - } - - @ParameterizedTest(name = "comparison operator on: ''{0}''") - @ValueSource(strings = {"property.port<9000", "property.port>1000"}) - @DisplayName("accepts less/greater than on NUMBER properties (type check is deferred to EntityService)") - void parse_comparisonOnProperty_succeeds(String query) { - var result = parser.parse(query); - assertThat(result.criteria()).hasSize(1); - } - } - - @Nested - @DisplayName("Edge cases") - class EdgeCaseTests { - - @Test - @DisplayName("consecutive semicolons produce empty filter") - void parse_consecutiveSemicolons_ignoresEmptyTokens() { - var result = parser.parse("name=API;;property.lang=JAVA"); - assertThat(result.criteria()).hasSize(2); - } - - @Test - @DisplayName("trailing semicolon is ignored") - void parse_trailingSemicolon_ignored() { - var result = parser.parse("name=API;"); - assertThat(result.criteria()).hasSize(1); - } - - @Test - @DisplayName("leading semicolon is ignored") - void parse_leadingSemicolon_ignored() { - var result = parser.parse(";name=API"); - assertThat(result.criteria()).hasSize(1); - } - - @Test - @DisplayName("values containing SQL LIKE wildcards are accepted") - void parse_valuesWithLikeWildcards_accepted() { - var result = parser.parse("name:100%_success"); - assertSingleCriterion(result, FilterKeyType.ATTRIBUTE, "name", FilterOperator.CONTAINS, - "100%_success"); - } - } - - @Nested - @DisplayName("Null or blank query") - class NullOrBlankQueryTests { - - @ParameterizedTest(name = "returns empty filter for: {0}") - @MethodSource("provideNullOrBlankQueries") - @DisplayName("parse(null/empty/blank) returns empty filter with no criteria") - void parse_nullOrBlankQuery_returnsEmptyFilter(String query) { - var result = parser.parse(query); - assertThat(result.criteria()).isEmpty(); - } - - private static Stream provideNullOrBlankQueries() { - return Stream.of(Arguments.of((String) null), Arguments.of(""), Arguments.of(" ")); - } - } - -} diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java index 747c7605..6c5312ee 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java @@ -24,17 +24,22 @@ import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; +import com.decathlon.idp_core.domain.constant.SearchConstraints; import com.decathlon.idp_core.domain.constant.ValidationMessages; import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.search.InvalidSearchQueryException; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntityFilter; import com.decathlon.idp_core.domain.model.entity.EntitySummary; +import com.decathlon.idp_core.domain.model.entity.SearchFilterNode; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; +import com.decathlon.idp_core.domain.model.enums.LogicalConnector; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; -import com.decathlon.idp_core.domain.service.EntityQueryParserService; +import com.decathlon.idp_core.domain.service.filter.EntityFilterDslParser; +import com.decathlon.idp_core.domain.service.search.SearchFilterValidationService; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; @@ -55,7 +60,10 @@ class EntityServiceTest { private EntityTemplateService entityTemplateService; @Mock - private EntityQueryParserService entityQueryParserService; + private EntityFilterDslParser entityFilterDslParser; + + @Mock + private SearchFilterValidationService searchFilterValidationService; @InjectMocks private EntityService entityService; @@ -77,7 +85,7 @@ void shouldReturnEntitiesByTemplateIdentifier() { assertSame(page, result); verify(entityTemplateService).getEntityTemplateByIdentifier("template-a"); - verify(entityQueryParserService).validateFilterPropertyTypes(EntityFilter.empty(), template); + verify(entityFilterDslParser).validateFilterPropertyTypes(EntityFilter.empty(), template); verify(entityRepository).findByTemplateIdentifierWithFilter("template-a", EntityFilter.empty(), pageable); } @@ -256,4 +264,83 @@ private Entity entity(String templateIdentifier, String identifier, String name) return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, List.of(), List.of()); } + + private SearchFilterNode emptyFilter() { + return new SearchFilterNode.Group(LogicalConnector.AND, List.of()); + } + + @Test + @DisplayName("Should search entities with valid parameters") + void shouldSearchEntitiesWithValidParameters() { + var filter = emptyFilter(); + var page = new PageImpl<>(List.of(entity("tmpl", "ent-a", "Entity A"))); + when(entityRepository.search(filter, "api", Pageable.ofSize(20))).thenReturn(page); + + var result = entityService.searchEntities(filter, "api", 0, 20, null); + + assertSame(page, result); + verify(searchFilterValidationService).validate(filter, "api"); + } + + @Test + @DisplayName("Should search entities with valid sort") + void shouldSearchEntitiesWithValidSort() { + var filter = emptyFilter(); + var page = new PageImpl<>(List.of(entity("tmpl", "ent-a", "Entity A"))); + when(entityRepository.search(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any())).thenReturn(page); + + var result = entityService.searchEntities(filter, null, 0, 10, "identifier:asc"); + + assertSame(page, result); + } + + @Test + @DisplayName("Should reject page size exceeding maximum") + void shouldRejectPageSizeExceedingMaximum() { + var filter = emptyFilter(); + assertThrows(InvalidSearchQueryException.class, + () -> entityService.searchEntities(filter, null, 0, SearchConstraints.MAX_PAGE_SIZE + 1, + null)); + } + + @Test + @DisplayName("Should reject negative page index") + void shouldRejectNegativePageIndex() { + var filter = emptyFilter(); + assertThrows(InvalidSearchQueryException.class, + () -> entityService.searchEntities(filter, null, -1, 20, null)); + } + + @Test + @DisplayName("Should reject non-positive page size") + void shouldRejectNonPositivePageSize() { + var filter = emptyFilter(); + assertThrows(InvalidSearchQueryException.class, + () -> entityService.searchEntities(filter, null, 0, 0, null)); + } + + @Test + @DisplayName("Should reject invalid sort field") + void shouldRejectInvalidSortField() { + var filter = emptyFilter(); + assertThrows(InvalidSearchQueryException.class, + () -> entityService.searchEntities(filter, null, 0, 20, "badField:asc")); + } + + @Test + @DisplayName("Should reject invalid sort direction") + void shouldRejectInvalidSortDirection() { + var filter = emptyFilter(); + assertThrows(InvalidSearchQueryException.class, + () -> entityService.searchEntities(filter, null, 0, 20, "identifier:zzz")); + } + + @Test + @DisplayName("Should reject extra sort expression segments") + void shouldRejectExtraSortSegments() { + var filter = emptyFilter(); + assertThrows(InvalidSearchQueryException.class, + () -> entityService.searchEntities(filter, null, 0, 20, "identifier:asc:extra")); + } } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/filter/EntityFilterDslParserTest.java b/src/test/java/com/decathlon/idp_core/domain/service/filter/EntityFilterDslParserTest.java new file mode 100644 index 00000000..befb47c2 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/filter/EntityFilterDslParserTest.java @@ -0,0 +1,528 @@ +package com.decathlon.idp_core.domain.service.filter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.stream.Stream; + +import com.decathlon.idp_core.domain.exception.filter.InvalidFilterDslException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +import com.decathlon.idp_core.domain.constant.ValidationMessages; +import com.decathlon.idp_core.domain.constant.FilterConstraints; +import com.decathlon.idp_core.domain.model.entity.EntityFilter; +import com.decathlon.idp_core.domain.model.entity.FilterCriterion; +import com.decathlon.idp_core.domain.model.enums.FilterKeyType; +import com.decathlon.idp_core.domain.model.enums.FilterOperator; + +@DisplayName("EntityFilterDslParser") +@SuppressWarnings("java:S2187") +class EntityFilterDslParserTest { + + private final EntityFilterDslParser parser = new EntityFilterDslParser(); + + private void assertSingleCriterion( + EntityFilter result, + FilterKeyType expectedKeyType, + String expectedKeyName, + FilterOperator expectedOperator, + String expectedValue) { + assertThat(result.criteria()).hasSize(1); + assertCriterion(result.criteria().getFirst(), expectedKeyType, expectedKeyName, expectedOperator, expectedValue); + } + + private void assertCriterion( + FilterCriterion criterion, + FilterKeyType expectedKeyType, + String expectedKeyName, + FilterOperator expectedOperator, + String expectedValue) { + assertThat(criterion.keyType()).isEqualTo(expectedKeyType); + assertThat(criterion.key()).isEqualTo(expectedKeyName); + assertThat(criterion.operator()).isEqualTo(expectedOperator); + assertThat(criterion.value()).isEqualTo(expectedValue); + } + + @Nested + @DisplayName("Attribute filters") + class AttributeFilterTests { + + @Test + @DisplayName("identifier equals") + void parse_attributeIdentifierEquals() { + var result = parser.parse("identifier=web-api-1"); + assertSingleCriterion(result, FilterKeyType.ATTRIBUTE, "identifier", FilterOperator.EQUALS, "web-api-1"); + } + + @Test + @DisplayName("name contains") + void parse_attributeNameContains() { + var result = parser.parse("name:API"); + assertSingleCriterion(result, FilterKeyType.ATTRIBUTE, "name", FilterOperator.CONTAINS, "API"); + } + } + + @Nested + @DisplayName("Property filters") + class PropertyFilterTests { + + @Test + @DisplayName("property equals") + void parse_propertyEquals() { + var result = parser.parse("property.language=JAVA"); + assertSingleCriterion(result, FilterKeyType.PROPERTY, "language", FilterOperator.EQUALS, "JAVA"); + } + + @Test + @DisplayName("property contains") + void parse_propertyContains() { + var result = parser.parse("property.version:1.0"); + assertSingleCriterion(result, FilterKeyType.PROPERTY, "version", FilterOperator.CONTAINS, "1.0"); + } + + @Test + @DisplayName("property less than") + void parse_propertyLessThan() { + var result = parser.parse("property.port<9000"); + assertSingleCriterion(result, FilterKeyType.PROPERTY, "port", FilterOperator.LESS_THAN, "9000"); + } + + @Test + @DisplayName("property greater than") + void parse_propertyGreaterThan() { + var result = parser.parse("property.port>1000"); + assertSingleCriterion(result, FilterKeyType.PROPERTY, "port", FilterOperator.GREATER_THAN, "1000"); + } + } + + @Nested + @DisplayName("Relation name filters") + class RelationNameFilterTests { + + @Test + @DisplayName("relation name equals") + void parse_relationNameEquals() { + var result = parser.parse("relation=api-link"); + assertSingleCriterion(result, FilterKeyType.RELATION_NAME, "", FilterOperator.EQUALS, "api-link"); + } + + @Test + @DisplayName("relation name contains") + void parse_relationNameContains() { + var result = parser.parse("relation:rover"); + assertSingleCriterion(result, FilterKeyType.RELATION_NAME, "", FilterOperator.CONTAINS, "rover"); + } + } + + @Nested + @DisplayName("Relation entity filters") + class RelationEntityFilterTests { + + @Test + @DisplayName("relation entity equals") + void parse_relationEntityEquals() { + var result = parser.parse("relation.database=my-db"); + assertSingleCriterion(result, FilterKeyType.RELATION_ENTITY, "database", FilterOperator.EQUALS, "my-db"); + } + + @Test + @DisplayName("relation entity contains") + void parse_relationEntityContains() { + var result = parser.parse("relation.database:my"); + assertSingleCriterion(result, FilterKeyType.RELATION_ENTITY, "database", FilterOperator.CONTAINS, "my"); + } + } + + @Nested + @DisplayName("Relation property filters") + class RelationPropertyFilterTests { + + @Test + @DisplayName("relation property equals") + void parse_relationPropertyEquals() { + var result = parser.parse("relation.api-link.identifier=microservice-1"); + assertSingleCriterion(result, FilterKeyType.RELATION_PROPERTY, "api-link.identifier", FilterOperator.EQUALS, "microservice-1"); + } + + @Test + @DisplayName("relation property contains") + void parse_relationPropertyContains() { + var result = parser.parse("relation.api-link.name:microservice"); + assertSingleCriterion(result, FilterKeyType.RELATION_PROPERTY, "api-link.name", FilterOperator.CONTAINS, "microservice"); + } + + @Test + @DisplayName("throws InvalidFilterDslException for unsupported property in relation (custom-prop is not identifier or name)") + void parse_relationPropertyUnsupported_throwsException() { + assertThatThrownBy(() -> parser.parse("relation.my-link.custom-prop=value")) + .isInstanceOf(InvalidFilterDslException.class) + .hasMessageContaining("custom-prop") + .hasMessageContaining("identifier") + .hasMessageContaining("name"); + } + } + + @Nested + @DisplayName("Relations as target filters") + class RelationsAsTargetFilterTests { + + @Test + @DisplayName("relations_as_target name equals") + void parse_relationsAsTargetNameEquals() { + var result = parser.parse("relations_as_target=api-link"); + assertSingleCriterion(result, FilterKeyType.RELATIONS_AS_TARGET_NAME, "", FilterOperator.EQUALS, "api-link"); + } + + @Test + @DisplayName("relations_as_target name contains") + void parse_relationsAsTargetNameContains() { + var result = parser.parse("relations_as_target:rover"); + assertSingleCriterion(result, FilterKeyType.RELATIONS_AS_TARGET_NAME, "", FilterOperator.CONTAINS, "rover"); + } + + @Test + @DisplayName("relations_as_target property identifier equals") + void parse_relationsAsTargetPropertyIdentifierEquals() { + var result = parser.parse("relations_as_target.api-link.identifier=web-api-1"); + assertSingleCriterion(result, FilterKeyType.RELATIONS_AS_TARGET_PROPERTY, "api-link.identifier", FilterOperator.EQUALS, "web-api-1"); + } + + @Test + @DisplayName("relations_as_target property name contains") + void parse_relationsAsTargetPropertyNameContains() { + var result = parser.parse("relations_as_target.api-link.name:microservice"); + assertSingleCriterion(result, FilterKeyType.RELATIONS_AS_TARGET_PROPERTY, "api-link.name", FilterOperator.CONTAINS, "microservice"); + } + + @Test + @DisplayName("throws exception for unsupported property in relations_as_target") + void parse_relationsAsTargetInvalidProperty_throwsException() { + assertThatThrownBy(() -> parser.parse("relations_as_target.api-link.language=JAVA")) + .isInstanceOf(InvalidFilterDslException.class) + .hasMessageContaining("only 'identifier' and 'name' are supported"); + } + + @Test + @DisplayName("throws exception for relations_as_target without property") + void parse_relationsAsTargetWithoutProperty_throwsException() { + assertThatThrownBy(() -> parser.parse("relations_as_target.api-link=web-api-1")) + .isInstanceOf(InvalidFilterDslException.class) + .hasMessageContaining("relations_as_target requires the form"); + } + } + + @Nested + @DisplayName("Combined AND criteria") + class CombinedCriteriaTests { + + @Test + @DisplayName("two criteria separated by semicolon") + void parse_twoCriteriaWithSemicolon() { + var result = parser.parse("name:API;property.language=JAVA"); + assertThat(result.criteria()).hasSize(2); + assertCriterion(result.criteria().get(0), FilterKeyType.ATTRIBUTE, "name", FilterOperator.CONTAINS, "API"); + assertCriterion(result.criteria().get(1), FilterKeyType.PROPERTY, "language", FilterOperator.EQUALS, "JAVA"); + } + + @Test + @DisplayName("four criteria of different key types") + void parse_fourCriteria() { + var result = parser.parse("name:API;property.language=JAVA;relation.database=my-db;relation.api-link.identifier=service-1"); + assertThat(result.criteria()).hasSize(4); + assertCriterion(result.criteria().get(0), FilterKeyType.ATTRIBUTE, "name", FilterOperator.CONTAINS, "API"); + assertCriterion(result.criteria().get(1), FilterKeyType.PROPERTY, "language", FilterOperator.EQUALS, "JAVA"); + assertCriterion(result.criteria().get(2), FilterKeyType.RELATION_ENTITY, "database", FilterOperator.EQUALS, "my-db"); + assertCriterion(result.criteria().get(3), FilterKeyType.RELATION_PROPERTY, "api-link.identifier", FilterOperator.EQUALS, "service-1"); + } + + @Test + @DisplayName("five criteria including relation property and reverse relation") + void parse_fiveCriteriaWithRelationProperty() { + var result = parser.parse("name:API;property.language=JAVA;relation.database=my-db;relation.api-link.identifier=service-1;relations_as_target.owned_by.name:platform"); + assertThat(result.criteria()).hasSize(5); + assertCriterion(result.criteria().get(0), FilterKeyType.ATTRIBUTE, "name", FilterOperator.CONTAINS, "API"); + assertCriterion(result.criteria().get(1), FilterKeyType.PROPERTY, "language", FilterOperator.EQUALS, "JAVA"); + assertCriterion(result.criteria().get(2), FilterKeyType.RELATION_ENTITY, "database", FilterOperator.EQUALS, "my-db"); + assertCriterion(result.criteria().get(3), FilterKeyType.RELATION_PROPERTY, "api-link.identifier", FilterOperator.EQUALS, "service-1"); + assertCriterion(result.criteria().get(4), FilterKeyType.RELATIONS_AS_TARGET_PROPERTY, "owned_by.name", FilterOperator.CONTAINS, "platform"); + } + } + + @Nested + @DisplayName("Invalid query syntax") + class InvalidQueryTests { + + @ParameterizedTest(name = "missing operator in: ''{0}''") + @ValueSource(strings = {"noOperatorHere", "property.lang", "relation.db"}) + @DisplayName("throws InvalidFilterDslException when operator is missing") + void parse_missingOperator_throwsException(String query) { + assertThatThrownBy(() -> parser.parse(query)) + .isInstanceOf(InvalidFilterDslException.class) + .hasMessage(ValidationMessages.FILTER_INVALID_FORMAT); + } + + @Test + @DisplayName("throws InvalidFilterDslException for unknown attribute") + void parse_unknownAttribute_throwsException() { + assertThatThrownBy(() -> parser.parse("unknownField=value")) + .isInstanceOf(InvalidFilterDslException.class) + .hasMessageContaining("Unknown attribute"); + } + + @Test + @DisplayName("throws InvalidFilterDslException for blank value") + void parse_blankValue_throwsException() { + assertThatThrownBy(() -> parser.parse("name=")) + .isInstanceOf(InvalidFilterDslException.class) + .hasMessageContaining("value must not be blank"); + } + + @Test + @DisplayName("throws InvalidFilterDslException for blank key") + void parse_blankKey_throwsException() { + assertThatThrownBy(() -> parser.parse("=value")) + .isInstanceOf(InvalidFilterDslException.class) + .hasMessageContaining("key must not be blank"); + } + + @Test + @DisplayName("throws InvalidFilterDslException for blank property name after prefix") + void parse_blankPropertyName_throwsException() { + assertThatThrownBy(() -> parser.parse("property.=JAVA")) + .isInstanceOf(InvalidFilterDslException.class) + .hasMessageContaining("key name must not be blank"); + } + } + + @Nested + @DisplayName("Security constraints") + class SecurityConstraintTests { + + @Test + @DisplayName("throws InvalidFilterDslException when criteria count exceeds limit") + void parse_tooManyCriteria_throwsException() { + var query = "property.a=1;property.b=2;property.c=3;property.d=4;property.e=5;" + + "property.f=6;property.g=7;property.h=8;property.i=9;property.j=10;" + + "property.k=11"; + assertThatThrownBy(() -> parser.parse(query)) + .isInstanceOf(InvalidFilterDslException.class) + .hasMessageContaining("maximum of %d".formatted(FilterConstraints.MAX_CRITERIA_COUNT)); + } + + @Test + @DisplayName("accepts exactly the maximum number of criteria") + void parse_exactlyMaxCriteria_succeeds() { + var query = "property.a=1;property.b=2;property.c=3;property.d=4;property.e=5;" + + "property.f=6;property.g=7;property.h=8;property.i=9;property.j=10"; + var result = parser.parse(query); + assertThat(result.criteria()).hasSize(FilterConstraints.MAX_CRITERIA_COUNT); + } + + @Test + @DisplayName("throws InvalidFilterDslException when value exceeds max length") + void parse_valueTooLong_throwsException() { + var longValue = "a".repeat(FilterConstraints.MAX_KEY_VALUE_LENGTH + 1); + assertThatThrownBy(() -> parser.parse("name=" + longValue)) + .isInstanceOf(InvalidFilterDslException.class) + .hasMessageContaining("must not exceed %d".formatted(FilterConstraints.MAX_KEY_VALUE_LENGTH)); + } + + @Test + @DisplayName("throws InvalidFilterDslException when key exceeds max length") + void parse_keyTooLong_throwsException() { + var longKey = "property." + "a".repeat(FilterConstraints.MAX_KEY_VALUE_LENGTH); + assertThatThrownBy(() -> parser.parse(longKey + "=value")) + .isInstanceOf(InvalidFilterDslException.class) + .hasMessageContaining("must not exceed %d".formatted(FilterConstraints.MAX_KEY_VALUE_LENGTH)); + } + + @ParameterizedTest(name = "valid key name: ''{0}''") + @ValueSource(strings = { + "property.language=JAVA", + "property.my-key=value", + "property.my_key=value", + "property.key123=value", + "property.lang@ge=JAVA", + "property.my key=JAVA", + "property.lang/age=JAVA", + "relation.database=my-db", + "relation.db$name=my-db", + "relation.my-cache.identifier=redis-1" + }) + @DisplayName("accepts valid key name characters") + void parse_validKeyNameChars_succeeds(String query) { + var result = parser.parse(query); + assertThat(result.criteria()).hasSize(1); + } + } + + @Nested + @DisplayName("Duplicate criterion detection") + class DuplicateCriterionTests { + + @ParameterizedTest(name = "duplicate criterion in: ''{0}''") + @ValueSource(strings = { + "name=A;name=B", + "property.language=JAVA;property.language=PYTHON", + "relation=api-link;relation=database" + }) + @DisplayName("throws InvalidFilterDslException for duplicate criteria") + void parse_duplicateCriterion_throwsException(String query) { + assertThatThrownBy(() -> parser.parse(query)) + .isInstanceOf(InvalidFilterDslException.class) + .hasMessage(ValidationMessages.FILTER_DUPLICATE_CRITERION); + } + + @Test + @DisplayName("accepts distinct attribute criteria") + void parse_distinctAttributeCriteria_succeeds() { + var result = parser.parse("identifier=web-api-1;name=Web API 1"); + assertThat(result.criteria()).hasSize(2); + } + + @Test + @DisplayName("accepts distinct property criteria") + void parse_distinctPropertyCriteria_succeeds() { + var result = parser.parse("property.language=JAVA;property.environment=PROD"); + assertThat(result.criteria()).hasSize(2); + } + } + + @Nested + @DisplayName("Type mismatch validation") + class TypeMismatchTests { + + @ParameterizedTest(name = "comparison operator on: ''{0}''") + @ValueSource(strings = {"relationapi-link"}) + @DisplayName("throws InvalidFilterDslException for less/greater than on relation name") + void parse_comparisonOnRelationName_throwsException(String query) { + assertThatThrownBy(() -> parser.parse(query)) + .isInstanceOf(InvalidFilterDslException.class) + .hasMessageContaining("is not applicable for field"); + } + + @ParameterizedTest(name = "comparison operator on: ''{0}''") + @ValueSource(strings = {"relation.databasemy-db"}) + @DisplayName("throws InvalidFilterDslException for less/greater than on relation entity") + void parse_comparisonOnRelationEntity_throwsException(String query) { + assertThatThrownBy(() -> parser.parse(query)) + .isInstanceOf(InvalidFilterDslException.class) + .hasMessageContaining("is not applicable for field"); + } + + @ParameterizedTest(name = "comparison operator on: ''{0}''") + @ValueSource(strings = {"relation.database.templatepostgresql"}) + @DisplayName("throws InvalidFilterDslException for unsupported property on relation (template is not a valid relation property)") + void parse_comparisonOnRelationTemplate_throwsException(String query) { + assertThatThrownBy(() -> parser.parse(query)) + .isInstanceOf(InvalidFilterDslException.class) + .hasMessageContaining("template"); + } + + @Test + @DisplayName("throws InvalidFilterDslException for unsupported property on relation with equals operator") + void parse_equalsOnRelationTemplate_throwsException() { + assertThatThrownBy(() -> parser.parse("relation.database.template=postgresql")) + .isInstanceOf(InvalidFilterDslException.class) + .hasMessageContaining("template") + .hasMessageContaining("identifier") + .hasMessageContaining("name"); + } + + @ParameterizedTest(name = "comparison operator on: ''{0}''") + @ValueSource(strings = {"relation.api-link.identifiermicroservice-1"}) + @DisplayName("throws InvalidFilterDslException for less/greater than on relation property") + void parse_comparisonOnRelationProperty_throwsException(String query) { + assertThatThrownBy(() -> parser.parse(query)) + .isInstanceOf(InvalidFilterDslException.class) + .hasMessageContaining("is not applicable for field"); + } + + @ParameterizedTest(name = "comparison operator on: ''{0}''") + @ValueSource(strings = {"relations_as_target.api-link.namemicroservice"}) + @DisplayName("throws InvalidFilterDslException for less/greater than on relations_as_target property") + void parse_comparisonOnRelationsAsTargetProperty_throwsException(String query) { + assertThatThrownBy(() -> parser.parse(query)) + .isInstanceOf(InvalidFilterDslException.class) + .hasMessageContaining("is not applicable for field"); + } + + @ParameterizedTest(name = "comparison operator on: ''{0}''") + @ValueSource(strings = {"nameA", "identifier parser.parse(query)) + .isInstanceOf(InvalidFilterDslException.class) + .hasMessageContaining("is not applicable for field"); + } + + @ParameterizedTest(name = "comparison operator on: ''{0}''") + @ValueSource(strings = {"property.port<9000", "property.port>1000"}) + @DisplayName("accepts less/greater than on NUMBER properties (type check is deferred to EntityService)") + void parse_comparisonOnProperty_succeeds(String query) { + var result = parser.parse(query); + assertThat(result.criteria()).hasSize(1); + } + } + + @Nested + @DisplayName("Edge cases") + class EdgeCaseTests { + + @Test + @DisplayName("consecutive semicolons produce empty filter") + void parse_consecutiveSemicolons_ignoresEmptyTokens() { + var result = parser.parse("name=API;;property.lang=JAVA"); + assertThat(result.criteria()).hasSize(2); + } + + @Test + @DisplayName("trailing semicolon is ignored") + void parse_trailingSemicolon_ignored() { + var result = parser.parse("name=API;"); + assertThat(result.criteria()).hasSize(1); + } + + @Test + @DisplayName("leading semicolon is ignored") + void parse_leadingSemicolon_ignored() { + var result = parser.parse(";name=API"); + assertThat(result.criteria()).hasSize(1); + } + + @Test + @DisplayName("values containing SQL LIKE wildcards are accepted") + void parse_valuesWithLikeWildcards_accepted() { + var result = parser.parse("name:100%_success"); + assertSingleCriterion(result, FilterKeyType.ATTRIBUTE, "name", FilterOperator.CONTAINS, "100%_success"); + } + } + + @Nested + @DisplayName("Null or blank query") + class NullOrBlankQueryTests { + + @ParameterizedTest(name = "returns empty filter for: {0}") + @MethodSource("provideNullOrBlankQueries") + @DisplayName("parse(null/empty/blank) returns empty filter with no criteria") + void parse_nullOrBlankQuery_returnsEmptyFilter(String query) { + var result = parser.parse(query); + assertThat(result.criteria()).isEmpty(); + } + + private static Stream provideNullOrBlankQueries() { + return Stream.of( + Arguments.of((String) null), + Arguments.of(""), + Arguments.of(" ") + ); + } + } + +} diff --git a/src/test/java/com/decathlon/idp_core/domain/service/search/SearchFilterParserTest.java b/src/test/java/com/decathlon/idp_core/domain/service/search/SearchFilterParserTest.java new file mode 100644 index 00000000..03c79b43 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/search/SearchFilterParserTest.java @@ -0,0 +1,218 @@ +package com.decathlon.idp_core.domain.service.search; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import com.decathlon.idp_core.domain.constant.SearchConstraints; +import com.decathlon.idp_core.domain.exception.search.InvalidSearchQueryException; +import com.decathlon.idp_core.domain.model.entity.RawSearchFilterNode; +import com.decathlon.idp_core.domain.model.entity.SearchFilterNode; +import com.decathlon.idp_core.domain.model.enums.LogicalConnector; +import com.decathlon.idp_core.domain.model.enums.SearchOperator; + +/// Unit tests for [SearchFilterParser]. +/// +/// The parser converts a [RawSearchFilterNode] tree to a validated [SearchFilterNode] tree, +/// enforcing structural rules, safety limits, and enum resolution. +@DisplayName("SearchFilterParser") +class SearchFilterParserTest { + + private final SearchFilterParser parser = new SearchFilterParser(); + + @Nested + @DisplayName("parse() — null and empty inputs") + class NullAndEmptyTests { + + @Test + @DisplayName("null input returns empty AND group") + void null_returnsEmptyAndGroup() { + var result = parser.parse(null); + assertThat(result).isInstanceOf(SearchFilterNode.Group.class); + var group = (SearchFilterNode.Group) result; + assertThat(group.connector()).isEqualTo(LogicalConnector.AND); + assertThat(group.nodes()).isEmpty(); + } + } + + @Nested + @DisplayName("parse() — criterion leaf node") + class CriterionTests { + + @Test + @DisplayName("valid criterion is correctly parsed") + void validCriterion_parsed() { + var raw = new RawSearchFilterNode.Criterion("template", "EQ", "microservice"); + var result = parser.parse(raw); + assertThat(result).isInstanceOf(SearchFilterNode.Criterion.class); + var criterion = (SearchFilterNode.Criterion) result; + assertThat(criterion.field()).isEqualTo("template"); + assertThat(criterion.operation()).isEqualTo(SearchOperator.EQ); + assertThat(criterion.value()).isEqualTo("microservice"); + } + + @Test + @DisplayName("operation is case-insensitive") + void operation_caseInsensitive() { + var raw = new RawSearchFilterNode.Criterion("identifier", "contains", "api"); + var result = (SearchFilterNode.Criterion) parser.parse(raw); + assertThat(result.operation()).isEqualTo(SearchOperator.CONTAINS); + } + + @Test + @DisplayName("throws when field is null") + void nullField_throws() { + var raw = new RawSearchFilterNode.Criterion(null, "EQ", "value"); + assertThatThrownBy(() -> parser.parse(raw)) + .isInstanceOf(InvalidSearchQueryException.class) + .hasMessageContaining("field"); + } + + @Test + @DisplayName("throws when field is blank") + void blankField_throws() { + var raw = new RawSearchFilterNode.Criterion(" ", "EQ", "value"); + assertThatThrownBy(() -> parser.parse(raw)) + .isInstanceOf(InvalidSearchQueryException.class) + .hasMessageContaining("field"); + } + + @Test + @DisplayName("throws when operation is null") + void nullOperation_throws() { + var raw = new RawSearchFilterNode.Criterion("identifier", null, "value"); + assertThatThrownBy(() -> parser.parse(raw)) + .isInstanceOf(InvalidSearchQueryException.class) + .hasMessageContaining("operation"); + } + + @Test + @DisplayName("throws when value is null") + void nullValue_throws() { + var raw = new RawSearchFilterNode.Criterion("identifier", "EQ", null); + assertThatThrownBy(() -> parser.parse(raw)) + .isInstanceOf(InvalidSearchQueryException.class) + .hasMessageContaining("value"); + } + + @Test + @DisplayName("throws for invalid operation string") + void invalidOperation_throws() { + var raw = new RawSearchFilterNode.Criterion("identifier", "LIKE", "api"); + assertThatThrownBy(() -> parser.parse(raw)) + .isInstanceOf(InvalidSearchQueryException.class) + .hasMessageContaining("LIKE"); + } + + @Test + @DisplayName("unknown field is accepted by the parser — semantic validation is deferred to SearchFilterValidationService") + void unknownField_acceptedByParser() { + var raw = new RawSearchFilterNode.Criterion("badField", "EQ", "value"); + assertThat(parser.parse(raw)).isInstanceOf(SearchFilterNode.Criterion.class); + } + } + + @Nested + @DisplayName("parse() — group nodes") + class GroupTests { + + @Test + @DisplayName("valid AND group is correctly parsed") + void validAndGroup_parsed() { + var child1 = new RawSearchFilterNode.Criterion("template", "EQ", "microservice"); + var child2 = new RawSearchFilterNode.Criterion("identifier", "CONTAINS", "api"); + var raw = new RawSearchFilterNode.Group("AND", List.of(child1, child2)); + + var result = parser.parse(raw); + assertThat(result).isInstanceOf(SearchFilterNode.Group.class); + var group = (SearchFilterNode.Group) result; + assertThat(group.connector()).isEqualTo(LogicalConnector.AND); + assertThat(group.nodes()).hasSize(2); + } + + @Test + @DisplayName("connector is case-insensitive") + void connector_caseInsensitive() { + var child = new RawSearchFilterNode.Criterion("template", "EQ", "microservice"); + var raw = new RawSearchFilterNode.Group("or", List.of(child)); + var group = (SearchFilterNode.Group) parser.parse(raw); + assertThat(group.connector()).isEqualTo(LogicalConnector.OR); + } + + @Test + @DisplayName("'IN' connector is rejected") + void inConnector_rejected() { + var child = new RawSearchFilterNode.Criterion("template", "EQ", "microservice"); + var raw = new RawSearchFilterNode.Group("IN", List.of(child)); + assertThatThrownBy(() -> parser.parse(raw)) + .isInstanceOf(InvalidSearchQueryException.class) + .hasMessageContaining("IN"); + } + + @Test + @DisplayName("throws for missing connector in group") + void missingConnector_throws() { + var child = new RawSearchFilterNode.Criterion("template", "EQ", "microservice"); + var raw = new RawSearchFilterNode.Group(null, List.of(child)); + assertThatThrownBy(() -> parser.parse(raw)) + .isInstanceOf(InvalidSearchQueryException.class) + .hasMessageContaining("connector"); + } + + @Test + @DisplayName("throws for empty criteria list in group") + void emptyCriteria_throws() { + var raw = new RawSearchFilterNode.Group("AND", List.of()); + assertThatThrownBy(() -> parser.parse(raw)) + .isInstanceOf(InvalidSearchQueryException.class) + .hasMessageContaining("criteria"); + } + + @Test + @DisplayName("throws for invalid connector string") + void invalidConnector_throws() { + var child = new RawSearchFilterNode.Criterion("template", "EQ", "microservice"); + var raw = new RawSearchFilterNode.Group("NAND", List.of(child)); + assertThatThrownBy(() -> parser.parse(raw)) + .isInstanceOf(InvalidSearchQueryException.class) + .hasMessageContaining("NAND"); + } + } + + @Nested + @DisplayName("parse() — safety limits") + class SafetyLimitsTests { + + @Test + @DisplayName("throws when total criteria exceed maximum") + void tooManyCriteria_throws() { + var innerCriteria = new ArrayList(); + for (int i = 0; i <= SearchConstraints.MAX_TOTAL_CRITERIA; i++) { + innerCriteria.add(new RawSearchFilterNode.Criterion("template", "EQ", "v" + i)); + } + var raw = new RawSearchFilterNode.Group("OR", innerCriteria); + assertThatThrownBy(() -> parser.parse(raw)) + .isInstanceOf(InvalidSearchQueryException.class) + .hasMessageContaining(String.valueOf(SearchConstraints.MAX_TOTAL_CRITERIA)); + } + + @Test + @DisplayName("throws when nesting exceeds maximum depth") + void nestingTooDeep_throws() { + RawSearchFilterNode node = new RawSearchFilterNode.Criterion("template", "EQ", "v"); + for (int i = 0; i <= SearchConstraints.MAX_NESTING_DEPTH; i++) { + node = new RawSearchFilterNode.Group("AND", List.of(node)); + } + var root = node; + assertThatThrownBy(() -> parser.parse(root)) + .isInstanceOf(InvalidSearchQueryException.class) + .hasMessageContaining(String.valueOf(SearchConstraints.MAX_NESTING_DEPTH)); + } + } +} diff --git a/src/test/java/com/decathlon/idp_core/domain/service/search/SearchFilterValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/search/SearchFilterValidationServiceTest.java new file mode 100644 index 00000000..11f60172 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/search/SearchFilterValidationServiceTest.java @@ -0,0 +1,409 @@ +package com.decathlon.idp_core.domain.service.search; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import com.decathlon.idp_core.domain.constant.SearchConstraints; +import com.decathlon.idp_core.domain.exception.search.InvalidSearchQueryException; +import com.decathlon.idp_core.domain.model.entity.SearchFilterNode; +import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; +import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; +import com.decathlon.idp_core.domain.model.enums.LogicalConnector; +import com.decathlon.idp_core.domain.model.enums.PropertyType; +import com.decathlon.idp_core.domain.model.enums.SearchOperator; +import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; + +/// Unit tests for [SearchFilterValidationService]. +@DisplayName("SearchFilterValidationService") +class SearchFilterValidationServiceTest { + + private final EntityTemplateRepositoryPort repository = mock(EntityTemplateRepositoryPort.class); + private final SearchFilterValidationService service = new SearchFilterValidationService(repository); + + private static final SearchFilterNode EMPTY_FILTER = + new SearchFilterNode.Group(LogicalConnector.AND, List.of()); + + private PropertyDefinition prop(String name, PropertyType type) { + return new PropertyDefinition(UUID.randomUUID(), name, "desc", type, false, null); + } + + private EntityTemplate template(String identifier, PropertyDefinition... props) { + return new EntityTemplate(UUID.randomUUID(), identifier, identifier, null, List.of(props), List.of()); + } + + // ========================================================================= + // Query string length validation + // ========================================================================= + + @Nested + @DisplayName("Query string length validation") + class QueryLengthTests { + + @Test + @DisplayName("null query does not throw") + void nullQuery_doesNotThrow() { + assertThatCode(() -> service.validate(EMPTY_FILTER, null)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("short query does not throw") + void shortQuery_doesNotThrow() { + assertThatCode(() -> service.validate(EMPTY_FILTER, "checkout")).doesNotThrowAnyException(); + } + + @Test + @DisplayName("query at exact limit does not throw") + void queryAtLimit_doesNotThrow() { + String atLimit = "x".repeat(SearchConstraints.MAX_QUERY_LENGTH); + assertThatCode(() -> service.validate(EMPTY_FILTER, atLimit)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("query exceeding limit throws") + void queryOverLimit_throws() { + String tooLong = "x".repeat(SearchConstraints.MAX_QUERY_LENGTH + 1); + assertThatThrownBy(() -> service.validate(EMPTY_FILTER, tooLong)) + .isInstanceOf(InvalidSearchQueryException.class) + .hasMessageContaining(String.valueOf(SearchConstraints.MAX_QUERY_LENGTH)); + } + } + + // ========================================================================= + // Field name grammar validation + // ========================================================================= + + @Nested + @DisplayName("Field name validation") + class FieldNameTests { + + @Test + @DisplayName("'template' field is accepted") + void template_accepted() { + assertThatCode(() -> service.validate(criterion("template", SearchOperator.EQ, "val"), null)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("'identifier' field is accepted") + void identifier_accepted() { + assertThatCode(() -> service.validate(criterion("identifier", SearchOperator.EQ, "val"), null)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("'name' field is accepted") + void name_accepted() { + assertThatCode(() -> service.validate(criterion("name", SearchOperator.EQ, "val"), null)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("'relation' bare field is accepted") + void relation_accepted() { + assertThatCode(() -> service.validate(criterion("relation", SearchOperator.EQ, "val"), null)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("'relations_as_target' bare field is accepted") + void relationsAsTarget_accepted() { + assertThatCode(() -> service.validate(criterion("relations_as_target", SearchOperator.EQ, "val"), null)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("'property.{name}' field is accepted") + void propertyField_accepted() { + assertThatCode(() -> service.validate(criterion("property.language", SearchOperator.EQ, "val"), null)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("'relation.{name}' field is accepted") + void relationField_accepted() { + assertThatCode(() -> service.validate(criterion("relation.api-link", SearchOperator.EQ, "val"), null)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("'relations_as_target.{name}.identifier' field is accepted") + void relationsAsTargetIdentifierField_accepted() { + assertThatCode(() -> service.validate( + criterion("relations_as_target.api-link.identifier", SearchOperator.EQ, "val"), null)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("'relations_as_target.{name}.name' field is accepted") + void relationsAsTargetNameField_accepted() { + assertThatCode(() -> service.validate( + criterion("relations_as_target.api-link.name", SearchOperator.EQ, "val"), null)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("unknown field throws") + void unknownField_throws() { + assertThatThrownBy(() -> service.validate(criterion("badField", SearchOperator.EQ, "val"), null)) + .isInstanceOf(InvalidSearchQueryException.class) + .hasMessageContaining("badField"); + } + + @Test + @DisplayName("'relations_as_target' without subfield throws") + void relationsAsTarget_missingSubfield_throws() { + assertThatThrownBy(() -> service.validate( + criterion("relations_as_target.api-link", SearchOperator.EQ, "val"), null)) + .isInstanceOf(InvalidSearchQueryException.class); + } + + @Test + @DisplayName("field validation applies to criteria nested inside groups") + void invalidField_nestedInGroup_throws() { + var filter = new SearchFilterNode.Group(LogicalConnector.AND, List.of( + criterion("template", SearchOperator.EQ, "svc"), + criterion("unknownField", SearchOperator.EQ, "val") + )); + assertThatThrownBy(() -> service.validate(filter, null)) + .isInstanceOf(InvalidSearchQueryException.class) + .hasMessageContaining("unknownField"); + } + } + + // ========================================================================= + // Numeric operator constraint validation + // ========================================================================= + + @Nested + @DisplayName("Numeric operator constraints") + class NumericOperatorTests { + + @Test + @DisplayName("GT on property.{name} with a numeric value is accepted") + void gt_onProperty_numericValue_accepted() { + assertThatCode(() -> service.validate(criterion("property.port", SearchOperator.GT, "8080"), null)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("GTE on property.{name} with a decimal value is accepted") + void gte_onProperty_decimalValue_accepted() { + assertThatCode(() -> service.validate(criterion("property.score", SearchOperator.GTE, "1.5"), null)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("GT on 'template' field throws — numeric ops only on property.{name}") + void gt_onTemplateField_throws() { + assertThatThrownBy(() -> service.validate(criterion("template", SearchOperator.GT, "5"), null)) + .isInstanceOf(InvalidSearchQueryException.class) + .hasMessageContaining("GT"); + } + + @Test + @DisplayName("LT on 'identifier' field throws — numeric ops only on property.{name}") + void lt_onIdentifierField_throws() { + assertThatThrownBy(() -> service.validate(criterion("identifier", SearchOperator.LT, "5"), null)) + .isInstanceOf(InvalidSearchQueryException.class) + .hasMessageContaining("LT"); + } + + @Test + @DisplayName("GT on property.{name} with a non-numeric value throws") + void gt_nonNumericValue_throws() { + assertThatThrownBy(() -> service.validate(criterion("property.port", SearchOperator.GT, "abc"), null)) + .isInstanceOf(InvalidSearchQueryException.class) + .hasMessageContaining("abc") + .hasMessageContaining("GT"); + } + + @Test + @DisplayName("LTE on property.{name} with alphanumeric non-numeric value throws") + void lte_nonNumericValue_throws() { + assertThatThrownBy(() -> service.validate(criterion("property.size", SearchOperator.LTE, "10MB"), null)) + .isInstanceOf(InvalidSearchQueryException.class) + .hasMessageContaining("10MB"); + } + } + + // ========================================================================= + // Template-scoped property-type validation + // ========================================================================= + + @Nested + @DisplayName("No numeric operators — no validation triggered") + class NoNumericOperatorsTests { + + @Test + @DisplayName("filter with only EQ operators does not throw") + void eq_only_doesNotThrow() { + var filter = new SearchFilterNode.Group(LogicalConnector.AND, List.of( + new SearchFilterNode.Criterion("template", SearchOperator.EQ, "microservice"), + new SearchFilterNode.Criterion("property.lifecycle", SearchOperator.EQ, "production") + )); + assertThatCode(() -> service.validate(filter, null)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("empty filter does not throw") + void emptyFilter_doesNotThrow() { + assertThatCode(() -> service.validate(EMPTY_FILTER, null)).doesNotThrowAnyException(); + } + } + + @Nested + @DisplayName("Numeric operators without a template constraint") + class NoTemplateConstraintTests { + + @Test + @DisplayName("GT on property without template constraint does not throw (can't validate)") + void gt_noTemplateConstraint_doesNotThrow() { + assertThatCode(() -> service.validate( + new SearchFilterNode.Criterion("property.port", SearchOperator.GT, "8080"), null)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("GT on property with only non-EQ template constraint does not throw") + void gt_templateConstraintNotEq_doesNotThrow() { + var filter = new SearchFilterNode.Group(LogicalConnector.AND, List.of( + new SearchFilterNode.Criterion("template", SearchOperator.CONTAINS, "service"), + new SearchFilterNode.Criterion("property.port", SearchOperator.GT, "8080") + )); + assertThatCode(() -> service.validate(filter, null)).doesNotThrowAnyException(); + } + } + + @Nested + @DisplayName("Numeric operators with a template constraint (type check enabled)") + class WithTemplateConstraintTests { + + @Test + @DisplayName("GT on a NUMBER property does not throw") + void gt_numberProperty_doesNotThrow() { + when(repository.findByIdentifier("web-service")) + .thenReturn(Optional.of(template("web-service", prop("port", PropertyType.NUMBER)))); + + var filter = new SearchFilterNode.Group(LogicalConnector.AND, List.of( + new SearchFilterNode.Criterion("template", SearchOperator.EQ, "web-service"), + new SearchFilterNode.Criterion("property.port", SearchOperator.GT, "8080") + )); + assertThatCode(() -> service.validate(filter, null)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("GTE, LT, LTE on a NUMBER property do not throw") + void allNumericOperators_numberProperty_doesNotThrow() { + when(repository.findByIdentifier("ws")) + .thenReturn(Optional.of(template("ws", prop("score", PropertyType.NUMBER)))); + + for (SearchOperator op : List.of(SearchOperator.GTE, SearchOperator.LT, SearchOperator.LTE)) { + var filter = new SearchFilterNode.Group(LogicalConnector.AND, List.of( + new SearchFilterNode.Criterion("template", SearchOperator.EQ, "ws"), + new SearchFilterNode.Criterion("property.score", op, "5") + )); + assertThatCode(() -> service.validate(filter, null)) + .as("operator %s should not throw for NUMBER property", op) + .doesNotThrowAnyException(); + } + } + + @Test + @DisplayName("GT on a STRING property throws") + void gt_stringProperty_throws() { + when(repository.findByIdentifier("web-service")) + .thenReturn(Optional.of(template("web-service", + prop("programmingLanguage", PropertyType.STRING), + prop("port", PropertyType.NUMBER)))); + + var filter = new SearchFilterNode.Group(LogicalConnector.AND, List.of( + new SearchFilterNode.Criterion("template", SearchOperator.EQ, "web-service"), + new SearchFilterNode.Criterion("property.programmingLanguage", SearchOperator.GT, "5") + )); + assertThatThrownBy(() -> service.validate(filter, null)) + .isInstanceOf(InvalidSearchQueryException.class) + .hasMessageContaining("programmingLanguage") + .hasMessageContaining("web-service") + .hasMessageContaining("STRING"); + } + + @Test + @DisplayName("GT on a BOOLEAN property throws") + void gt_booleanProperty_throws() { + when(repository.findByIdentifier("svc")) + .thenReturn(Optional.of(template("svc", prop("isActive", PropertyType.BOOLEAN)))); + + var filter = new SearchFilterNode.Group(LogicalConnector.AND, List.of( + new SearchFilterNode.Criterion("template", SearchOperator.EQ, "svc"), + new SearchFilterNode.Criterion("property.isActive", SearchOperator.LTE, "1") + )); + assertThatThrownBy(() -> service.validate(filter, null)) + .isInstanceOf(InvalidSearchQueryException.class) + .hasMessageContaining("isActive") + .hasMessageContaining("BOOLEAN"); + } + + @Test + @DisplayName("unknown template does not throw — template may not exist yet") + void unknownTemplate_doesNotThrow() { + when(repository.findByIdentifier("unknown")).thenReturn(Optional.empty()); + + var filter = new SearchFilterNode.Group(LogicalConnector.AND, List.of( + new SearchFilterNode.Criterion("template", SearchOperator.EQ, "unknown"), + new SearchFilterNode.Criterion("property.port", SearchOperator.GT, "80") + )); + assertThatCode(() -> service.validate(filter, null)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("property not defined in template does not throw — may be optional") + void propertyNotInTemplate_doesNotThrow() { + when(repository.findByIdentifier("ws")) + .thenReturn(Optional.of(template("ws", prop("port", PropertyType.NUMBER)))); + + var filter = new SearchFilterNode.Group(LogicalConnector.AND, List.of( + new SearchFilterNode.Criterion("template", SearchOperator.EQ, "ws"), + new SearchFilterNode.Criterion("property.undefinedProp", SearchOperator.GT, "5") + )); + assertThatCode(() -> service.validate(filter, null)).doesNotThrowAnyException(); + } + } + + @Nested + @DisplayName("Nested filter trees") + class NestedTreeTests { + + @Test + @DisplayName("GT on STRING property nested inside OR group throws") + void gt_stringProperty_nestedInOr_throws() { + when(repository.findByIdentifier("svc")) + .thenReturn(Optional.of(template("svc", prop("name", PropertyType.STRING)))); + + var inner = new SearchFilterNode.Group(LogicalConnector.OR, List.of( + new SearchFilterNode.Criterion("property.name", SearchOperator.GT, "5") + )); + var filter = new SearchFilterNode.Group(LogicalConnector.AND, List.of( + new SearchFilterNode.Criterion("template", SearchOperator.EQ, "svc"), + inner + )); + assertThatThrownBy(() -> service.validate(filter, null)) + .isInstanceOf(InvalidSearchQueryException.class) + .hasMessageContaining("name") + .hasMessageContaining("STRING"); + } + } + + private static SearchFilterNode.Criterion criterion(String field, SearchOperator op, String value) { + return new SearchFilterNode.Criterion(field, op, value); + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java index 428a036c..939b70f2 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java @@ -1,5 +1,6 @@ package com.decathlon.idp_core.infrastructure.adapters.api.controller; +import static org.hamcrest.Matchers.hasItem; import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -23,6 +24,7 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import com.decathlon.idp_core.AbstractIntegrationTest; +import com.decathlon.idp_core.domain.constant.SearchConstraints; /// Integration tests for the EntityController REST API endpoints. These tests /// verify the behavior of entity retrieval endpoints, including pagination, @@ -757,6 +759,799 @@ void putEntity_403_without_csrf() throws Exception { .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).content(VALID_UPDATE_PAYLOAD)) .andExpect(status().isForbidden()); } + + } + + + @Nested + @DisplayName("POST /api/v1/entities/search") + class SearchEntitiesTests { + + private static final String SEARCH_PATH = "/api/v1/entities/search"; + + @Test + @DisplayName("Should return 401 without authentication") + void search_401_withoutAuth() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { "page": 0, "size": 20 } + """)) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("Should search entities by template AND property (EQ)") + @WithMockUser + void search_200_byTemplateAndProperty() throws Exception { + var result = mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { + "filter": { + "connector": "AND", + "criteria": [ + { "field": "template", "operation": "EQ", "value": "web-service" }, + { "field": "property.programmingLanguage", "operation": "EQ", "value": "JAVA" } + ] + }, + "page": 0, "size": 20, "sort": "identifier:asc" + } + """)) + .andExpect(status().isOk()) + .andReturn(); + JSONAssert.assertEquals( + getJsonTestFileContent(ENTITY_JSON_FILES_TEST_PATH + "searchEntities_200_byTemplateAndProperty.json"), + result.getResponse().getContentAsString(), + JSONCompareMode.STRICT); + } + + @Test + @DisplayName("Should search entities using OR connector across templates") + @WithMockUser + void search_200_orTemplates() throws Exception { + var result = mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { + "filter": { + "connector": "OR", + "criteria": [ + { "field": "template", "operation": "EQ", "value": "microservice" }, + { "field": "template", "operation": "EQ", "value": "batch-job" } + ] + }, + "page": 0, "size": 20, "sort": "identifier:asc" + } + """)) + .andExpect(status().isOk()) + .andReturn(); + JSONAssert.assertEquals( + getJsonTestFileContent(ENTITY_JSON_FILES_TEST_PATH + "searchEntities_200_orTemplates.json"), + result.getResponse().getContentAsString(), + JSONCompareMode.STRICT); + } + + @Test + @DisplayName("Should search entities by relations_as_target identifier") + @WithMockUser + void search_200_byRelationsAsTarget() throws Exception { + var result = mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { + "filter": { + "connector": "AND", + "criteria": [ + { "field": "template", "operation": "EQ", "value": "microservice" }, + { "field": "relations_as_target.api-link.identifier", "operation": "EQ", "value": "web-api-1" } + ] + }, + "page": 0, "size": 20 + } + """)) + .andExpect(status().isOk()) + .andReturn(); + JSONAssert.assertEquals( + getJsonTestFileContent(ENTITY_JSON_FILES_TEST_PATH + "searchEntities_200_byRelationsAsTarget.json"), + result.getResponse().getContentAsString(), + JSONCompareMode.STRICT); + } + + @Test + @DisplayName("Should search entities by bare relations_as_target presence (EQ)") + @WithMockUser + void search_200_byRelationsAsTargetPresence() throws Exception { + var result = mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { + "filter": { + "connector": "AND", + "criteria": [ + { "field": "template", "operation": "EQ", "value": "microservice" }, + { "field": "relations_as_target", "operation": "EQ", "value": "api-link" } + ] + }, + "page": 0, "size": 20 + } + """)) + .andExpect(status().isOk()) + .andReturn(); + JSONAssert.assertEquals( + getJsonTestFileContent(ENTITY_JSON_FILES_TEST_PATH + "searchEntities_200_byRelationsAsTargetPresence.json"), + result.getResponse().getContentAsString(), + JSONCompareMode.STRICT); + } + + @Test + @DisplayName("Should search entities by bare relations_as_target absence (NOT_CONTAINS)") + @WithMockUser + void search_200_byRelationsAsTargetAbsence() throws Exception { + // web-api-1 and web-api-2 are web-service entities not targeted by any 'uses' relation, + // so NOT_CONTAINS must include them in the results. + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { + "filter": { + "connector": "AND", + "criteria": [ + { "field": "template", "operation": "EQ", "value": "web-service" }, + { "field": "relations_as_target", "operation": "NOT_CONTAINS", "value": "uses" } + ] + }, + "page": 0, "size": 20 + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[*].identifier", hasItem("web-api-1"))) + .andExpect(jsonPath("$.content[*].identifier", hasItem("web-api-2"))); + } + + @Test + @DisplayName("Should search entities using STARTS_WITH operator") + @WithMockUser + void search_200_startsWith() throws Exception { + var result = mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { + "filter": { + "connector": "AND", + "criteria": [ + { "field": "template", "operation": "EQ", "value": "web-service" }, + { "field": "name", "operation": "STARTS_WITH", "value": "Web API 1" } + ] + }, + "page": 0, "size": 20 + } + """)) + .andExpect(status().isOk()) + .andReturn(); + JSONAssert.assertEquals( + getJsonTestFileContent(ENTITY_JSON_FILES_TEST_PATH + "searchEntities_200_startsWith.json"), + result.getResponse().getContentAsString(), + JSONCompareMode.STRICT); + } + + @Test + @DisplayName("Should search entities using NEQ operator") + @WithMockUser + void search_200_neq() throws Exception { + var result = mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { + "filter": { + "connector": "AND", + "criteria": [ + { "field": "template", "operation": "EQ", "value": "web-service" }, + { "field": "identifier", "operation": "STARTS_WITH", "value": "web-api" }, + { "field": "identifier", "operation": "NEQ", "value": "web-api-1" } + ] + }, + "page": 0, "size": 20 + } + """)) + .andExpect(status().isOk()) + .andReturn(); + JSONAssert.assertEquals( + getJsonTestFileContent(ENTITY_JSON_FILES_TEST_PATH + "searchEntities_200_neq.json"), + result.getResponse().getContentAsString(), + JSONCompareMode.STRICT); + } + + @Test + @DisplayName("Should return empty content when no entities match") + @WithMockUser + void search_200_noMatch() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { + "filter": { + "field": "identifier", + "operation": "EQ", + "value": "non-existent-entity-xyz" + }, + "page": 0, + "size": 20 + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(0)) + .andExpect(jsonPath("$.page.total_elements").value(0)); + } + + @Test + @DisplayName("Should return all entities when no filter is applied") + @WithMockUser + void search_200_nullFilter() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { + "page": 0, + "size": 5 + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content.length()").value(5)); + } + + @Test + @DisplayName("Should return paginated results respecting size parameter") + @WithMockUser + void search_200_paginated() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { + "filter": { + "field": "template", + "operation": "EQ", + "value": "monitoring-service" + }, + "page": 0, + "size": 3 + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(3)) + .andExpect(jsonPath("$.page.size").value(3)) + .andExpect(jsonPath("$.page.total_elements").value(6)) + .andExpect(jsonPath("$.page.total_pages").value(2)); + } + + @Test + @DisplayName("Should return 400 for invalid connector value") + @WithMockUser + void search_400_invalidConnector() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { + "filter": { + "connector": "INVALID_CONNECTOR", + "criteria": [ + { "field": "template", "operation": "EQ", "value": "microservice" } + ] + }, + "page": 0, + "size": 20 + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_description").value("Invalid connector 'INVALID_CONNECTOR'. Supported values: AND, OR")); + } + + @Test + @DisplayName("Should return 400 for invalid operation value") + @WithMockUser + void search_400_invalidOperation() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { + "filter": { + "field": "identifier", + "operation": "LIKE", + "value": "api" + }, + "page": 0, + "size": 20 + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_description").value("Invalid operation 'LIKE'. Supported values: EQ, NEQ, CONTAINS, NOT_CONTAINS, STARTS_WITH, ENDS_WITH, GT, GTE, LT, LTE")); + } + + @Test + @DisplayName("Should return 400 for invalid field name") + @WithMockUser + void search_400_invalidField() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { + "filter": { + "field": "unknownField", + "operation": "EQ", + "value": "value" + }, + "page": 0, + "size": 20 + } + """)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("Should return 400 when criterion is missing field") + @WithMockUser + void search_400_missingField() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { + "filter": { + "operation": "EQ", + "value": "microservice" + }, + "page": 0, + "size": 20 + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_description").value("A criterion node must have a non-blank 'field'")); + } + + @Test + @DisplayName("Should return 400 when group is missing criteria") + @WithMockUser + void search_400_groupMissingCriteria() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { + "filter": { + "connector": "AND" + }, + "page": 0, + "size": 20 + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_description").value("A group node must have a non-empty 'criteria' list")); + } + + @Test + @DisplayName("Should support sort parameter") + @WithMockUser + void search_200_withSort() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { + "filter": { + "field": "template", + "operation": "EQ", + "value": "monitoring-service" + }, + "page": 0, + "size": 6, + "sort": "name:desc" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(6)) + .andExpect(jsonPath("$.content[0].name").value("Monitoring Service 6")); + } + + @Test + @DisplayName("Should support nested AND/OR filter composition") + @WithMockUser + void search_200_nestedFilter() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { + "filter": { + "connector": "AND", + "criteria": [ + { + "connector": "OR", + "criteria": [ + { "field": "template", "operation": "EQ", "value": "microservice" }, + { "field": "template", "operation": "EQ", "value": "batch-job" } + ] + }, + { "field": "identifier", "operation": "EQ", "value": "microservice-1" } + ] + }, + "page": 0, + "size": 20 + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(1)) + .andExpect(jsonPath("$.content[0].identifier").value("microservice-1")); + } + + @Test + @DisplayName("Should find entities by query matching identifier") + @WithMockUser + void search_200_queryByIdentifier() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { "query": "web-api", "page": 0, "size": 20 } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(2)) + .andExpect(jsonPath("$.page.total_elements").value(2)); + } + + @Test + @DisplayName("Should find entities by query matching name (case-insensitive)") + @WithMockUser + void search_200_queryByName() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { "query": "Web API", "page": 0, "size": 20 } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(2)) + .andExpect(jsonPath("$.page.total_elements").value(2)); + } + + @Test + @DisplayName("Should find entities by query matching a property value") + @WithMockUser + void search_200_queryByPropertyValue() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { "query": "JAVA", "page": 0, "size": 20 } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(2)) + .andExpect(jsonPath("$.content[*].identifier", hasItem("web-api-1"))) + .andExpect(jsonPath("$.content[*].identifier", hasItem("web-service-valid-1"))); + } + + @Test + @DisplayName("Should combine query and filter with AND semantics") + @WithMockUser + void search_200_queryAndFilter() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { + "query": "JAVA", + "filter": { + "field": "template", + "operation": "EQ", + "value": "web-service" + }, + "page": 0, + "size": 20 + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(2)) + .andExpect(jsonPath("$.content[*].identifier", hasItem("web-api-1"))) + .andExpect(jsonPath("$.content[*].identifier", hasItem("web-service-valid-1"))); + } + + @Test + @DisplayName("Should treat blank query as no-op and return all entities") + @WithMockUser + void search_200_blankQueryIsNoOp() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { "query": " ", "page": 0, "size": 5 } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(5)); + } + + @Test + @DisplayName("Should return 400 when query exceeds maximum length") + @WithMockUser + void search_400_queryTooLong() throws Exception { + var tooLong = "x".repeat(256); + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { "query": "%s", "page": 0, "size": 20 } + """.formatted(tooLong))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_description").value("Search query must not exceed 255 characters")); + } + + @Test + @WithMockUser + @DisplayName("Should return 400 when GT operator is used on a non-property field") + void search_400_numericOperator_onNonPropertyField() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { + "filter": { + "connector": "AND", + "criteria": [ + { "field": "template", "operation": "GT", "value": "5" } + ] + }, + "page": 0, "size": 20 + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_description").value( + org.hamcrest.Matchers.containsString("GT"))); + } + + @Test + @WithMockUser + @DisplayName("Should return 400 when GT operator is used with a non-numeric value") + void search_400_numericOperator_nonNumericValue() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { + "filter": { + "connector": "AND", + "criteria": [ + { "field": "property.port", "operation": "GT", "value": "not-a-number" } + ] + }, + "page": 0, "size": 20 + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_description").value( + org.hamcrest.Matchers.containsString("not-a-number"))); + } + + @Test + @WithMockUser + @DisplayName("Should return 400 when GTE is used on a STRING-typed property with a known template") + void search_400_numericOperator_onStringProperty() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { + "filter": { + "connector": "AND", + "criteria": [ + { "field": "template", "operation": "EQ", "value": "web-service" }, + { "field": "property.programmingLanguage", "operation": "GTE", "value": "5" } + ] + }, + "page": 0, "size": 20 + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_description").value( + org.hamcrest.Matchers.allOf( + org.hamcrest.Matchers.containsString("programmingLanguage"), + org.hamcrest.Matchers.containsString("STRING")))); + } + + @Test + @WithMockUser + @DisplayName("Should return 200 and match correct entities when GT used on a NUMBER property") + void search_200_numericGt_onNumberProperty() throws Exception { + // web-api-1 has port=8080, web-api-2 has port=9090; GT 8085 should return only web-api-2 + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { + "filter": { + "connector": "AND", + "criteria": [ + { "field": "template", "operation": "EQ", "value": "web-service" }, + { "field": "property.port", "operation": "GT", "value": "8085" } + ] + }, + "page": 0, "size": 20 + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.page.total_elements").value(1)) + .andExpect(jsonPath("$.content[0].identifier").value("web-api-2")); + } + + @Test + @WithMockUser + @DisplayName("Should return 200 and match all seeded entities when LTE used with upper bound covering all") + void search_200_numericLte_onNumberProperty_allMatch() throws Exception { + // Both web-api-1 (port=8080) and web-api-2 (port=9090) are <= 9999. + // Other test methods (e.g. postEntity_201) may create additional web-service entities + // in the same shared DB, so we only assert at-least-2 rather than an exact count. + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { + "filter": { + "connector": "AND", + "criteria": [ + { "field": "template", "operation": "EQ", "value": "web-service" }, + { "field": "property.port", "operation": "LTE", "value": "9999" } + ] + }, + "page": 0, "size": 20 + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.page.total_elements", + org.hamcrest.Matchers.greaterThanOrEqualTo(2))); + } + + @Test + @WithMockUser + @DisplayName("Should return 200 when page and size are omitted from the request body") + void search_200_noPageOrSize_usesDefaults() throws Exception { + // Omitting page and size must not cause a 400 JSON parse error (primitive int vs null). + // The record defaults should kick in: page=0, size=20. + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { + "filter": { + "connector": "AND", + "criteria": [ + { "field": "template", "operation": "EQ", "value": "web-service" } + ] + } + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.page.size").value(20)) + .andExpect(jsonPath("$.page.number").value(0)); + } + + @Test + @WithMockUser + @DisplayName("Should return 400 when size exceeds the maximum allowed value") + void search_400_pageSizeTooLarge() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { "page": 0, "size": 501 } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_description") + .value("Page size must not exceed %d".formatted(SearchConstraints.MAX_PAGE_SIZE))); + } + + @Test + @WithMockUser + @DisplayName("Should return 400 when sort field is not in the allowed list") + void search_400_invalidSortField() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { "page": 0, "size": 20, "sort": "badField:asc" } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_description").value("Invalid sort field 'badField'. Supported fields: identifier, name, templateIdentifier")); + } + + @Test + @WithMockUser + @DisplayName("Should return entities that have a relation with an exact name match") + void search_200_byRelationNameEq() throws Exception { + // web-api-1 has relation "api-link"; web-api-2 does not + var result = mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { + "filter": { "field": "relation", "operation": "EQ", "value": "api-link" }, + "page": 0, "size": 20 + } + """)) + .andExpect(status().isOk()) + .andReturn(); + JSONAssert.assertEquals( + getJsonTestFileContent(ENTITY_JSON_FILES_TEST_PATH + "searchEntities_200_byRelationNameEq.json"), + result.getResponse().getContentAsString(), + JSONCompareMode.STRICT); + } + + @Test + @WithMockUser + @DisplayName("Should return entities that have a relation whose name contains the given value") + void search_200_byRelationNameContains() throws Exception { + // both web-api-1 and web-api-2 have a relation named "database" + var result = mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(""" + { + "filter": { "field": "relation", "operation": "CONTAINS", "value": "database" }, + "page": 0, "size": 20, "sort": "identifier:asc" + } + """)) + .andExpect(status().isOk()) + .andReturn(); + JSONAssert.assertEquals( + getJsonTestFileContent(ENTITY_JSON_FILES_TEST_PATH + "searchEntities_200_byRelationNameContains.json"), + result.getResponse().getContentAsString(), + JSONCompareMode.STRICT); + } } } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntityFilterSpecificationTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntityFilterSpecificationTest.java new file mode 100644 index 00000000..02987a27 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntityFilterSpecificationTest.java @@ -0,0 +1,12 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.specification; + +import com.decathlon.idp_core.infrastructure.adapters.api.controller.EntityControllerTest; + +/// Unit tests for [EntityFilterSpecification]. +/// +/// LIKE wildcard escaping logic is tested in [JpaPredicateBuilderTest]. +/// Integration-level specification behavior is verified through the +/// [EntityControllerTest] integration tests. +@SuppressWarnings("java:S2187") +class EntityFilterSpecificationTest { +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecificationTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecificationTest.java new file mode 100644 index 00000000..1cb3b721 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecificationTest.java @@ -0,0 +1,257 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.specification; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.data.jpa.domain.Specification; + +import com.decathlon.idp_core.domain.model.entity.SearchFilterNode; +import com.decathlon.idp_core.domain.model.enums.LogicalConnector; +import com.decathlon.idp_core.domain.model.enums.SearchOperator; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.EntityJpaEntity; + +/// Unit tests for [EntitySearchSpecification]. +/// +/// Tests the static specification building logic and edge cases for various field types +/// and operators. Wildcard escaping logic is tested in [JpaPredicateBuilderTest]. +/// Integration-level behavior is verified in [EntityControllerTest]. +@DisplayName("EntitySearchSpecification") +class EntitySearchSpecificationTest { + + @Nested + @DisplayName("of() — empty and null filter") + class EmptyFilterTests { + + @Test + @DisplayName("empty group returns non-null specification") + void emptyGroup_returnsSpec() { + var filter = new SearchFilterNode.Group(LogicalConnector.AND, List.of()); + Specification spec = EntitySearchSpecification.of(filter); + assertThat(spec).isNotNull(); + } + + @Test + @DisplayName("single criterion returns non-null specification") + void singleCriterion_returnsSpec() { + var filter = new SearchFilterNode.Criterion("template", SearchOperator.EQ, "microservice"); + Specification spec = EntitySearchSpecification.of(filter); + assertThat(spec).isNotNull(); + } + } + + @Nested + @DisplayName("of() — group connectors") + class GroupConnectorTests { + + @Test + @DisplayName("AND group returns non-null specification") + void andGroup_returnsSpec() { + var filter = new SearchFilterNode.Group(LogicalConnector.AND, List.of( + new SearchFilterNode.Criterion("template", SearchOperator.EQ, "microservice"), + new SearchFilterNode.Criterion("identifier", SearchOperator.CONTAINS, "api") + )); + assertThat(EntitySearchSpecification.of(filter)).isNotNull(); + } + + @Test + @DisplayName("OR group returns non-null specification") + void orGroup_returnsSpec() { + var filter = new SearchFilterNode.Group(LogicalConnector.OR, List.of( + new SearchFilterNode.Criterion("template", SearchOperator.EQ, "microservice"), + new SearchFilterNode.Criterion("template", SearchOperator.EQ, "web-service") + )); + assertThat(EntitySearchSpecification.of(filter)).isNotNull(); + } + + @Test + @DisplayName("nested group returns non-null specification") + void nestedGroup_returnsSpec() { + var inner = new SearchFilterNode.Group(LogicalConnector.OR, List.of( + new SearchFilterNode.Criterion("template", SearchOperator.EQ, "microservice"), + new SearchFilterNode.Criterion("template", SearchOperator.EQ, "web-service") + )); + var outer = new SearchFilterNode.Group(LogicalConnector.AND, List.of( + inner, + new SearchFilterNode.Criterion("property.language", SearchOperator.EQ, "JAVA") + )); + assertThat(EntitySearchSpecification.of(outer)).isNotNull(); + } + } + + @Nested + @DisplayName("of() — field types") + class FieldTypeTests { + + @Test + @DisplayName("template field returns non-null spec") + void templateField_returnsSpec() { + assertThat(specFor("template", SearchOperator.EQ, "microservice")).isNotNull(); + } + + @Test + @DisplayName("identifier field returns non-null spec") + void identifierField_returnsSpec() { + assertThat(specFor("identifier", SearchOperator.EQ, "my-entity")).isNotNull(); + } + + @Test + @DisplayName("name field returns non-null spec") + void nameField_returnsSpec() { + assertThat(specFor("name", SearchOperator.CONTAINS, "Service")).isNotNull(); + } + + @Test + @DisplayName("property.{name} field returns non-null spec") + void propertyField_returnsSpec() { + assertThat(specFor("property.language", SearchOperator.EQ, "JAVA")).isNotNull(); + } + + @Test + @DisplayName("relation.{name} field returns non-null spec") + void relationField_returnsSpec() { + assertThat(specFor("relation.api-link", SearchOperator.EQ, "microservice-1")).isNotNull(); + } + + @Test + @DisplayName("relation.{name}.identifier field returns non-null spec") + void relationIdentifierField_returnsSpec() { + assertThat(specFor("relation.api-link.identifier", SearchOperator.EQ, "microservice-1")).isNotNull(); + } + + @Test + @DisplayName("relation.{name}.name field returns non-null spec") + void relationNameField_returnsSpec() { + assertThat(specFor("relation.api-link.name", SearchOperator.CONTAINS, "Microservice")).isNotNull(); + } + + @Test + @DisplayName("relations_as_target.{name}.identifier field returns non-null spec") + void relationsAsTargetIdentifierField_returnsSpec() { + assertThat(specFor("relations_as_target.api-link.identifier", SearchOperator.EQ, "web-api-1")).isNotNull(); + } + + @Test + @DisplayName("relations_as_target.{name}.name field returns non-null spec") + void relationsAsTargetNameField_returnsSpec() { + assertThat(specFor("relations_as_target.api-link.name", SearchOperator.CONTAINS, "Web")).isNotNull(); + } + + @Test + @DisplayName("bare 'relations_as_target' field (filter on reverse relation name) returns non-null spec") + void bareRelationsAsTargetField_returnsSpec() { + assertThat(specFor("relations_as_target", SearchOperator.NOT_CONTAINS, "used_by")).isNotNull(); + } + + @Test + @DisplayName("bare 'relation' field (filter on relation name) returns non-null spec") + void bareRelationField_returnsSpec() { + assertThat(specFor("relation", SearchOperator.CONTAINS, "api-link")).isNotNull(); + } + + @Test + @DisplayName("unknown field throws IllegalArgumentException") + void unknownField_throwsException() { + var criterion = new SearchFilterNode.Criterion("unknown_field", SearchOperator.EQ, "value"); + assertThatThrownBy(() -> EntitySearchSpecification.of(criterion)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("unknown_field"); + } + } + + @Nested + @DisplayName("of() — all operators") + class OperatorTests { + + @Test + @DisplayName("EQ operator returns non-null spec") + void eq_returnsSpec() { + assertThat(specFor("identifier", SearchOperator.EQ, "val")).isNotNull(); + } + + @Test + @DisplayName("NEQ operator returns non-null spec") + void neq_returnsSpec() { + assertThat(specFor("identifier", SearchOperator.NEQ, "val")).isNotNull(); + } + + @Test + @DisplayName("CONTAINS operator returns non-null spec") + void contains_returnsSpec() { + assertThat(specFor("name", SearchOperator.CONTAINS, "service")).isNotNull(); + } + + @Test + @DisplayName("NOT_CONTAINS operator returns non-null spec") + void notContains_returnsSpec() { + assertThat(specFor("name", SearchOperator.NOT_CONTAINS, "legacy")).isNotNull(); + } + + @Test + @DisplayName("STARTS_WITH operator returns non-null spec") + void startsWith_returnsSpec() { + assertThat(specFor("name", SearchOperator.STARTS_WITH, "Web")).isNotNull(); + } + + @Test + @DisplayName("ENDS_WITH operator returns non-null spec") + void endsWith_returnsSpec() { + assertThat(specFor("name", SearchOperator.ENDS_WITH, "Service")).isNotNull(); + } + + @Test + @DisplayName("GT operator returns non-null spec") + void gt_returnsSpec() { + assertThat(specFor("property.version", SearchOperator.GT, "1.0")).isNotNull(); + } + + @Test + @DisplayName("GTE operator returns non-null spec") + void gte_returnsSpec() { + assertThat(specFor("property.version", SearchOperator.GTE, "1.0")).isNotNull(); + } + + @Test + @DisplayName("LT operator returns non-null spec") + void lt_returnsSpec() { + assertThat(specFor("property.version", SearchOperator.LT, "2.0")).isNotNull(); + } + + @Test + @DisplayName("LTE operator returns non-null spec") + void lte_returnsSpec() { + assertThat(specFor("property.version", SearchOperator.LTE, "2.0")).isNotNull(); + } + } + + private static Specification specFor(String field, SearchOperator op, String value) { + return EntitySearchSpecification.of(new SearchFilterNode.Criterion(field, op, value)); + } + + @Nested + @DisplayName("globalTextSearch()") + class GlobalTextSearchTests { + + @Test + @DisplayName("returns non-null specification for a plain query") + void plainQuery_returnsSpec() { + assertThat(EntitySearchSpecification.globalTextSearch("checkout")).isNotNull(); + } + + @Test + @DisplayName("returns non-null specification for a query with LIKE wildcards") + void queryWithWildcards_returnsSpec() { + assertThat(EntitySearchSpecification.globalTextSearch("a%b_c")).isNotNull(); + } + + @Test + @DisplayName("returns non-null specification for an upper-case query") + void upperCaseQuery_returnsSpec() { + assertThat(EntitySearchSpecification.globalTextSearch("JAVA")).isNotNull(); + } + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySpecificationTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySpecificationTest.java deleted file mode 100644 index 595738f5..00000000 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySpecificationTest.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.decathlon.idp_core.infrastructure.adapters.persistence.specification; - -import static org.assertj.core.api.Assertions.assertThat; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; - -/// Unit tests for [EntitySpecification]. -/// -/// Focuses on the LIKE wildcard escaping logic which is security-critical. -/// Integration-level specification behavior is verified through the -/// [EntityControllerTest] integration tests. -@DisplayName("EntitySpecification") -@SuppressWarnings("java:S2187") -class EntitySpecificationTest { - - @Nested - @DisplayName("escapeLikeWildcards") - class EscapeLikeWildcardsTests { - - @Test - @DisplayName("escapes percent sign") - void escapes_percent() { - assertThat(EntitySpecification.escapeLikeWildcards("100%")).isEqualTo("100\\%"); - } - - @Test - @DisplayName("escapes underscore") - void escapes_underscore() { - assertThat(EntitySpecification.escapeLikeWildcards("my_value")).isEqualTo("my\\_value"); - } - - @Test - @DisplayName("escapes backslash before other wildcards") - void escapes_backslash() { - assertThat(EntitySpecification.escapeLikeWildcards("path\\to%file")) - .isEqualTo("path\\\\to\\%file"); - } - - @Test - @DisplayName("escapes multiple wildcards") - void escapes_multipleWildcards() { - assertThat(EntitySpecification.escapeLikeWildcards("100%_success")) - .isEqualTo("100\\%\\_success"); - } - - @Test - @DisplayName("returns plain string unchanged") - void leaves_plainString_unchanged() { - assertThat(EntitySpecification.escapeLikeWildcards("hello")).isEqualTo("hello"); - } - - @ParameterizedTest(name = "escapes ''{0}'' correctly") - @ValueSource(strings = {"%", "_", "%%", "__", "%_", "_%"}) - @DisplayName("escapes various wildcard combinations") - void escapes_wildcardCombinations(String input) { - String escaped = EntitySpecification.escapeLikeWildcards(input); - // Strip all valid escape sequences, then verify no bare wildcards remain - String stripped = escaped.replace("\\%", "").replace("\\_", "").replace("\\\\", ""); - assertThat(stripped).doesNotContain("%").doesNotContain("_"); - assertThat(escaped).contains("\\"); - } - } -} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/JpaPredicateBuilderTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/JpaPredicateBuilderTest.java new file mode 100644 index 00000000..552127a6 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/JpaPredicateBuilderTest.java @@ -0,0 +1,70 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.specification; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/// Unit tests for [JpaPredicateBuilder]. +/// +/// Focuses on the LIKE wildcard escaping logic which is security-critical and shared +/// between [EntitySpecification] and [EntitySearchSpecification]. +@DisplayName("JpaPredicateBuilder") +class JpaPredicateBuilderTest { + + @Nested + @DisplayName("escapeLikeWildcards") + class EscapeLikeWildcardsTests { + + @Test + @DisplayName("escapes percent sign") + void escapes_percent() { + assertThat(JpaPredicateBuilder.escapeLikeWildcards("100%")) + .isEqualTo("100\\%"); + } + + @Test + @DisplayName("escapes underscore") + void escapes_underscore() { + assertThat(JpaPredicateBuilder.escapeLikeWildcards("my_value")) + .isEqualTo("my\\_value"); + } + + @Test + @DisplayName("escapes backslash before other wildcards") + void escapes_backslash() { + assertThat(JpaPredicateBuilder.escapeLikeWildcards("path\\to%file")) + .isEqualTo("path\\\\to\\%file"); + } + + @Test + @DisplayName("escapes multiple wildcards") + void escapes_multipleWildcards() { + assertThat(JpaPredicateBuilder.escapeLikeWildcards("100%_success")) + .isEqualTo("100\\%\\_success"); + } + + @Test + @DisplayName("returns plain string unchanged") + void leaves_plainString_unchanged() { + assertThat(JpaPredicateBuilder.escapeLikeWildcards("hello")) + .isEqualTo("hello"); + } + + @ParameterizedTest(name = "escapes ''{0}'' correctly") + @ValueSource(strings = {"%", "_", "%%", "__", "%_", "_%"}) + @DisplayName("escapes various wildcard combinations") + void escapes_wildcardCombinations(String input) { + String escaped = JpaPredicateBuilder.escapeLikeWildcards(input); + // Strip all valid escape sequences, then verify no bare wildcards remain + String stripped = escaped.replace("\\%", "").replace("\\_", "").replace("\\\\", ""); + assertThat(stripped) + .doesNotContain("%") + .doesNotContain("_"); + assertThat(escaped).contains("\\"); + } + } +} diff --git a/src/test/resources/db/init/init-extensions.sql b/src/test/resources/db/init/init-extensions.sql new file mode 100644 index 00000000..144c6baf --- /dev/null +++ b/src/test/resources/db/init/init-extensions.sql @@ -0,0 +1,3 @@ +-- Initialize PostgreSQL extensions required by the application. +-- This script runs once when the Testcontainers PostgreSQL container starts. +CREATE EXTENSION IF NOT EXISTS pg_trgm; diff --git a/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byRelationNameContains.json b/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byRelationNameContains.json new file mode 100644 index 00000000..9a2a8a18 --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byRelationNameContains.json @@ -0,0 +1,32 @@ +{ + "content": [ + { + "identifier": "web-api-1", + "name": "Web API 1", + "properties": { "environment": "PROD", "programmingLanguage": "JAVA", "port": 8080.0 }, + "relations": { + "database": [ + { "identifier": "database-service-1", "name": "Database Service 1" } + ], + "api-link": [ + { "identifier": "microservice-1", "name": "Microservice 1" } + ] + }, + "relations_as_target": {}, + "template_identifier": "web-service" + }, + { + "identifier": "web-api-2", + "name": "Web API 2", + "properties": { "environment": "DEV", "programmingLanguage": "PYTHON", "port": 9090.0 }, + "relations": { + "database": [ + { "identifier": "cache-service-1", "name": "Cache Service 1" } + ] + }, + "relations_as_target": {}, + "template_identifier": "web-service" + } + ], + "page": { "size": 20, "number": 0, "total_elements": 2, "total_pages": 1 } +} diff --git a/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byRelationNameEq.json b/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byRelationNameEq.json new file mode 100644 index 00000000..72591748 --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byRelationNameEq.json @@ -0,0 +1,20 @@ +{ + "content": [ + { + "identifier": "web-api-1", + "name": "Web API 1", + "properties": { "environment": "PROD", "programmingLanguage": "JAVA", "port": 8080.0 }, + "relations": { + "database": [ + { "identifier": "database-service-1", "name": "Database Service 1" } + ], + "api-link": [ + { "identifier": "microservice-1", "name": "Microservice 1" } + ] + }, + "relations_as_target": {}, + "template_identifier": "web-service" + } + ], + "page": { "size": 20, "number": 0, "total_elements": 1, "total_pages": 1 } +} diff --git a/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byRelationsAsTarget.json b/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byRelationsAsTarget.json new file mode 100644 index 00000000..2b5f2bc2 --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byRelationsAsTarget.json @@ -0,0 +1,25 @@ +{ + "content": [ + { + "identifier": "microservice-1", + "name": "Microservice 1", + "properties": {}, + "relations": {}, + "relations_as_target": { + "api-link": [ + { + "identifier": "web-api-1", + "name": "Web API 1" + } + ] + }, + "template_identifier": "microservice" + } + ], + "page": { + "size": 20, + "number": 0, + "total_elements": 1, + "total_pages": 1 + } +} diff --git a/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byRelationsAsTargetPresence.json b/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byRelationsAsTargetPresence.json new file mode 100644 index 00000000..92fc6149 --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byRelationsAsTargetPresence.json @@ -0,0 +1,17 @@ +{ + "content": [ + { + "identifier": "microservice-1", + "name": "Microservice 1", + "properties": {}, + "relations": {}, + "relations_as_target": { + "api-link": [ + { "identifier": "web-api-1", "name": "Web API 1" } + ] + }, + "template_identifier": "microservice" + } + ], + "page": { "size": 20, "number": 0, "total_elements": 1, "total_pages": 1 } +} diff --git a/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byTemplateAndProperty.json b/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byTemplateAndProperty.json new file mode 100644 index 00000000..ec8b9e1b --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byTemplateAndProperty.json @@ -0,0 +1,38 @@ +{ + "content": [ + { + "identifier": "web-api-1", + "name": "Web API 1", + "properties": { "environment": "PROD", "programmingLanguage": "JAVA", "port": 8080.0 }, + "relations": { + "database": [ + { "identifier": "database-service-1", "name": "Database Service 1" } + ], + "api-link": [ + { "identifier": "microservice-1", "name": "Microservice 1" } + ] + }, + "relations_as_target": {}, + "template_identifier": "web-service" + }, + { + "identifier": "web-service-valid-1", + "name": "web-service-valid-1", + "properties": { + "teamName": "platform-team", + "environment": "DEV", + "baseUrl": "https://catalog.example.com", + "protocol": "HTTP", + "port": 8080.0, + "programmingLanguage": "JAVA", + "version": "1.2.3", + "applicationName": "catalog-api", + "ownerEmail": "owner@example.com" + }, + "relations": {}, + "relations_as_target": {}, + "template_identifier": "web-service" + } + ], + "page": { "size": 20, "number": 0, "total_elements": 2, "total_pages": 1 } +} diff --git a/src/test/resources/integration_test/json/entity/v1/searchEntities_200_neq.json b/src/test/resources/integration_test/json/entity/v1/searchEntities_200_neq.json new file mode 100644 index 00000000..06baa547 --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/searchEntities_200_neq.json @@ -0,0 +1,17 @@ +{ + "content": [ + { + "identifier": "web-api-2", + "name": "Web API 2", + "properties": { "environment": "DEV", "programmingLanguage": "PYTHON", "port": 9090.0 }, + "relations": { + "database": [ + { "identifier": "cache-service-1", "name": "Cache Service 1" } + ] + }, + "relations_as_target": {}, + "template_identifier": "web-service" + } + ], + "page": { "size": 20, "number": 0, "total_elements": 1, "total_pages": 1 } +} diff --git a/src/test/resources/integration_test/json/entity/v1/searchEntities_200_orTemplates.json b/src/test/resources/integration_test/json/entity/v1/searchEntities_200_orTemplates.json new file mode 100644 index 00000000..1e89d6a3 --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/searchEntities_200_orTemplates.json @@ -0,0 +1,25 @@ +{ + "content": [ + { + "identifier": "batch-job-1", + "name": "Batch Job 1", + "properties": {}, + "relations": {}, + "relations_as_target": {}, + "template_identifier": "batch-job" + }, + { + "identifier": "microservice-1", + "name": "Microservice 1", + "properties": {}, + "relations": {}, + "relations_as_target": { + "api-link": [ + { "identifier": "web-api-1", "name": "Web API 1" } + ] + }, + "template_identifier": "microservice" + } + ], + "page": { "size": 20, "number": 0, "total_elements": 2, "total_pages": 1 } +} diff --git a/src/test/resources/integration_test/json/entity/v1/searchEntities_200_startsWith.json b/src/test/resources/integration_test/json/entity/v1/searchEntities_200_startsWith.json new file mode 100644 index 00000000..72591748 --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/searchEntities_200_startsWith.json @@ -0,0 +1,20 @@ +{ + "content": [ + { + "identifier": "web-api-1", + "name": "Web API 1", + "properties": { "environment": "PROD", "programmingLanguage": "JAVA", "port": 8080.0 }, + "relations": { + "database": [ + { "identifier": "database-service-1", "name": "Database Service 1" } + ], + "api-link": [ + { "identifier": "microservice-1", "name": "Microservice 1" } + ] + }, + "relations_as_target": {}, + "template_identifier": "web-service" + } + ], + "page": { "size": 20, "number": 0, "total_elements": 1, "total_pages": 1 } +} From b21dfc5856e197a3e15c7f8e6e4f9cb60dae9547 Mon Sep 17 00:00:00 2001 From: evebrnd Date: Fri, 29 May 2026 16:47:02 +0200 Subject: [PATCH 2/7] fix: flyway script order --- ...mance_indexes.sql => V3_6__add_search_performance_indexes.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/main/resources/db/migration/{V3_5__add_search_performance_indexes.sql => V3_6__add_search_performance_indexes.sql} (100%) diff --git a/src/main/resources/db/migration/V3_5__add_search_performance_indexes.sql b/src/main/resources/db/migration/V3_6__add_search_performance_indexes.sql similarity index 100% rename from src/main/resources/db/migration/V3_5__add_search_performance_indexes.sql rename to src/main/resources/db/migration/V3_6__add_search_performance_indexes.sql From 9cbad522ea68fa0573a15b77857ed639b20f956c Mon Sep 17 00:00:00 2001 From: evebrnd Date: Fri, 29 May 2026 17:23:32 +0200 Subject: [PATCH 3/7] fix: restore fromEntitiesSearchPageToDtoPage in EntityDtoOutMapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Method was lost when resolving rebase conflict — the --ours strategy on EntityDtoOutMapper.java preserved main's Spotless formatting but dropped the new search-page mapping method added by the branch. Also applies Spotless formatting to all files touched during rebase resolution that still had indentation/whitespace violations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../domain/constant/FilterConstraints.java | 9 +- .../domain/constant/SearchConstraints.java | 24 +- .../domain/constant/ValidationMessages.java | 56 +- .../filter/InvalidFilterDslException.java | 6 +- .../search/InvalidSearchQueryException.java | 6 +- .../model/entity/RawSearchFilterNode.java | 33 +- .../domain/model/entity/SearchFilterNode.java | 34 +- .../domain/model/enums/LogicalConnector.java | 3 +- .../domain/model/enums/SearchOperator.java | 11 +- .../domain/port/EntityRepositoryPort.java | 3 +- .../domain/service/entity/EntityService.java | 138 +-- .../service/filter/EntityFilterDslParser.java | 400 +++---- .../service/search/SearchFilterParser.java | 116 +- .../search/SearchFilterValidationService.java | 213 ++-- .../api/configuration/SwaggerDescription.java | 34 +- .../api/controller/EntityController.java | 159 +-- .../api/dto/in/EntitySearchRequestDtoIn.java | 42 +- .../adapters/api/dto/in/FilterNodeDtoIn.java | 16 +- .../api/handler/ApiExceptionHandler.java | 16 +- .../api/mapper/entity/EntityDtoOutMapper.java | 42 + .../api/mapper/entity/SearchFilterMapper.java | 36 +- .../persistence/PostgresEntityAdapter.java | 24 +- .../EntityFilterSpecification.java | 342 +++--- .../EntitySearchSpecification.java | 494 ++++----- .../specification/JpaPredicateBuilder.java | 165 +-- .../idp_core/AbstractIntegrationTest.java | 1 - .../service/entity/EntityServiceTest.java | 9 +- .../filter/EntityFilterDslParserTest.java | 994 +++++++++--------- .../search/SearchFilterParserTest.java | 369 ++++--- .../SearchFilterValidationServiceTest.java | 747 +++++++------ .../api/controller/EntityControllerTest.java | 788 ++++++-------- .../EntitySearchSpecificationTest.java | 440 ++++---- .../JpaPredicateBuilderTest.java | 85 +- 33 files changed, 2883 insertions(+), 2972 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/constant/FilterConstraints.java b/src/main/java/com/decathlon/idp_core/domain/constant/FilterConstraints.java index 926c3d65..42fd1559 100644 --- a/src/main/java/com/decathlon/idp_core/domain/constant/FilterConstraints.java +++ b/src/main/java/com/decathlon/idp_core/domain/constant/FilterConstraints.java @@ -7,9 +7,10 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public final class FilterConstraints { - /// Maximum number of filter criteria per `q` query string (DoS prevention). - public static final int MAX_CRITERIA_COUNT = 10; + /// Maximum number of filter criteria per `q` query string (DoS prevention). + public static final int MAX_CRITERIA_COUNT = 10; - /// Maximum length (in characters) of a key or value in a single filter criterion. - public static final int MAX_KEY_VALUE_LENGTH = 255; + /// Maximum length (in characters) of a key or value in a single filter + /// criterion. + public static final int MAX_KEY_VALUE_LENGTH = 255; } diff --git a/src/main/java/com/decathlon/idp_core/domain/constant/SearchConstraints.java b/src/main/java/com/decathlon/idp_core/domain/constant/SearchConstraints.java index 75f4ae2f..49cd82cf 100644 --- a/src/main/java/com/decathlon/idp_core/domain/constant/SearchConstraints.java +++ b/src/main/java/com/decathlon/idp_core/domain/constant/SearchConstraints.java @@ -9,19 +9,21 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public final class SearchConstraints { - /// Maximum number of entities returned per page in a search request. - public static final int MAX_PAGE_SIZE = 500; + /// Maximum number of entities returned per page in a search request. + public static final int MAX_PAGE_SIZE = 500; - /// Maximum length (in characters) of the free-text `query` parameter. - public static final int MAX_QUERY_LENGTH = 255; + /// Maximum length (in characters) of the free-text `query` parameter. + public static final int MAX_QUERY_LENGTH = 255; - /// Maximum nesting depth of a [com.decathlon.idp_core.domain.model.entity.SearchFilterNode] tree. - public static final int MAX_NESTING_DEPTH = 5; + /// Maximum nesting depth of a + /// [com.decathlon.idp_core.domain.model.entity.SearchFilterNode] tree. + public static final int MAX_NESTING_DEPTH = 5; - /// Maximum total number of criterion nodes across a - /// [com.decathlon.idp_core.domain.model.entity.SearchFilterNode] tree. - public static final int MAX_TOTAL_CRITERIA = 50; + /// Maximum total number of criterion nodes across a + /// [com.decathlon.idp_core.domain.model.entity.SearchFilterNode] tree. + public static final int MAX_TOTAL_CRITERIA = 50; - /// Fields on which search results may be sorted. - public static final Set ALLOWED_SORT_FIELDS = Set.of("identifier", "name", "templateIdentifier"); + /// Fields on which search results may be sorted. + public static final Set ALLOWED_SORT_FIELDS = Set.of("identifier", "name", + "templateIdentifier"); } diff --git a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java index c49d6dd5..3e12f1e4 100644 --- a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java +++ b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java @@ -83,34 +83,34 @@ public static String minMaxConstraintViolated(String constraint) { return PROPERTY_RULES_MIN_MAX_CONSTRAINT_VIOLATED.replace("{constraint}", constraint); } - // Filter query validation messages - public static final String FILTER_TOO_MANY_CRITERIA = "Filter query exceeds maximum of %d criteria"; - public static final String FILTER_VALUE_TOO_LONG = "Filter value must not exceed %d characters in criterion '%s'"; - public static final String FILTER_KEY_TOO_LONG = "Filter key must not exceed %d characters in criterion '%s'"; - public static final String FILTER_INVALID_FORMAT = "Invalid query format, expected field:operator:value"; - public static final String FILTER_DUPLICATE_CRITERION = "Multiple filters for the same property are not supported"; - public static final String FILTER_TYPE_MISMATCH = "Operation '%s' is not applicable for field '%s'."; - public static final String FILTER_PROPERTY_TYPE_NOT_NUMERIC = "Operation '%s' is not applicable for property '%s': only NUMBER properties support comparison operators."; + // Filter query validation messages + public static final String FILTER_TOO_MANY_CRITERIA = "Filter query exceeds maximum of %d criteria"; + public static final String FILTER_VALUE_TOO_LONG = "Filter value must not exceed %d characters in criterion '%s'"; + public static final String FILTER_KEY_TOO_LONG = "Filter key must not exceed %d characters in criterion '%s'"; + public static final String FILTER_INVALID_FORMAT = "Invalid query format, expected field:operator:value"; + public static final String FILTER_DUPLICATE_CRITERION = "Multiple filters for the same property are not supported"; + public static final String FILTER_TYPE_MISMATCH = "Operation '%s' is not applicable for field '%s'."; + public static final String FILTER_PROPERTY_TYPE_NOT_NUMERIC = "Operation '%s' is not applicable for property '%s': only NUMBER properties support comparison operators."; - // Search filter validation messages - public static final String SEARCH_INVALID_CONNECTOR = "Invalid connector '%s'. Supported values: AND, OR"; - public static final String SEARCH_INVALID_OPERATOR = "Invalid operation '%s'. Supported values: EQ, NEQ, CONTAINS, NOT_CONTAINS, STARTS_WITH, ENDS_WITH, GT, GTE, LT, LTE"; - public static final String SEARCH_INVALID_FIELD = "Unknown field '%s'. Supported fields: template, identifier, name, relation, property.{name}, relation.{name}, relation.{name}.identifier, relation.{name}.name, relations_as_target, relations_as_target.{name}.identifier, relations_as_target.{name}.name"; - public static final String SEARCH_TOO_MANY_CRITERIA = "Search filter exceeds maximum of %d total criteria"; - public static final String SEARCH_NESTING_TOO_DEEP = "Search filter exceeds maximum nesting depth of %d"; - public static final String SEARCH_CRITERION_MISSING_FIELD = "A criterion node must have a non-blank 'field'"; - public static final String SEARCH_CRITERION_MISSING_OPERATION = "A criterion node must have a non-blank 'operation'"; - public static final String SEARCH_CRITERION_MISSING_VALUE = "A criterion node must have a non-blank 'value'"; - public static final String SEARCH_GROUP_MISSING_CONNECTOR = "A group node must have a non-blank 'connector'"; - public static final String SEARCH_GROUP_MISSING_CRITERIA = "A group node must have a non-empty 'criteria' list"; - public static final String SEARCH_QUERY_TOO_LONG = "Search query must not exceed %d characters"; - public static final String SEARCH_NUMERIC_OPERATOR_REQUIRES_PROPERTY = "Operator '%s' is only valid for property.{name} fields"; - public static final String SEARCH_INVALID_SORT_FIELD = "Invalid sort field '%s'. Supported fields: identifier, name, templateIdentifier"; - public static final String SEARCH_INVALID_SORT_FORMAT = "Invalid sort expression '%s'. Expected format: field or field:asc|desc"; - public static final String SEARCH_PAGE_SIZE_TOO_LARGE = "Page size must not exceed %d"; - public static final String SEARCH_PAGE_INVALID = "Page index must be 0 or greater"; - public static final String SEARCH_SIZE_INVALID = "Page size must be greater than 0"; - public static final String SEARCH_NUMERIC_OPERATOR_INVALID_VALUE = "Value '%s' is not a valid number for operator '%s'"; - public static final String SEARCH_NUMERIC_OPERATOR_PROPERTY_TYPE_MISMATCH = "Property '%s' in template '%s' is of type %s; operators GT, GTE, LT, LTE require type NUMBER"; + // Search filter validation messages + public static final String SEARCH_INVALID_CONNECTOR = "Invalid connector '%s'. Supported values: AND, OR"; + public static final String SEARCH_INVALID_OPERATOR = "Invalid operation '%s'. Supported values: EQ, NEQ, CONTAINS, NOT_CONTAINS, STARTS_WITH, ENDS_WITH, GT, GTE, LT, LTE"; + public static final String SEARCH_INVALID_FIELD = "Unknown field '%s'. Supported fields: template, identifier, name, relation, property.{name}, relation.{name}, relation.{name}.identifier, relation.{name}.name, relations_as_target, relations_as_target.{name}.identifier, relations_as_target.{name}.name"; + public static final String SEARCH_TOO_MANY_CRITERIA = "Search filter exceeds maximum of %d total criteria"; + public static final String SEARCH_NESTING_TOO_DEEP = "Search filter exceeds maximum nesting depth of %d"; + public static final String SEARCH_CRITERION_MISSING_FIELD = "A criterion node must have a non-blank 'field'"; + public static final String SEARCH_CRITERION_MISSING_OPERATION = "A criterion node must have a non-blank 'operation'"; + public static final String SEARCH_CRITERION_MISSING_VALUE = "A criterion node must have a non-blank 'value'"; + public static final String SEARCH_GROUP_MISSING_CONNECTOR = "A group node must have a non-blank 'connector'"; + public static final String SEARCH_GROUP_MISSING_CRITERIA = "A group node must have a non-empty 'criteria' list"; + public static final String SEARCH_QUERY_TOO_LONG = "Search query must not exceed %d characters"; + public static final String SEARCH_NUMERIC_OPERATOR_REQUIRES_PROPERTY = "Operator '%s' is only valid for property.{name} fields"; + public static final String SEARCH_INVALID_SORT_FIELD = "Invalid sort field '%s'. Supported fields: identifier, name, templateIdentifier"; + public static final String SEARCH_INVALID_SORT_FORMAT = "Invalid sort expression '%s'. Expected format: field or field:asc|desc"; + public static final String SEARCH_PAGE_SIZE_TOO_LARGE = "Page size must not exceed %d"; + public static final String SEARCH_PAGE_INVALID = "Page index must be 0 or greater"; + public static final String SEARCH_SIZE_INVALID = "Page size must be greater than 0"; + public static final String SEARCH_NUMERIC_OPERATOR_INVALID_VALUE = "Value '%s' is not a valid number for operator '%s'"; + public static final String SEARCH_NUMERIC_OPERATOR_PROPERTY_TYPE_MISMATCH = "Property '%s' in template '%s' is of type %s; operators GT, GTE, LT, LTE require type NUMBER"; } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/filter/InvalidFilterDslException.java b/src/main/java/com/decathlon/idp_core/domain/exception/filter/InvalidFilterDslException.java index aacd82ce..5cffcc16 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/filter/InvalidFilterDslException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/filter/InvalidFilterDslException.java @@ -7,7 +7,7 @@ /// mapped to HTTP 400 Bad Request by the infrastructure layer. public class InvalidFilterDslException extends RuntimeException { - public InvalidFilterDslException(String message) { - super(message); - } + public InvalidFilterDslException(String message) { + super(message); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/search/InvalidSearchQueryException.java b/src/main/java/com/decathlon/idp_core/domain/exception/search/InvalidSearchQueryException.java index 3506f855..a0723bfd 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/search/InvalidSearchQueryException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/search/InvalidSearchQueryException.java @@ -7,7 +7,7 @@ /// HTTP 400 Bad Request by the infrastructure layer. public class InvalidSearchQueryException extends RuntimeException { - public InvalidSearchQueryException(String message) { - super(message); - } + public InvalidSearchQueryException(String message) { + super(message); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/RawSearchFilterNode.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/RawSearchFilterNode.java index 46553473..e5858526 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/RawSearchFilterNode.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/RawSearchFilterNode.java @@ -16,21 +16,24 @@ /// - [Criterion] — a leaf predicate with raw field, operation, and value strings public sealed interface RawSearchFilterNode { - /// A logical group combining multiple child [RawSearchFilterNode]s. - /// - /// @param connector raw connector string (e.g. "AND", "OR"); may be null or blank until validated - /// @param nodes child nodes; may be null until validated - record Group(String connector, List nodes) implements RawSearchFilterNode { - public Group { - nodes = nodes != null ? List.copyOf(nodes) : List.of(); - } + /// A logical group combining multiple child [RawSearchFilterNode]s. + /// + /// @param connector raw connector string (e.g. "AND", "OR"); may be null or + /// blank until validated + /// @param nodes child nodes; may be null until validated + record Group(String connector, List nodes) implements RawSearchFilterNode { + public Group { + nodes = nodes != null ? List.copyOf(nodes) : List.of(); } + } - /// A leaf predicate in the filter tree. - /// - /// @param field raw field name (e.g. "template", "property.language"); may be null until validated - /// @param operation raw operation string (e.g. "EQ", "CONTAINS"); may be null until validated - /// @param value raw value string; may be null until validated - record Criterion(String field, String operation, String value) implements RawSearchFilterNode { - } + /// A leaf predicate in the filter tree. + /// + /// @param field raw field name (e.g. "template", "property.language"); may be + /// null until validated + /// @param operation raw operation string (e.g. "EQ", "CONTAINS"); may be null + /// until validated + /// @param value raw value string; may be null until validated + record Criterion(String field, String operation, String value) implements RawSearchFilterNode { + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/SearchFilterNode.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/SearchFilterNode.java index 2aee81bc..996f4ad5 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/SearchFilterNode.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/SearchFilterNode.java @@ -29,21 +29,25 @@ /// - `relations_as_target.{name}.name` — filters by source entity name in a reverse relation public sealed interface SearchFilterNode { - /// A logical group combining multiple child [SearchFilterNode]s with a connector. - /// - /// @param connector how child nodes are logically combined - /// @param nodes child nodes; an empty list matches all entities - record Group(LogicalConnector connector, List nodes) implements SearchFilterNode { - public Group { - nodes = nodes != null ? List.copyOf(nodes) : List.of(); - } + /// A logical group combining multiple child [SearchFilterNode]s with a + /// connector. + /// + /// @param connector how child nodes are logically combined + /// @param nodes child nodes; an empty list matches all entities + record Group(LogicalConnector connector, + List nodes) implements SearchFilterNode { + public Group { + nodes = nodes != null ? List.copyOf(nodes) : List.of(); } + } - /// A leaf predicate in the search filter tree. - /// - /// @param field the entity field to filter on (see [SearchFilterNode] for supported fields) - /// @param operation the comparison operator to apply - /// @param value the value to compare against - record Criterion(String field, SearchOperator operation, String value) implements SearchFilterNode { - } + /// A leaf predicate in the search filter tree. + /// + /// @param field the entity field to filter on (see [SearchFilterNode] for + /// supported fields) + /// @param operation the comparison operator to apply + /// @param value the value to compare against + record Criterion(String field, SearchOperator operation, + String value) implements SearchFilterNode { + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/enums/LogicalConnector.java b/src/main/java/com/decathlon/idp_core/domain/model/enums/LogicalConnector.java index b167f3ac..aa05674f 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/enums/LogicalConnector.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/enums/LogicalConnector.java @@ -6,6 +6,5 @@ /// - [AND] — all child nodes must match /// - [OR] — at least one child node must match public enum LogicalConnector { - AND, - OR + AND, OR } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/enums/SearchOperator.java b/src/main/java/com/decathlon/idp_core/domain/model/enums/SearchOperator.java index 6b861c0f..501baaec 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/enums/SearchOperator.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/enums/SearchOperator.java @@ -14,14 +14,5 @@ /// - [LT] requires the field to be strictly less than the value /// - [LTE] requires the field to be less than or equal to the value public enum SearchOperator { - EQ, - NEQ, - CONTAINS, - NOT_CONTAINS, - STARTS_WITH, - ENDS_WITH, - GT, - GTE, - LT, - LTE + EQ, NEQ, CONTAINS, NOT_CONTAINS, STARTS_WITH, ENDS_WITH, GT, GTE, LT, LTE } diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java index 9856a4f7..c5b61afa 100644 --- a/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java @@ -52,7 +52,8 @@ Page findByTemplateIdentifierWithFilter(String templateIdentifier, Entit void deletePropertiesByTemplateIdentifierAndPropertyName(String templateIdentifier, Collection propertyNames); - void deleteRelationsByTemplateIdentifierAndRelationName(String templateIdentifier, Collection relationNames); + void deleteRelationsByTemplateIdentifierAndRelationName(String templateIdentifier, + Collection relationNames); Page search(SearchFilterNode filter, String query, Pageable pageable); } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java index 92c6c07f..9bd33401 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java @@ -25,10 +25,10 @@ import com.decathlon.idp_core.domain.model.entity.SearchFilterNode; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; -import com.decathlon.idp_core.domain.service.filter.EntityFilterDslParser; -import com.decathlon.idp_core.domain.service.search.SearchFilterValidationService; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; +import com.decathlon.idp_core.domain.service.filter.EntityFilterDslParser; +import com.decathlon.idp_core.domain.service.search.SearchFilterValidationService; import lombok.RequiredArgsConstructor; @@ -48,12 +48,12 @@ @Validated @RequiredArgsConstructor public class EntityService { - private final EntityRepositoryPort entityRepository; - private final EntityValidationService entityValidationService; - private final EntityTemplateValidationService entityTemplateValidationService; - private final EntityTemplateService entityTemplateService; - private final EntityFilterDslParser entityQueryParserService; - private final SearchFilterValidationService searchFilterValidationService; + private final EntityRepositoryPort entityRepository; + private final EntityValidationService entityValidationService; + private final EntityTemplateValidationService entityTemplateValidationService; + private final EntityTemplateService entityTemplateService; + private final EntityFilterDslParser entityQueryParserService; + private final SearchFilterValidationService searchFilterValidationService; /// Retrieves entities filtered by template with optional query filter. /// @@ -167,66 +167,74 @@ public Entity updateEntity(String templateIdentifier, String entityIdentifier, return entityRepository.save(entityToSave); } - /// Searches for entities across all templates using a nested filter tree and optional free-text query. - /// - /// **Contract:** Executes a global entity search using the provided filter tree and optional text query. - /// Not scoped to a single template; include a template criterion in the filter - /// to scope the result to a specific template. Validates and builds pagination internally. - /// - /// @param filter root node of the search filter tree; an empty group returns all entities - /// @param query optional free-text string searched across identifier, name, templateIdentifier, - /// and all property values; null means no text restriction - /// @param page zero-based page index; must be 0 or greater - /// @param size number of items per page; must be between 1 and [SearchConstraints#MAX_PAGE_SIZE] - /// @param sort optional sort expression in the form `field` or `field:asc|desc; - /// null or blank means default ordering - /// @return paginated entities matching the filter and query - /// @throws InvalidSearchQueryException when page, size, or sort parameters are invalid - @Transactional - public Page searchEntities(SearchFilterNode filter, String query, int page, int size, String sort) { - searchFilterValidationService.validate(filter, query); - Pageable pageable = buildPageable(page, size, sort); - return entityRepository.search(filter, query, pageable); - } + /// Searches for entities across all templates using a nested filter tree and + /// optional free-text query. + /// + /// **Contract:** Executes a global entity search using the provided filter tree + /// and optional text query. + /// Not scoped to a single template; include a template criterion in the filter + /// to scope the result to a specific template. Validates and builds pagination + /// internally. + /// + /// @param filter root node of the search filter tree; an empty group returns + /// all entities + /// @param query optional free-text string searched across identifier, name, + /// templateIdentifier, + /// and all property values; null means no text restriction + /// @param page zero-based page index; must be 0 or greater + /// @param size number of items per page; must be between 1 and + /// [SearchConstraints#MAX_PAGE_SIZE] + /// @param sort optional sort expression in the form `field` or `field:asc|desc; + /// null or blank means default ordering + /// @return paginated entities matching the filter and query + /// @throws InvalidSearchQueryException when page, size, or sort parameters are + /// invalid + @Transactional + public Page searchEntities(SearchFilterNode filter, String query, int page, int size, + String sort) { + searchFilterValidationService.validate(filter, query); + Pageable pageable = buildPageable(page, size, sort); + return entityRepository.search(filter, query, pageable); + } - private Pageable buildPageable(int page, int size, String sort) { - if (page < 0) { - throw new InvalidSearchQueryException(ValidationMessages.SEARCH_PAGE_INVALID); - } - if (size <= 0) { - throw new InvalidSearchQueryException(ValidationMessages.SEARCH_SIZE_INVALID); - } - if (size > SearchConstraints.MAX_PAGE_SIZE) { - throw new InvalidSearchQueryException( - ValidationMessages.SEARCH_PAGE_SIZE_TOO_LARGE.formatted(SearchConstraints.MAX_PAGE_SIZE)); - } - if (sort == null || sort.isBlank()) { - return PageRequest.of(page, size); - } - return PageRequest.of(page, size, parseSortExpression(sort)); + private Pageable buildPageable(int page, int size, String sort) { + if (page < 0) { + throw new InvalidSearchQueryException(ValidationMessages.SEARCH_PAGE_INVALID); + } + if (size <= 0) { + throw new InvalidSearchQueryException(ValidationMessages.SEARCH_SIZE_INVALID); + } + if (size > SearchConstraints.MAX_PAGE_SIZE) { + throw new InvalidSearchQueryException( + ValidationMessages.SEARCH_PAGE_SIZE_TOO_LARGE.formatted(SearchConstraints.MAX_PAGE_SIZE)); + } + if (sort == null || sort.isBlank()) { + return PageRequest.of(page, size); } + return PageRequest.of(page, size, parseSortExpression(sort)); + } - private Sort parseSortExpression(String sortExpression) { - String[] parts = sortExpression.split(":"); - if (parts.length > 2) { - throw new InvalidSearchQueryException( - ValidationMessages.SEARCH_INVALID_SORT_FORMAT.formatted(sortExpression)); - } - String property = parts[0].trim(); - if (!SearchConstraints.ALLOWED_SORT_FIELDS.contains(property)) { - throw new InvalidSearchQueryException( - ValidationMessages.SEARCH_INVALID_SORT_FIELD.formatted(property)); - } - if (parts.length == 1) { - return Sort.by(Sort.Direction.ASC, property); - } - String direction = parts[1].trim().toLowerCase(); - return switch (direction) { - case "asc" -> Sort.by(Sort.Direction.ASC, property); - case "desc" -> Sort.by(Sort.Direction.DESC, property); - default -> throw new InvalidSearchQueryException( - ValidationMessages.SEARCH_INVALID_SORT_FORMAT.formatted(sortExpression)); - }; + private Sort parseSortExpression(String sortExpression) { + String[] parts = sortExpression.split(":"); + if (parts.length > 2) { + throw new InvalidSearchQueryException( + ValidationMessages.SEARCH_INVALID_SORT_FORMAT.formatted(sortExpression)); + } + String property = parts[0].trim(); + if (!SearchConstraints.ALLOWED_SORT_FIELDS.contains(property)) { + throw new InvalidSearchQueryException( + ValidationMessages.SEARCH_INVALID_SORT_FIELD.formatted(property)); } + if (parts.length == 1) { + return Sort.by(Sort.Direction.ASC, property); + } + String direction = parts[1].trim().toLowerCase(); + return switch (direction) { + case "asc" -> Sort.by(Sort.Direction.ASC, property); + case "desc" -> Sort.by(Sort.Direction.DESC, property); + default -> throw new InvalidSearchQueryException( + ValidationMessages.SEARCH_INVALID_SORT_FORMAT.formatted(sortExpression)); + }; + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/filter/EntityFilterDslParser.java b/src/main/java/com/decathlon/idp_core/domain/service/filter/EntityFilterDslParser.java index dd3a618a..8d1bddfd 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/filter/EntityFilterDslParser.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/filter/EntityFilterDslParser.java @@ -6,11 +6,11 @@ import java.util.Set; import java.util.stream.Stream; -import com.decathlon.idp_core.domain.exception.filter.InvalidFilterDslException; import org.springframework.stereotype.Service; import com.decathlon.idp_core.domain.constant.FilterConstraints; import com.decathlon.idp_core.domain.constant.ValidationMessages; +import com.decathlon.idp_core.domain.exception.filter.InvalidFilterDslException; import com.decathlon.idp_core.domain.model.entity.EntityFilter; import com.decathlon.idp_core.domain.model.entity.FilterCriterion; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; @@ -43,234 +43,238 @@ @Service public class EntityFilterDslParser { - private static final String RELATION = "relation"; - private static final String RELATIONS_AS_TARGET = "relations_as_target"; - private static final String PROPERTY_PREFIX = "property."; - private static final String RELATION_PREFIX = "relation."; - private static final String RELATIONS_AS_TARGET_PREFIX = "relations_as_target."; - private static final Set VALID_ATTRIBUTE_NAMES = Set.of("identifier", "name"); - - private static final Set COMPARISON_INCOMPATIBLE_TYPES = Set.of( - FilterKeyType.ATTRIBUTE, - FilterKeyType.RELATION_NAME, - FilterKeyType.RELATION_ENTITY, - FilterKeyType.RELATION_PROPERTY, - FilterKeyType.RELATIONS_AS_TARGET_NAME, - FilterKeyType.RELATIONS_AS_TARGET_PROPERTY); - - /// Parses a query string into an [EntityFilter]. - /// - /// @param query the raw `q` parameter value; may be null or blank - /// @return an [EntityFilter] with parsed criteria, or [EntityFilter#empty()] when query is blank - /// @throws InvalidFilterDslException when the query string is malformed or exceeds safety limits - public EntityFilter parse(String query) { - if (query == null || query.isBlank()) { - return EntityFilter.empty(); - } - - List criteria = Stream.of(query.split(";")) - .filter(token -> !token.isBlank()) - .map(token -> parseCriterion(token.trim())) - .toList(); - - if (criteria.size() > FilterConstraints.MAX_CRITERIA_COUNT) { - throw new InvalidFilterDslException( - ValidationMessages.FILTER_TOO_MANY_CRITERIA.formatted(FilterConstraints.MAX_CRITERIA_COUNT)); - } + private static final String RELATION = "relation"; + private static final String RELATIONS_AS_TARGET = "relations_as_target"; + private static final String PROPERTY_PREFIX = "property."; + private static final String RELATION_PREFIX = "relation."; + private static final String RELATIONS_AS_TARGET_PREFIX = "relations_as_target."; + private static final Set VALID_ATTRIBUTE_NAMES = Set.of("identifier", "name"); + + private static final Set COMPARISON_INCOMPATIBLE_TYPES = Set.of( + FilterKeyType.ATTRIBUTE, FilterKeyType.RELATION_NAME, FilterKeyType.RELATION_ENTITY, + FilterKeyType.RELATION_PROPERTY, FilterKeyType.RELATIONS_AS_TARGET_NAME, + FilterKeyType.RELATIONS_AS_TARGET_PROPERTY); + + /// Parses a query string into an [EntityFilter]. + /// + /// @param query the raw `q` parameter value; may be null or blank + /// @return an [EntityFilter] with parsed criteria, or [EntityFilter#empty()] + /// when query is blank + /// @throws InvalidFilterDslException when the query string is malformed or + /// exceeds safety limits + public EntityFilter parse(String query) { + if (query == null || query.isBlank()) { + return EntityFilter.empty(); + } - validateNoDuplicates(criteria); + List criteria = Stream.of(query.split(";")).filter(token -> !token.isBlank()) + .map(token -> parseCriterion(token.trim())).toList(); - return new EntityFilter(criteria); + if (criteria.size() > FilterConstraints.MAX_CRITERIA_COUNT) { + throw new InvalidFilterDslException(ValidationMessages.FILTER_TOO_MANY_CRITERIA + .formatted(FilterConstraints.MAX_CRITERIA_COUNT)); } - private FilterCriterion parseCriterion(String token) { - int operatorIndex = findOperatorIndex(token) - .orElseThrow(() -> new InvalidFilterDslException(ValidationMessages.FILTER_INVALID_FORMAT)); + validateNoDuplicates(criteria); - var rawKey = token.substring(0, operatorIndex); - var operatorChar = token.charAt(operatorIndex); - var value = token.substring(operatorIndex + 1); + return new EntityFilter(criteria); + } - validateKey(rawKey, token); - validateValue(value, token); - validateLength(rawKey, value, token); + private FilterCriterion parseCriterion(String token) { + int operatorIndex = findOperatorIndex(token) + .orElseThrow(() -> new InvalidFilterDslException(ValidationMessages.FILTER_INVALID_FORMAT)); - var operator = toOperator(operatorChar); - var criterion = buildCriterion(rawKey, operator, value, token); - validateOperatorCompatibility(criterion.keyType(), operator, rawKey); - return criterion; - } + var rawKey = token.substring(0, operatorIndex); + var operatorChar = token.charAt(operatorIndex); + var value = token.substring(operatorIndex + 1); - private OptionalInt findOperatorIndex(String token) { - for (int i = 0; i < token.length(); i++) { - char c = token.charAt(i); - if (c == '=' || c == ':' || c == '<' || c == '>') { - return OptionalInt.of(i); - } - } - return OptionalInt.empty(); - } + validateKey(rawKey, token); + validateValue(value, token); + validateLength(rawKey, value, token); + + var operator = toOperator(operatorChar); + var criterion = buildCriterion(rawKey, operator, value, token); + validateOperatorCompatibility(criterion.keyType(), operator, rawKey); + return criterion; + } - private FilterOperator toOperator(char c) { - return switch (c) { - case '=' -> FilterOperator.EQUALS; - case ':' -> FilterOperator.CONTAINS; - case '<' -> FilterOperator.LESS_THAN; - case '>' -> FilterOperator.GREATER_THAN; - default -> throw new InvalidFilterDslException("Unknown operator character: " + c); - }; + private OptionalInt findOperatorIndex(String token) { + for (int i = 0; i < token.length(); i++) { + char c = token.charAt(i); + if (c == '=' || c == ':' || c == '<' || c == '>') { + return OptionalInt.of(i); + } + } + return OptionalInt.empty(); + } + + private FilterOperator toOperator(char c) { + return switch (c) { + case '=' -> FilterOperator.EQUALS; + case ':' -> FilterOperator.CONTAINS; + case '<' -> FilterOperator.LESS_THAN; + case '>' -> FilterOperator.GREATER_THAN; + default -> throw new InvalidFilterDslException("Unknown operator character: " + c); + }; + } + + private FilterCriterion buildCriterion(String rawKey, FilterOperator operator, String value, + String token) { + // Direct attribute filters (relation=X means filter by relation name) + if (RELATION.equals(rawKey)) { + validateKeyName(value, token); + return new FilterCriterion(FilterKeyType.RELATION_NAME, "", operator, value); } - private FilterCriterion buildCriterion(String rawKey, FilterOperator operator, String value, String token) { - // Direct attribute filters (relation=X means filter by relation name) - if (RELATION.equals(rawKey)) { - validateKeyName(value, token); - return new FilterCriterion(FilterKeyType.RELATION_NAME, "", operator, value); - } - - if (RELATIONS_AS_TARGET.equals(rawKey)) { - validateKeyName(value, token); - return new FilterCriterion(FilterKeyType.RELATIONS_AS_TARGET_NAME, "", operator, value); - } - - if (rawKey.startsWith(PROPERTY_PREFIX)) { - var keyName = rawKey.substring(PROPERTY_PREFIX.length()); - validateKeyName(keyName, token); - return new FilterCriterion(FilterKeyType.PROPERTY, keyName, operator, value); - } - - if (rawKey.startsWith(RELATIONS_AS_TARGET_PREFIX)) { - var relationPart = rawKey.substring(RELATIONS_AS_TARGET_PREFIX.length()); - validateKey(relationPart, token); - return buildRelationsAsTargetCriterion(relationPart, operator, value, token); - } - - if (rawKey.startsWith(RELATION_PREFIX)) { - var relationPart = rawKey.substring(RELATION_PREFIX.length()); - validateKey(relationPart, token); - return buildRelationCriterion(relationPart, operator, value, token); - } - - if (!VALID_ATTRIBUTE_NAMES.contains(rawKey)) { - throw new InvalidFilterDslException( - "Unknown attribute '%s' in filter criterion '%s'. Valid attributes: %s" - .formatted(rawKey, token, VALID_ATTRIBUTE_NAMES)); - } - return new FilterCriterion(FilterKeyType.ATTRIBUTE, rawKey, operator, value); + if (RELATIONS_AS_TARGET.equals(rawKey)) { + validateKeyName(value, token); + return new FilterCriterion(FilterKeyType.RELATIONS_AS_TARGET_NAME, "", operator, value); } - private FilterCriterion buildRelationsAsTargetCriterion(String relationPart, FilterOperator operator, String value, String token) { - int dotIndex = relationPart.indexOf('.'); - if (dotIndex <= 0) { - throw new InvalidFilterDslException( - "Invalid filter criterion '%s': relations_as_target requires the form 'relations_as_target..'" - .formatted(token)); - } - - var relationName = relationPart.substring(0, dotIndex); - var propertyName = relationPart.substring(dotIndex + 1); - validateKeyName(relationName, token); - validatePropertyName(propertyName, RELATIONS_AS_TARGET, token); - var compositeKey = relationName + "." + propertyName; - return new FilterCriterion(FilterKeyType.RELATIONS_AS_TARGET_PROPERTY, compositeKey, operator, value); + if (rawKey.startsWith(PROPERTY_PREFIX)) { + var keyName = rawKey.substring(PROPERTY_PREFIX.length()); + validateKeyName(keyName, token); + return new FilterCriterion(FilterKeyType.PROPERTY, keyName, operator, value); } - private FilterCriterion buildRelationCriterion(String relationPart, FilterOperator operator, String value, String token) { - int dotIndex = relationPart.indexOf('.'); - if (dotIndex > 0) { - var relationName = relationPart.substring(0, dotIndex); - var propertyName = relationPart.substring(dotIndex + 1); - validateKeyName(relationName, token); - validatePropertyName(propertyName, RELATION, token); - var compositeKey = relationName + "." + propertyName; - return new FilterCriterion(FilterKeyType.RELATION_PROPERTY, compositeKey, operator, value); - } - - // Default: relation entity filter - validateKeyName(relationPart, token); - return new FilterCriterion(FilterKeyType.RELATION_ENTITY, relationPart, operator, value); + if (rawKey.startsWith(RELATIONS_AS_TARGET_PREFIX)) { + var relationPart = rawKey.substring(RELATIONS_AS_TARGET_PREFIX.length()); + validateKey(relationPart, token); + return buildRelationsAsTargetCriterion(relationPart, operator, value, token); } - private void validateNoDuplicates(List criteria) { - Set seen = new HashSet<>(); - for (FilterCriterion criterion : criteria) { - String dedupeKey = criterion.keyType().name() + ":" + criterion.key(); - if (!seen.add(dedupeKey)) { - throw new InvalidFilterDslException(ValidationMessages.FILTER_DUPLICATE_CRITERION); - } - } + if (rawKey.startsWith(RELATION_PREFIX)) { + var relationPart = rawKey.substring(RELATION_PREFIX.length()); + validateKey(relationPart, token); + return buildRelationCriterion(relationPart, operator, value, token); } - private void validateOperatorCompatibility(FilterKeyType keyType, FilterOperator operator, String rawKey) { - if (COMPARISON_INCOMPATIBLE_TYPES.contains(keyType) && - (operator == FilterOperator.LESS_THAN || operator == FilterOperator.GREATER_THAN)) { - var opSymbol = operator == FilterOperator.LESS_THAN ? "<" : ">"; - throw new InvalidFilterDslException(ValidationMessages.FILTER_TYPE_MISMATCH.formatted(opSymbol, rawKey)); - } + if (!VALID_ATTRIBUTE_NAMES.contains(rawKey)) { + throw new InvalidFilterDslException( + "Unknown attribute '%s' in filter criterion '%s'. Valid attributes: %s".formatted(rawKey, + token, VALID_ATTRIBUTE_NAMES)); + } + return new FilterCriterion(FilterKeyType.ATTRIBUTE, rawKey, operator, value); + } + + private FilterCriterion buildRelationsAsTargetCriterion(String relationPart, + FilterOperator operator, String value, String token) { + int dotIndex = relationPart.indexOf('.'); + if (dotIndex <= 0) { + throw new InvalidFilterDslException( + "Invalid filter criterion '%s': relations_as_target requires the form 'relations_as_target..'" + .formatted(token)); } - /// Validates that all PROPERTY criteria using `<` or `>` operators - /// correspond to a NUMBER-typed property in the given template. - /// - /// This is a semantic check that requires the template to be available (i.e., it - /// cannot be performed in [#parse] which has no template context). - /// - /// @param filter the parsed query filter - /// @param template the entity template providing property type information - /// @throws InvalidFilterDslException when a comparison operator is used on a non-NUMBER property - public void validateFilterPropertyTypes(EntityFilter filter, EntityTemplate template) { - filter.criteria().stream() - .filter(c -> c.keyType() == FilterKeyType.PROPERTY) - .filter(c -> c.operator() == FilterOperator.LESS_THAN || c.operator() == FilterOperator.GREATER_THAN) - .forEach(c -> { - var propertyDef = template.propertiesDefinitions().stream() - .filter(p -> p.name().equals(c.key())) - .findFirst(); - if (propertyDef.isEmpty() || propertyDef.get().type() != PropertyType.NUMBER) { - var opSymbol = c.operator() == FilterOperator.LESS_THAN ? "<" : ">"; - throw new InvalidFilterDslException( - ValidationMessages.FILTER_PROPERTY_TYPE_NOT_NUMERIC.formatted(opSymbol, c.key())); - } - }); + var relationName = relationPart.substring(0, dotIndex); + var propertyName = relationPart.substring(dotIndex + 1); + validateKeyName(relationName, token); + validatePropertyName(propertyName, RELATIONS_AS_TARGET, token); + var compositeKey = relationName + "." + propertyName; + return new FilterCriterion(FilterKeyType.RELATIONS_AS_TARGET_PROPERTY, compositeKey, operator, + value); + } + + private FilterCriterion buildRelationCriterion(String relationPart, FilterOperator operator, + String value, String token) { + int dotIndex = relationPart.indexOf('.'); + if (dotIndex > 0) { + var relationName = relationPart.substring(0, dotIndex); + var propertyName = relationPart.substring(dotIndex + 1); + validateKeyName(relationName, token); + validatePropertyName(propertyName, RELATION, token); + var compositeKey = relationName + "." + propertyName; + return new FilterCriterion(FilterKeyType.RELATION_PROPERTY, compositeKey, operator, value); } - private void validateKey(String key, String token) { - if (key.isBlank()) { + // Default: relation entity filter + validateKeyName(relationPart, token); + return new FilterCriterion(FilterKeyType.RELATION_ENTITY, relationPart, operator, value); + } + + private void validateNoDuplicates(List criteria) { + Set seen = new HashSet<>(); + for (FilterCriterion criterion : criteria) { + String dedupeKey = criterion.keyType().name() + ":" + criterion.key(); + if (!seen.add(dedupeKey)) { + throw new InvalidFilterDslException(ValidationMessages.FILTER_DUPLICATE_CRITERION); + } + } + } + + private void validateOperatorCompatibility(FilterKeyType keyType, FilterOperator operator, + String rawKey) { + if (COMPARISON_INCOMPATIBLE_TYPES.contains(keyType) + && (operator == FilterOperator.LESS_THAN || operator == FilterOperator.GREATER_THAN)) { + var opSymbol = operator == FilterOperator.LESS_THAN ? "<" : ">"; + throw new InvalidFilterDslException( + ValidationMessages.FILTER_TYPE_MISMATCH.formatted(opSymbol, rawKey)); + } + } + + /// Validates that all PROPERTY criteria using `<` or `>` operators + /// correspond to a NUMBER-typed property in the given template. + /// + /// This is a semantic check that requires the template to be available (i.e., + /// it + /// cannot be performed in [#parse] which has no template context). + /// + /// @param filter the parsed query filter + /// @param template the entity template providing property type information + /// @throws InvalidFilterDslException when a comparison operator is used on a + /// non-NUMBER property + public void validateFilterPropertyTypes(EntityFilter filter, EntityTemplate template) { + filter.criteria().stream().filter(c -> c.keyType() == FilterKeyType.PROPERTY) + .filter(c -> c.operator() == FilterOperator.LESS_THAN + || c.operator() == FilterOperator.GREATER_THAN) + .forEach(c -> { + var propertyDef = template.propertiesDefinitions().stream() + .filter(p -> p.name().equals(c.key())).findFirst(); + if (propertyDef.isEmpty() || propertyDef.get().type() != PropertyType.NUMBER) { + var opSymbol = c.operator() == FilterOperator.LESS_THAN ? "<" : ">"; throw new InvalidFilterDslException( - "Invalid filter criterion '%s': key must not be blank".formatted(token)); - } + ValidationMessages.FILTER_PROPERTY_TYPE_NOT_NUMERIC.formatted(opSymbol, c.key())); + } + }); + } + + private void validateKey(String key, String token) { + if (key.isBlank()) { + throw new InvalidFilterDslException( + "Invalid filter criterion '%s': key must not be blank".formatted(token)); } + } - private void validateKeyName(String keyName, String token) { - if (keyName.isBlank()) { - throw new InvalidFilterDslException( - "Invalid filter criterion '%s': key name must not be blank".formatted(token)); - } + private void validateKeyName(String keyName, String token) { + if (keyName.isBlank()) { + throw new InvalidFilterDslException( + "Invalid filter criterion '%s': key name must not be blank".formatted(token)); } + } - private void validateValue(String value, String token) { - if (value.isBlank()) { - throw new InvalidFilterDslException( - "Invalid filter criterion '%s': value must not be blank".formatted(token)); - } + private void validateValue(String value, String token) { + if (value.isBlank()) { + throw new InvalidFilterDslException( + "Invalid filter criterion '%s': value must not be blank".formatted(token)); } + } - private void validatePropertyName(String propertyName, String contextType, String token) { - if (!VALID_ATTRIBUTE_NAMES.contains(propertyName)) { - throw new InvalidFilterDslException( - "Invalid property '%s' in criterion '%s': only 'identifier' and 'name' are supported for %s" - .formatted(propertyName, token, contextType)); - } + private void validatePropertyName(String propertyName, String contextType, String token) { + if (!VALID_ATTRIBUTE_NAMES.contains(propertyName)) { + throw new InvalidFilterDslException( + "Invalid property '%s' in criterion '%s': only 'identifier' and 'name' are supported for %s" + .formatted(propertyName, token, contextType)); } + } - private void validateLength(String rawKey, String value, String token) { - if (rawKey.length() > FilterConstraints.MAX_KEY_VALUE_LENGTH) { - throw new InvalidFilterDslException( - ValidationMessages.FILTER_KEY_TOO_LONG.formatted(FilterConstraints.MAX_KEY_VALUE_LENGTH, token)); - } - if (value.length() > FilterConstraints.MAX_KEY_VALUE_LENGTH) { - throw new InvalidFilterDslException( - ValidationMessages.FILTER_VALUE_TOO_LONG.formatted(FilterConstraints.MAX_KEY_VALUE_LENGTH, token)); - } + private void validateLength(String rawKey, String value, String token) { + if (rawKey.length() > FilterConstraints.MAX_KEY_VALUE_LENGTH) { + throw new InvalidFilterDslException(ValidationMessages.FILTER_KEY_TOO_LONG + .formatted(FilterConstraints.MAX_KEY_VALUE_LENGTH, token)); + } + if (value.length() > FilterConstraints.MAX_KEY_VALUE_LENGTH) { + throw new InvalidFilterDslException(ValidationMessages.FILTER_VALUE_TOO_LONG + .formatted(FilterConstraints.MAX_KEY_VALUE_LENGTH, token)); } + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/search/SearchFilterParser.java b/src/main/java/com/decathlon/idp_core/domain/service/search/SearchFilterParser.java index f061e30b..8dae2a6a 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/search/SearchFilterParser.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/search/SearchFilterParser.java @@ -31,19 +31,22 @@ @Service public class SearchFilterParser { - /// Parses and validates a [RawSearchFilterNode] tree into a typed [SearchFilterNode] tree. - /// - /// @param raw the root of the raw filter tree; null is treated as "no filter" and returns an empty AND group - /// @return the validated, type-safe domain filter tree - /// @throws InvalidSearchQueryException when the raw tree contains structural errors or exceeds safety limits - public SearchFilterNode parse(RawSearchFilterNode raw) { - if (raw == null) { - return new SearchFilterNode.Group(LogicalConnector.AND, List.of()); - } - return convertNode(raw, 0, new int[]{0}); + /// Parses and validates a [RawSearchFilterNode] tree into a typed + /// [SearchFilterNode] tree. + /// + /// @param raw the root of the raw filter tree; null is treated as "no filter" + /// and returns an empty AND group + /// @return the validated, type-safe domain filter tree + /// @throws InvalidSearchQueryException when the raw tree contains structural + /// errors or exceeds safety limits + public SearchFilterNode parse(RawSearchFilterNode raw) { + if (raw == null) { + return new SearchFilterNode.Group(LogicalConnector.AND, List.of()); } + return convertNode(raw, 0, new int[]{0}); + } - private SearchFilterNode convertNode(RawSearchFilterNode raw, int depth, int[] criteriaCounter) { + private SearchFilterNode convertNode(RawSearchFilterNode raw, int depth, int[] criteriaCounter) { if (depth > SearchConstraints.MAX_NESTING_DEPTH) { throw new InvalidSearchQueryException( ValidationMessages.SEARCH_NESTING_TOO_DEEP.formatted(SearchConstraints.MAX_NESTING_DEPTH)); @@ -54,58 +57,59 @@ private SearchFilterNode convertNode(RawSearchFilterNode raw, int depth, int[] c }; } - private SearchFilterNode.Group convertGroup(RawSearchFilterNode.Group raw, int depth, int[] criteriaCounter) { - if (raw.connector() == null || raw.connector().isBlank()) { - throw new InvalidSearchQueryException(ValidationMessages.SEARCH_GROUP_MISSING_CONNECTOR); - } - if (raw.nodes() == null || raw.nodes().isEmpty()) { - throw new InvalidSearchQueryException(ValidationMessages.SEARCH_GROUP_MISSING_CRITERIA); - } - - var connector = parseConnector(raw.connector()); - List children = raw.nodes().stream() - .map(child -> convertNode(child, depth + 1, criteriaCounter)) - .toList(); - - return new SearchFilterNode.Group(connector, children); + private SearchFilterNode.Group convertGroup(RawSearchFilterNode.Group raw, int depth, + int[] criteriaCounter) { + if (raw.connector() == null || raw.connector().isBlank()) { + throw new InvalidSearchQueryException(ValidationMessages.SEARCH_GROUP_MISSING_CONNECTOR); + } + if (raw.nodes() == null || raw.nodes().isEmpty()) { + throw new InvalidSearchQueryException(ValidationMessages.SEARCH_GROUP_MISSING_CRITERIA); } - private SearchFilterNode.Criterion convertCriterion(RawSearchFilterNode.Criterion raw, int[] criteriaCounter) { - if (raw.field() == null || raw.field().isBlank()) { - throw new InvalidSearchQueryException(ValidationMessages.SEARCH_CRITERION_MISSING_FIELD); - } - if (raw.operation() == null || raw.operation().isBlank()) { - throw new InvalidSearchQueryException(ValidationMessages.SEARCH_CRITERION_MISSING_OPERATION); - } - if (raw.value() == null || raw.value().isBlank()) { - throw new InvalidSearchQueryException(ValidationMessages.SEARCH_CRITERION_MISSING_VALUE); - } + var connector = parseConnector(raw.connector()); + List children = raw.nodes().stream() + .map(child -> convertNode(child, depth + 1, criteriaCounter)).toList(); - criteriaCounter[0]++; - if (criteriaCounter[0] > SearchConstraints.MAX_TOTAL_CRITERIA) { - throw new InvalidSearchQueryException( - ValidationMessages.SEARCH_TOO_MANY_CRITERIA.formatted(SearchConstraints.MAX_TOTAL_CRITERIA)); - } + return new SearchFilterNode.Group(connector, children); + } - var operator = parseOperator(raw.operation()); - return new SearchFilterNode.Criterion(raw.field(), operator, raw.value()); + private SearchFilterNode.Criterion convertCriterion(RawSearchFilterNode.Criterion raw, + int[] criteriaCounter) { + if (raw.field() == null || raw.field().isBlank()) { + throw new InvalidSearchQueryException(ValidationMessages.SEARCH_CRITERION_MISSING_FIELD); + } + if (raw.operation() == null || raw.operation().isBlank()) { + throw new InvalidSearchQueryException(ValidationMessages.SEARCH_CRITERION_MISSING_OPERATION); + } + if (raw.value() == null || raw.value().isBlank()) { + throw new InvalidSearchQueryException(ValidationMessages.SEARCH_CRITERION_MISSING_VALUE); } - private LogicalConnector parseConnector(String raw) { - try { - return LogicalConnector.valueOf(raw.toUpperCase()); - } catch (IllegalArgumentException _) { - throw new InvalidSearchQueryException( - ValidationMessages.SEARCH_INVALID_CONNECTOR.formatted(raw)); - } + criteriaCounter[0]++; + if (criteriaCounter[0] > SearchConstraints.MAX_TOTAL_CRITERIA) { + throw new InvalidSearchQueryException(ValidationMessages.SEARCH_TOO_MANY_CRITERIA + .formatted(SearchConstraints.MAX_TOTAL_CRITERIA)); } - private SearchOperator parseOperator(String raw) { - try { - return SearchOperator.valueOf(raw.toUpperCase()); - } catch (IllegalArgumentException _) { - throw new InvalidSearchQueryException( - ValidationMessages.SEARCH_INVALID_OPERATOR.formatted(raw)); - } + var operator = parseOperator(raw.operation()); + return new SearchFilterNode.Criterion(raw.field(), operator, raw.value()); + } + + private LogicalConnector parseConnector(String raw) { + try { + return LogicalConnector.valueOf(raw.toUpperCase()); + } catch (IllegalArgumentException _) { + throw new InvalidSearchQueryException( + ValidationMessages.SEARCH_INVALID_CONNECTOR.formatted(raw)); + } + } + + private SearchOperator parseOperator(String raw) { + try { + return SearchOperator.valueOf(raw.toUpperCase()); + } catch (IllegalArgumentException _) { + throw new InvalidSearchQueryException( + ValidationMessages.SEARCH_INVALID_OPERATOR.formatted(raw)); } + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/search/SearchFilterValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/search/SearchFilterValidationService.java index d08bf98f..86ba5782 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/search/SearchFilterValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/search/SearchFilterValidationService.java @@ -34,130 +34,125 @@ @AllArgsConstructor public class SearchFilterValidationService { - private static final Set NUMERIC_OPERATORS = - Set.of(SearchOperator.GT, SearchOperator.GTE, SearchOperator.LT, SearchOperator.LTE); - private static final Set SIMPLE_FIELDS = - Set.of("template", "identifier", "name", "relation", "relations_as_target"); - private static final String PROPERTY_PREFIX = "property."; - private static final String RELATION_PREFIX = "relation."; - private static final String RELATIONS_AS_TARGET_PREFIX = "relations_as_target."; - private static final String TEMPLATE_FIELD = "template"; - - private final EntityTemplateRepositoryPort entityTemplateRepository; - - /// Validates the filter tree and query string for semantic correctness. - /// - /// @param filter the root of the search filter tree to validate - /// @param query optional free-text query string; may be null (no-op) - /// @throws InvalidSearchQueryException when any validation rule is violated - public void validate(SearchFilterNode filter, String query) { - validateQuery(query); - collectCriteria(filter).forEach(this::validateCriterion); - validateTemplatePropertyTypes(filter); + private static final Set NUMERIC_OPERATORS = Set.of(SearchOperator.GT, + SearchOperator.GTE, SearchOperator.LT, SearchOperator.LTE); + private static final Set SIMPLE_FIELDS = Set.of("template", "identifier", "name", + "relation", "relations_as_target"); + private static final String PROPERTY_PREFIX = "property."; + private static final String RELATION_PREFIX = "relation."; + private static final String RELATIONS_AS_TARGET_PREFIX = "relations_as_target."; + private static final String TEMPLATE_FIELD = "template"; + + private final EntityTemplateRepositoryPort entityTemplateRepository; + + /// Validates the filter tree and query string for semantic correctness. + /// + /// @param filter the root of the search filter tree to validate + /// @param query optional free-text query string; may be null (no-op) + /// @throws InvalidSearchQueryException when any validation rule is violated + public void validate(SearchFilterNode filter, String query) { + validateQuery(query); + collectCriteria(filter).forEach(this::validateCriterion); + validateTemplatePropertyTypes(filter); + } + + private void validateQuery(String query) { + if (query != null && query.length() > SearchConstraints.MAX_QUERY_LENGTH) { + throw new InvalidSearchQueryException( + ValidationMessages.SEARCH_QUERY_TOO_LONG.formatted(SearchConstraints.MAX_QUERY_LENGTH)); } + } - private void validateQuery(String query) { - if (query != null && query.length() > SearchConstraints.MAX_QUERY_LENGTH) { - throw new InvalidSearchQueryException( - ValidationMessages.SEARCH_QUERY_TOO_LONG.formatted(SearchConstraints.MAX_QUERY_LENGTH)); - } - } + private void validateCriterion(SearchFilterNode.Criterion criterion) { + validateField(criterion.field()); + validateNumericConstraints(criterion.operation(), criterion.field(), criterion.value()); + } - private void validateCriterion(SearchFilterNode.Criterion criterion) { - validateField(criterion.field()); - validateNumericConstraints(criterion.operation(), criterion.field(), criterion.value()); + private void validateField(String field) { + if (SIMPLE_FIELDS.contains(field)) { + return; } - - private void validateField(String field) { - if (SIMPLE_FIELDS.contains(field)) { - return; - } - if (field.startsWith(PROPERTY_PREFIX) && field.length() > PROPERTY_PREFIX.length()) { - return; - } - if (field.startsWith(RELATIONS_AS_TARGET_PREFIX)) { - validateRelationsAsTargetField(field); - return; - } - if (field.startsWith(RELATION_PREFIX) && field.length() > RELATION_PREFIX.length()) { - return; - } - throw new InvalidSearchQueryException(ValidationMessages.SEARCH_INVALID_FIELD.formatted(field)); + if (field.startsWith(PROPERTY_PREFIX) && field.length() > PROPERTY_PREFIX.length()) { + return; } - - private void validateRelationsAsTargetField(String field) { - String rest = field.substring(RELATIONS_AS_TARGET_PREFIX.length()); - int dot = rest.indexOf('.'); - if (dot <= 0 || dot == rest.length() - 1) { - throw new InvalidSearchQueryException(ValidationMessages.SEARCH_INVALID_FIELD.formatted(field)); - } + if (field.startsWith(RELATIONS_AS_TARGET_PREFIX)) { + validateRelationsAsTargetField(field); + return; } - - private void validateNumericConstraints(SearchOperator operator, String field, String value) { - if (!NUMERIC_OPERATORS.contains(operator)) { - return; - } - if (!field.startsWith(PROPERTY_PREFIX) || field.length() <= PROPERTY_PREFIX.length()) { - throw new InvalidSearchQueryException( - ValidationMessages.SEARCH_NUMERIC_OPERATOR_REQUIRES_PROPERTY.formatted(operator)); - } - try { - new BigDecimal(value); - } catch (NumberFormatException _) { - throw new InvalidSearchQueryException( - ValidationMessages.SEARCH_NUMERIC_OPERATOR_INVALID_VALUE.formatted(value, operator)); - } + if (field.startsWith(RELATION_PREFIX) && field.length() > RELATION_PREFIX.length()) { + return; } - - private void validateTemplatePropertyTypes(SearchFilterNode filter) { - Set numericPropertyNames = collectNumericPropertyCriteria(filter); - if (numericPropertyNames.isEmpty()) { - return; - } - - Set templateIdentifiers = collectTemplateIdentifiers(filter); - if (templateIdentifiers.isEmpty()) { - return; - } - - for (String templateIdentifier : templateIdentifiers) { - entityTemplateRepository.findByIdentifier(templateIdentifier) - .ifPresent(template -> checkPropertyTypes(numericPropertyNames, template)); - } + throw new InvalidSearchQueryException(ValidationMessages.SEARCH_INVALID_FIELD.formatted(field)); + } + + private void validateRelationsAsTargetField(String field) { + String rest = field.substring(RELATIONS_AS_TARGET_PREFIX.length()); + int dot = rest.indexOf('.'); + if (dot <= 0 || dot == rest.length() - 1) { + throw new InvalidSearchQueryException( + ValidationMessages.SEARCH_INVALID_FIELD.formatted(field)); } + } - private void checkPropertyTypes(Set numericPropertyNames, EntityTemplate template) { - template.propertiesDefinitions().stream() - .filter(pd -> numericPropertyNames.contains(pd.name())) - .filter(pd -> pd.type() != PropertyType.NUMBER) - .findFirst() - .ifPresent(pd -> { - throw new InvalidSearchQueryException( - ValidationMessages.SEARCH_NUMERIC_OPERATOR_PROPERTY_TYPE_MISMATCH - .formatted(pd.name(), template.identifier(), pd.type())); - }); + private void validateNumericConstraints(SearchOperator operator, String field, String value) { + if (!NUMERIC_OPERATORS.contains(operator)) { + return; + } + if (!field.startsWith(PROPERTY_PREFIX) || field.length() <= PROPERTY_PREFIX.length()) { + throw new InvalidSearchQueryException( + ValidationMessages.SEARCH_NUMERIC_OPERATOR_REQUIRES_PROPERTY.formatted(operator)); } + try { + new BigDecimal(value); + } catch (NumberFormatException _) { + throw new InvalidSearchQueryException( + ValidationMessages.SEARCH_NUMERIC_OPERATOR_INVALID_VALUE.formatted(value, operator)); + } + } - private Set collectNumericPropertyCriteria(SearchFilterNode filter) { - Set names = new HashSet<>(); - collectCriteria(filter) - .filter(c -> NUMERIC_OPERATORS.contains(c.operation())) - .filter(c -> c.field().startsWith(PROPERTY_PREFIX)) - .map(c -> c.field().substring(PROPERTY_PREFIX.length())) - .forEach(names::add); - return names; + private void validateTemplatePropertyTypes(SearchFilterNode filter) { + Set numericPropertyNames = collectNumericPropertyCriteria(filter); + if (numericPropertyNames.isEmpty()) { + return; } - private Set collectTemplateIdentifiers(SearchFilterNode filter) { - Set identifiers = new HashSet<>(); - collectCriteria(filter) - .filter(c -> TEMPLATE_FIELD.equals(c.field()) && c.operation() == SearchOperator.EQ) - .map(SearchFilterNode.Criterion::value) - .forEach(identifiers::add); - return identifiers; + Set templateIdentifiers = collectTemplateIdentifiers(filter); + if (templateIdentifiers.isEmpty()) { + return; } - private Stream collectCriteria(SearchFilterNode node) { + for (String templateIdentifier : templateIdentifiers) { + entityTemplateRepository.findByIdentifier(templateIdentifier) + .ifPresent(template -> checkPropertyTypes(numericPropertyNames, template)); + } + } + + private void checkPropertyTypes(Set numericPropertyNames, EntityTemplate template) { + template.propertiesDefinitions().stream().filter(pd -> numericPropertyNames.contains(pd.name())) + .filter(pd -> pd.type() != PropertyType.NUMBER).findFirst().ifPresent(pd -> { + throw new InvalidSearchQueryException( + ValidationMessages.SEARCH_NUMERIC_OPERATOR_PROPERTY_TYPE_MISMATCH.formatted(pd.name(), + template.identifier(), pd.type())); + }); + } + + private Set collectNumericPropertyCriteria(SearchFilterNode filter) { + Set names = new HashSet<>(); + collectCriteria(filter).filter(c -> NUMERIC_OPERATORS.contains(c.operation())) + .filter(c -> c.field().startsWith(PROPERTY_PREFIX)) + .map(c -> c.field().substring(PROPERTY_PREFIX.length())).forEach(names::add); + return names; + } + + private Set collectTemplateIdentifiers(SearchFilterNode filter) { + Set identifiers = new HashSet<>(); + collectCriteria(filter) + .filter(c -> TEMPLATE_FIELD.equals(c.field()) && c.operation() == SearchOperator.EQ) + .map(SearchFilterNode.Criterion::value).forEach(identifiers::add); + return identifiers; + } + + private Stream collectCriteria(SearchFilterNode node) { return switch (node) { case SearchFilterNode.Criterion c -> Stream.of(c); case SearchFilterNode.Group g -> g.nodes().stream().flatMap(this::collectCriteria); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java index 94cc65c0..15abd19e 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java @@ -147,22 +147,22 @@ public class SwaggerDescription { public static final String FIELD_RELATION_REQUIRED = "Whether this relation is required"; public static final String FIELD_RELATION_TO_MANY = "Whether this relation can have multiple targets"; - // --- Pagination and sorting parameter descriptions --- - public static final String PARAM_PAGE_DESCRIPTION = "Page number for pagination. Defaults to 0."; - public static final String PARAM_SIZE_DESCRIPTION = "Number of items per page. Defaults to 20."; - public static final String PARAM_SORT_DESCRIPTION = "Sorting criteria in the format: property(,asc|desc). Defaults to identifier,asc."; - public static final String PARAM_QUERY_DESCRIPTION = """ - Optional filter query using a simple expression language. See more details in the API documentation. Example: `name:idp` for entities with names containing 'idp'. - """; - public static final String RESPONSE_INVALID_QUERY = "Invalid filter query syntax"; - - /// Search API endpoint constants - public static final String ENDPOINT_POST_SEARCH_SUMMARY = "Search entities"; - public static final String ENDPOINT_POST_SEARCH_DESCRIPTION = """ - Search for entities across all templates using nested filter queries. \ - Supports complex logical compositions (AND / OR / IN) of filter criteria on \ - template, identifier, name, properties, relations, and reverse relations."""; - public static final String RESPONSE_SEARCH_SUCCESS = "Entities retrieved successfully"; - public static final String RESPONSE_INVALID_SEARCH_QUERY = "Invalid search filter"; + // --- Pagination and sorting parameter descriptions --- + public static final String PARAM_PAGE_DESCRIPTION = "Page number for pagination. Defaults to 0."; + public static final String PARAM_SIZE_DESCRIPTION = "Number of items per page. Defaults to 20."; + public static final String PARAM_SORT_DESCRIPTION = "Sorting criteria in the format: property(,asc|desc). Defaults to identifier,asc."; + public static final String PARAM_QUERY_DESCRIPTION = """ + Optional filter query using a simple expression language. See more details in the API documentation. Example: `name:idp` for entities with names containing 'idp'. + """; + public static final String RESPONSE_INVALID_QUERY = "Invalid filter query syntax"; + + /// Search API endpoint constants + public static final String ENDPOINT_POST_SEARCH_SUMMARY = "Search entities"; + public static final String ENDPOINT_POST_SEARCH_DESCRIPTION = """ + Search for entities across all templates using nested filter queries. \ + Supports complex logical compositions (AND / OR / IN) of filter criteria on \ + template, identifier, name, properties, relations, and reverse relations."""; + public static final String RESPONSE_SEARCH_SUCCESS = "Entities retrieved successfully"; + public static final String RESPONSE_INVALID_SEARCH_QUERY = "Invalid search filter"; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java index 178f2821..7e096bc2 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java @@ -9,10 +9,10 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITY_BY_IDENTIFIER_SUMMARY; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_POST_ENTITY_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_POST_ENTITY_SUMMARY; -import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_PUT_ENTITY_DESCRIPTION; -import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_PUT_ENTITY_SUMMARY; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_POST_SEARCH_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_POST_SEARCH_SUMMARY; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_PUT_ENTITY_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_PUT_ENTITY_SUMMARY; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FORBIDDEN_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.INTERNAL_SERVER_ERROR_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.NOT_FOUND_CODE; @@ -33,17 +33,12 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_INVALID_QUERY; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_INVALID_SEARCH_QUERY; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_SEARCH_SUCCESS; -import static org.springframework.http.HttpStatus.CREATED; -import static org.springframework.http.HttpStatus.OK; - -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.enums.ParameterIn; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_UNAUTHORIZED; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_UNEXPECTED_SERVER_ERROR; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.UNAUTHORIZED_CODE; - -import lombok.RequiredArgsConstructor; +import static org.springframework.http.HttpStatus.CREATED; +import static org.springframework.http.HttpStatus.OK; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; @@ -66,13 +61,13 @@ import com.decathlon.idp_core.domain.model.entity.EntityFilter; import com.decathlon.idp_core.domain.model.entity.RawSearchFilterNode; import com.decathlon.idp_core.domain.model.entity.SearchFilterNode; -import com.decathlon.idp_core.domain.service.filter.EntityFilterDslParser; import com.decathlon.idp_core.domain.service.entity.EntityService; +import com.decathlon.idp_core.domain.service.filter.EntityFilterDslParser; import com.decathlon.idp_core.domain.service.search.SearchFilterParser; import com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerConfiguration.EntityPageResponse; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityCreateDtoIn; -import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityUpdateDtoIn; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntitySearchRequestDtoIn; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityUpdateDtoIn; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityDtoOut; import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler; import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse; @@ -81,10 +76,13 @@ import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.SearchFilterMapper; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; /// REST API adapter providing entity management endpoints. /// @@ -101,46 +99,49 @@ @RequiredArgsConstructor public class EntityController { - private final EntityService entityService; - private final EntityDtoOutMapper entityDtoOutMapper; - private final EntityDtoInMapper entityDtoInMapper; - private final EntityFilterDslParser entityFilterDslParser; - private final SearchFilterMapper searchFilterMapper; - private final SearchFilterParser searchFilterParser; + private final EntityService entityService; + private final EntityDtoOutMapper entityDtoOutMapper; + private final EntityDtoInMapper entityDtoInMapper; + private final EntityFilterDslParser entityFilterDslParser; + private final SearchFilterMapper searchFilterMapper; + private final SearchFilterParser searchFilterParser; - /// Returns paginated entities filtered by template with HTTP pagination support. - /// - /// **API contract:** Provides paginated entity listings for template-specific views. - /// Supports standard REST pagination parameters and an optional `q` filter query. - /// Template validation is handled by the domain service layer. - /// - /// @param page zero-based page index for pagination navigation - /// @param size number of entities per page for response size control - /// @param templateIdentifier template filter for entity scope limitation - /// @param q optional filter query string (e.g. `name:API;property.language=JAVA`) - /// @return paginated entity DTOs matching the template and optional filter - @Operation(summary = ENDPOINT_GET_ENTITIES_SUMMARY, description = ENDPOINT_GET_ENTITIES_PAGINATED_DESCRIPTION) - @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITIES_PAGINATED_SUCCESS, content = @Content(schema = @Schema(implementation = EntityPageResponse.class))) - @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_PAGINATION, content = { - @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) - @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_QUERY, content = { - @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) - @Parameter(name = "page", description = PARAM_PAGE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "0"))) - @Parameter(name = "size", description = PARAM_SIZE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "20"))) - @Parameter(name = "sort", description = PARAM_SORT_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string", defaultValue = "identifier,asc"))) - @Parameter(name = "q", description = PARAM_QUERY_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string"))) - @ResponseStatus(OK) - @GetMapping("/{templateIdentifier}") - public Page getEntities( - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size, - @PathVariable String templateIdentifier, - @RequestParam(required = false) String q) { - Pageable pageable = PageRequest.of(page, size); - EntityFilter filter = entityFilterDslParser.parse(q); - Page entities = entityService.getEntitiesByTemplateIdentifier(pageable, templateIdentifier, filter); - return entityDtoOutMapper.fromEntitiesPageToDtoPage(entities, templateIdentifier); - } + /// Returns paginated entities filtered by template with HTTP pagination + /// support. + /// + /// **API contract:** Provides paginated entity listings for template-specific + /// views. + /// Supports standard REST pagination parameters and an optional `q` filter + /// query. + /// Template validation is handled by the domain service layer. + /// + /// @param page zero-based page index for pagination navigation + /// @param size number of entities per page for response size control + /// @param templateIdentifier template filter for entity scope limitation + /// @param q optional filter query string (e.g. + /// `name:API;property.language=JAVA`) + /// @return paginated entity DTOs matching the template and optional filter + @Operation(summary = ENDPOINT_GET_ENTITIES_SUMMARY, description = ENDPOINT_GET_ENTITIES_PAGINATED_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITIES_PAGINATED_SUCCESS, content = @Content(schema = @Schema(implementation = EntityPageResponse.class))) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_PAGINATION, content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_QUERY, content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @Parameter(name = "page", description = PARAM_PAGE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "0"))) + @Parameter(name = "size", description = PARAM_SIZE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "20"))) + @Parameter(name = "sort", description = PARAM_SORT_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string", defaultValue = "identifier,asc"))) + @Parameter(name = "q", description = PARAM_QUERY_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string"))) + @ResponseStatus(OK) + @GetMapping("/{templateIdentifier}") + public Page getEntities(@RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, @PathVariable String templateIdentifier, + @RequestParam(required = false) String q) { + Pageable pageable = PageRequest.of(page, size); + EntityFilter filter = entityFilterDslParser.parse(q); + Page entities = entityService.getEntitiesByTemplateIdentifier(pageable, + templateIdentifier, filter); + return entityDtoOutMapper.fromEntitiesPageToDtoPage(entities, templateIdentifier); + } /// Retrieves a single entity by template and entity identifiers. /// @@ -197,12 +198,13 @@ public EntityDtoOut getEntity(@PathVariable String templateIdentifier, public EntityDtoOut createEntity(@NotBlank @PathVariable String templateIdentifier, @Valid @RequestBody EntityCreateDtoIn entityCreateDtoIn) { - Entity entity = entityDtoInMapper.fromEntityDtoInToEntity(entityDtoIn, templateIdentifier); - Entity savedEntity = entityService.createEntity(entity); - return entityDtoOutMapper.fromEntity(savedEntity); - } + Entity entity = entityDtoInMapper.fromPostEntityDtoInToEntity(entityCreateDtoIn, + templateIdentifier); + Entity savedEntity = entityService.createEntity(entity); + return entityDtoOutMapper.fromEntity(savedEntity); + } - /// Updates an existing entity for the specified template. + /// Updates an existing entity for the specified template. /// /// **API contract:** Accepts entity update payload and returns updated entity. /// Validates @@ -239,26 +241,29 @@ public EntityDtoOut updateEntity(@NotBlank @PathVariable String templateIdentifi return entityDtoOutMapper.fromEntity(updatedEntity); } - /// Searches for entities across all templates using a nested filter query. - /// - /// **API contract:** Accepts a JSON body with a nested filter tree, pagination, and - /// sorting parameters. Returns a paginated list of entities matching the filter. - /// No template scoping is applied by default; include a template criterion - /// in the filter to scope results to a specific template. - /// - /// @param searchRequest the search request body with filter, page, size, and sort - /// @return paginated entity DTOs matching the filter - @Operation(summary = ENDPOINT_POST_SEARCH_SUMMARY, description = ENDPOINT_POST_SEARCH_DESCRIPTION) - @ApiResponse(responseCode = OK_CODE, description = RESPONSE_SEARCH_SUCCESS, content = @Content(schema = @Schema(implementation = EntityPageResponse.class))) - @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_SEARCH_QUERY, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class)) }) - @PostMapping("/search") - @ResponseStatus(OK) - public Page searchEntities(@RequestBody EntitySearchRequestDtoIn searchRequest) { - RawSearchFilterNode rawFilter = searchFilterMapper.toRaw(searchRequest.filter()); - SearchFilterNode filter = searchFilterParser.parse(rawFilter); - Page entities = entityService.searchEntities( - filter, searchRequest.query(), searchRequest.page(), searchRequest.size(), searchRequest.sort()); - return entityDtoOutMapper.fromEntitiesSearchPageToDtoPage(entities); - } + /// Searches for entities across all templates using a nested filter query. + /// + /// **API contract:** Accepts a JSON body with a nested filter tree, pagination, + /// and + /// sorting parameters. Returns a paginated list of entities matching the + /// filter. + /// No template scoping is applied by default; include a template criterion + /// in the filter to scope results to a specific template. + /// + /// @param searchRequest the search request body with filter, page, size, and + /// sort + /// @return paginated entity DTOs matching the filter + @Operation(summary = ENDPOINT_POST_SEARCH_SUMMARY, description = ENDPOINT_POST_SEARCH_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_SEARCH_SUCCESS, content = @Content(schema = @Schema(implementation = EntityPageResponse.class))) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_SEARCH_QUERY, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @PostMapping("/search") + @ResponseStatus(OK) + public Page searchEntities(@RequestBody EntitySearchRequestDtoIn searchRequest) { + RawSearchFilterNode rawFilter = searchFilterMapper.toRaw(searchRequest.filter()); + SearchFilterNode filter = searchFilterParser.parse(rawFilter); + Page entities = entityService.searchEntities(filter, searchRequest.query(), + searchRequest.page(), searchRequest.size(), searchRequest.sort()); + return entityDtoOutMapper.fromEntitiesSearchPageToDtoPage(entities); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntitySearchRequestDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntitySearchRequestDtoIn.java index 3ea06791..f31d7d61 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntitySearchRequestDtoIn.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntitySearchRequestDtoIn.java @@ -35,33 +35,27 @@ @Schema(description = "Request body for the POST /api/v1/entities/search endpoint") public record EntitySearchRequestDtoIn( - @Schema(description = "Free-text search string. When present, returns entities whose identifier, name, templateIdentifier, or any property value contains this string (case-insensitive). Can be combined with filter.", example = "checkout") - String query, + @Schema(description = "Free-text search string. When present, returns entities whose identifier, name, templateIdentifier, or any property value contains this string (case-insensitive). Can be combined with filter.", example = "checkout") String query, - @Schema(description = "Root node of the search filter tree. May be omitted or null to return all entities.") - FilterNodeDtoIn filter, + @Schema(description = "Root node of the search filter tree. May be omitted or null to return all entities.") FilterNodeDtoIn filter, - @Schema(description = "Zero-based page index. Defaults to 0.", defaultValue = "0", example = "0") - Integer page, + @Schema(description = "Zero-based page index. Defaults to 0.", defaultValue = "0", example = "0") Integer page, - @Schema(description = "Number of entities per page. Defaults to 20.", defaultValue = "20", example = "20") - Integer size, + @Schema(description = "Number of entities per page. Defaults to 20.", defaultValue = "20", example = "20") Integer size, - @Schema(description = "Sort expression in the form field:asc|desc, e.g. identifier:asc.", example = "identifier:asc") - String sort -) { - public EntitySearchRequestDtoIn { - if (size == null || size <= 0) { - size = 20; - } - if (page == null || page < 0) { - page = 0; - } - if (query != null) { - query = query.strip(); - if (query.isBlank()) { - query = null; - } - } + @Schema(description = "Sort expression in the form field:asc|desc, e.g. identifier:asc.", example = "identifier:asc") String sort) { + public EntitySearchRequestDtoIn { + if (size == null || size <= 0) { + size = 20; } + if (page == null || page < 0) { + page = 0; + } + if (query != null) { + query = query.strip(); + if (query.isBlank()) { + query = null; + } + } + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/FilterNodeDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/FilterNodeDtoIn.java index 047bf2d0..4cb11a03 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/FilterNodeDtoIn.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/FilterNodeDtoIn.java @@ -15,19 +15,13 @@ @Schema(description = "A node in the search filter tree. Either a logical group (connector + criteria) or a leaf criterion (field + operation + value).") public record FilterNodeDtoIn( - @Schema(description = "Logical connector for a group node. One of: AND, OR. Required for group nodes.", example = "AND") - String connector, + @Schema(description = "Logical connector for a group node. One of: AND, OR. Required for group nodes.", example = "AND") String connector, - @Schema(description = "Child filter nodes for a group node. Required for group nodes (must be non-empty).") - List criteria, + @Schema(description = "Child filter nodes for a group node. Required for group nodes (must be non-empty).") List criteria, - @Schema(description = "Field to filter on for a criterion node. Required for leaf nodes. Examples: template, identifier, name, relation, property.language, relation.api-link, relation.api-link.identifier, relations_as_target.api-link.name", example = "template") - String field, + @Schema(description = "Field to filter on for a criterion node. Required for leaf nodes. Examples: template, identifier, name, relation, property.language, relation.api-link, relation.api-link.identifier, relations_as_target.api-link.name", example = "template") String field, - @Schema(description = "Filter operation for a criterion node. One of: EQ, NEQ, CONTAINS, NOT_CONTAINS, STARTS_WITH, ENDS_WITH, GT, GTE, LT, LTE. Required for leaf nodes.", example = "EQ") - String operation, + @Schema(description = "Filter operation for a criterion node. One of: EQ, NEQ, CONTAINS, NOT_CONTAINS, STARTS_WITH, ENDS_WITH, GT, GTE, LT, LTE. Required for leaf nodes.", example = "EQ") String operation, - @Schema(description = "Value to compare against for a criterion node. Required for leaf nodes.", example = "microservice") - String value -) { + @Schema(description = "Value to compare against for a criterion node. Required for leaf nodes.", example = "microservice") String value) { } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java index 5b289402..9c995025 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java @@ -10,7 +10,6 @@ import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; -import com.decathlon.idp_core.domain.exception.filter.InvalidFilterDslException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; @@ -19,7 +18,6 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.method.annotation.HandlerMethodValidationException; -import com.decathlon.idp_core.domain.exception.search.InvalidSearchQueryException; import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; @@ -34,6 +32,8 @@ import com.decathlon.idp_core.domain.exception.entity_template.RelationNameAlreadyExistsException; import com.decathlon.idp_core.domain.exception.entity_template.RelationTargetTemplateChangeException; import com.decathlon.idp_core.domain.exception.entity_template.TargetTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.filter.InvalidFilterDslException; +import com.decathlon.idp_core.domain.exception.search.InvalidSearchQueryException; import lombok.AllArgsConstructor; import lombok.Getter; @@ -76,17 +76,21 @@ public ResponseEntity handleTemplateNotFoundException( /// Handles domain exception for malformed filter query strings (`q=` DSL). /// - /// **HTTP mapping:** Maps domain [InvalidFilterDslException] to HTTP 400 Bad Request + /// **HTTP mapping:** Maps domain [InvalidFilterDslException] to HTTP 400 Bad + /// Request /// so API consumers receive clear feedback about invalid `q` parameter syntax. @ExceptionHandler(InvalidFilterDslException.class) - public ResponseEntity handleInvalidFilterDslException(InvalidFilterDslException ex) { + public ResponseEntity handleInvalidFilterDslException( + InvalidFilterDslException ex) { log.warn("Invalid filter query: {}", ex.getMessage()); return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); } - /// Handles domain exception for malformed search filter trees or free-text query strings. + /// Handles domain exception for malformed search filter trees or free-text + /// query strings. /// - /// **HTTP mapping:** Maps domain [InvalidSearchQueryException] to HTTP 400 Bad Request + /// **HTTP mapping:** Maps domain [InvalidSearchQueryException] to HTTP 400 Bad + /// Request /// so API consumers receive clear feedback about invalid search request syntax. @ExceptionHandler(InvalidSearchQueryException.class) public ResponseEntity handleInvalidSearchQueryException( diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java index 80957ce7..fa5f5ef4 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java @@ -68,6 +68,48 @@ public EntityDtoOut fromEntity(Entity entity) { return fromEntityUsingEntityTemplate(entity, entityTemplate); } + /// Maps paginated search results to API DTOs with optimized bulk operations. + /// + /// **Performance optimization:** Batches template resolution across all + /// templates + /// referenced in the page — unlike [#fromEntitiesPageToDtoPage] which is scoped + /// to a single template, this method handles multi-template result sets. + /// + /// @param entities paginated domain entities, possibly spanning several + /// templates + /// @return paginated API DTOs with complete relationship data + public Page fromEntitiesSearchPageToDtoPage(Page entities) { + if (entities.isEmpty()) { + return entities.map(entity -> entityDtoOutMapper(entity, Map.of(), Map.of())); + } + + Map pageEntitiesSummaries = buildRelatedEntitiesSummaryMapByPage( + entities); + Map> relationTargetOwnershipsMap = buildRelationsAsTargetSummaryMapByPage( + entities); + + Map templatesByIdentifier = entities.stream() + .map(Entity::templateIdentifier).filter(Objects::nonNull).distinct().collect(Collectors + .toMap(Function.identity(), entityTemplateService::getEntityTemplateByIdentifier)); + + return entities.map(entity -> { + EntityTemplate template = templatesByIdentifier.get(entity.templateIdentifier()); + if (template == null) { + return entityDtoOutMapper(entity, pageEntitiesSummaries, relationTargetOwnershipsMap); + } + return fromEntityUsingEntityTemplateAndSummaryMap(entity, template, pageEntitiesSummaries, + relationTargetOwnershipsMap); + }); + } + + private EntityDtoOut entityDtoOutMapper(Entity entity, Map summaries, + Map> relationsAsTargetMap) { + return EntityDtoOut.builder().templateIdentifier(entity.templateIdentifier()) + .name(entity.name()).identifier(entity.identifier()).properties(Collections.emptyMap()) + .relations(mapRelationsDto(entity, summaries)) + .relationsAsTarget(mapRelationsAsTargetDto(entity, relationsAsTargetMap)).build(); + } + /// Maps paginated domain entities to API DTOs with optimized bulk operations. /// /// **Performance optimization:** Batches template resolution and relationship diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/SearchFilterMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/SearchFilterMapper.java index a5cbe5a0..4bec6da7 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/SearchFilterMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/SearchFilterMapper.java @@ -16,24 +16,26 @@ @Component public class SearchFilterMapper { - /// Converts a nullable [FilterNodeDtoIn] to a [RawSearchFilterNode]. - /// - /// @param dto the root node DTO; may be null, in which case null is returned (the domain parser - /// treats null as "no filter") - /// @return the raw domain tree, or null when dto is null - public RawSearchFilterNode toRaw(FilterNodeDtoIn dto) { - if (dto == null) { - return null; - } - return convertNode(dto); + /// Converts a nullable [FilterNodeDtoIn] to a [RawSearchFilterNode]. + /// + /// @param dto the root node DTO; may be null, in which case null is returned + /// (the domain parser + /// treats null as "no filter") + /// @return the raw domain tree, or null when dto is null + public RawSearchFilterNode toRaw(FilterNodeDtoIn dto) { + if (dto == null) { + return null; } + return convertNode(dto); + } - private RawSearchFilterNode convertNode(FilterNodeDtoIn dto) { - if (dto.connector() != null || dto.criteria() != null) { - List children = dto.criteria() == null ? List.of() - : dto.criteria().stream().map(this::convertNode).toList(); - return new RawSearchFilterNode.Group(dto.connector(), children); - } - return new RawSearchFilterNode.Criterion(dto.field(), dto.operation(), dto.value()); + private RawSearchFilterNode convertNode(FilterNodeDtoIn dto) { + if (dto.connector() != null || dto.criteria() != null) { + List children = dto.criteria() == null + ? List.of() + : dto.criteria().stream().map(this::convertNode).toList(); + return new RawSearchFilterNode.Group(dto.connector(), children); } + return new RawSearchFilterNode.Criterion(dto.field(), dto.operation(), dto.value()); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java index 347d5271..e20758f8 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java @@ -84,17 +84,19 @@ public void deletePropertiesByTemplateIdentifierAndPropertyName(String templateI propertyNames); } - @Override - public void deleteRelationsByTemplateIdentifierAndRelationName(String templateIdentifier, Collection relationNames) { - jpaEntityRepository.deleteRelationsByTemplateIdentifierAndRelationName(templateIdentifier, relationNames); - } + @Override + public void deleteRelationsByTemplateIdentifierAndRelationName(String templateIdentifier, + Collection relationNames) { + jpaEntityRepository.deleteRelationsByTemplateIdentifierAndRelationName(templateIdentifier, + relationNames); + } - @Override - public Page search(SearchFilterNode filter, String query, Pageable pageable) { - Specification spec = EntitySearchSpecification.of(filter); - if (query != null && !query.isBlank()) { - spec = spec.and(EntitySearchSpecification.globalTextSearch(query)); - } - return jpaEntityRepository.findAll(spec, pageable).map(mapper::toDomain); + @Override + public Page search(SearchFilterNode filter, String query, Pageable pageable) { + Specification spec = EntitySearchSpecification.of(filter); + if (query != null && !query.isBlank()) { + spec = spec.and(EntitySearchSpecification.globalTextSearch(query)); } + return jpaEntityRepository.findAll(spec, pageable).map(mapper::toDomain); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntityFilterSpecification.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntityFilterSpecification.java index 4c477568..411ce5ac 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntityFilterSpecification.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntityFilterSpecification.java @@ -2,6 +2,13 @@ import java.util.stream.Stream; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.Expression; +import jakarta.persistence.criteria.Join; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; +import jakarta.persistence.criteria.Subquery; + import org.springframework.data.jpa.domain.Specification; import com.decathlon.idp_core.domain.model.entity.EntityFilter; @@ -12,12 +19,6 @@ import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.PropertyJpaEntity; import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.RelationJpaEntity; -import jakarta.persistence.criteria.CriteriaBuilder; -import jakarta.persistence.criteria.Expression; -import jakarta.persistence.criteria.Join; -import jakarta.persistence.criteria.Predicate; -import jakarta.persistence.criteria.Root; -import jakarta.persistence.criteria.Subquery; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -41,172 +42,167 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public final class EntityFilterSpecification { - private static final String NAME = "name"; - private static final String IDENTIFIER = "identifier"; - private static final String RELATIONS = "relations"; - private static final String TARGET_ENTITY_IDENTIFIERS = "targetEntityIdentifiers"; - - /// Builds a [Specification] that matches entities belonging to the given template identifier - /// and satisfying all criteria in the given filter. - /// - /// @param templateIdentifier the template to scope the query to - /// @param filter the filter to apply; may be empty (no additional predicates) - /// @return a composed [Specification] combining template scope and all filter criteria - public static Specification of(String templateIdentifier, EntityFilter filter) { - var criteriaSpecs = filter.criteria().stream() - .map(EntityFilterSpecification::fromCriterion); - - return Stream.concat( - Stream.of(hasTemplateIdentifier(templateIdentifier)), - criteriaSpecs - ).reduce(Specification::and).orElse(hasTemplateIdentifier(templateIdentifier)); - } - - private static Specification hasTemplateIdentifier(String templateIdentifier) { - return (root, query, cb) -> cb.equal(root.get("templateIdentifier"), templateIdentifier); - } - - private static Specification fromCriterion(FilterCriterion criterion) { - return switch (criterion.keyType()) { - case ATTRIBUTE -> attributeSpec(criterion); - case PROPERTY -> propertySpec(criterion); - case RELATION_NAME -> relationNameSpec(criterion); - case RELATION_ENTITY -> relationEntitySpec(criterion); - case RELATION_PROPERTY -> relationPropertySpec(criterion); - case RELATIONS_AS_TARGET_NAME -> relationsAsTargetNameSpec(criterion); - case RELATIONS_AS_TARGET_PROPERTY -> relationsAsTargetPropertySpec(criterion); - }; - } - - private static Specification attributeSpec(FilterCriterion criterion) { - return (root, query, cb) -> - buildPredicate(cb, root.get(criterion.key()), criterion.operator(), criterion.value()); - } - - private static Specification propertySpec(FilterCriterion criterion) { - return (root, query, cb) -> { - query.distinct(true); - Join propJoin = root.join("properties"); - return cb.and( - cb.equal(propJoin.get(NAME), criterion.key()), - buildPredicate(cb, propJoin.get("value"), criterion.operator(), criterion.value()) - ); - }; - } - - private static Specification relationEntitySpec(FilterCriterion criterion) { - return (root, query, cb) -> { - query.distinct(true); - Join relJoin = root.join(RELATIONS); - Join targetJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); - return cb.and( - cb.equal(relJoin.get(NAME), criterion.key()), - buildPredicate(cb, targetJoin, criterion.operator(), criterion.value()) - ); - }; - } - - private static Specification relationPropertySpec(FilterCriterion criterion) { - return (root, query, cb) -> { - query.distinct(true); - Join relJoin = root.join(RELATIONS); - - String compositeKey = criterion.key(); - int dotIndex = compositeKey.indexOf('.'); - if (dotIndex < 0) { - throw new IllegalArgumentException("Invalid composite key format: " + compositeKey); - } - String relationName = compositeKey.substring(0, dotIndex); - String propertyName = compositeKey.substring(dotIndex + 1); - - // Check if the property is a target entity property (identifier, name) - if (IDENTIFIER.equals(propertyName) || NAME.equals(propertyName)) { - // Join to target entity identifiers first - Join targetIdJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); - // Create a subquery to find the actual target entities and filter by their properties - var subquery = query.subquery(String.class); - var subRoot = subquery.from(EntityJpaEntity.class); - subquery.select(subRoot.get(IDENTIFIER)) - .where(buildPredicate(cb, subRoot.get(propertyName), criterion.operator(), criterion.value())); - - return cb.and( - cb.equal(relJoin.get(NAME), relationName), - cb.in(targetIdJoin).value(subquery) - ); - } else { - // Direct relation property (shouldn't happen normally as RelationJpaEntity has limited properties) - return cb.and( - cb.equal(relJoin.get(NAME), relationName), - buildPredicate(cb, relJoin.get(propertyName), criterion.operator(), criterion.value()) - ); - } - }; - } - - private static Predicate buildPredicate( - CriteriaBuilder cb, - Expression field, - FilterOperator operator, - String value) { - return switch (operator) { - case EQUALS -> JpaPredicateBuilder.buildPredicate(cb, field, SearchOperator.EQ, value); - case CONTAINS -> JpaPredicateBuilder.buildPredicate(cb, field, SearchOperator.CONTAINS, value); - // LESS_THAN / GREATER_THAN keep lexicographic string comparison (System A semantics). - case LESS_THAN -> cb.lessThan(field.as(String.class), value); - case GREATER_THAN -> cb.greaterThan(field.as(String.class), value); - }; - } - - private static Specification relationNameSpec(FilterCriterion criterion) { - return (root, query, cb) -> { - query.distinct(true); - Join relJoin = root.join(RELATIONS); - return buildPredicate(cb, relJoin.get(NAME), criterion.operator(), criterion.value()); - }; - } - - private static Specification relationsAsTargetNameSpec(FilterCriterion criterion) { - return (root, query, cb) -> { - // Find entities whose identifier appears as a target in any relation whose name matches. - // Uses a correlated subquery to avoid joining through the entity's own outgoing relations. - Subquery subquery = query.subquery(String.class); - Root relRoot = subquery.from(RelationJpaEntity.class); - Join targetJoin = relRoot.join(TARGET_ENTITY_IDENTIFIERS); - subquery.select(targetJoin) - .where(buildPredicate(cb, relRoot.get(NAME), criterion.operator(), criterion.value())); - return cb.in(root.get(IDENTIFIER)).value(subquery); - }; - } - - /// Finds entities whose `identifier` appears as a `targetEntityIdentifier` in any - /// relation whose **source entity** property matches the criterion. - /// - /// Example: `relations_as_target.api-link.name:microservice` returns entities that - /// are targeted by a `api-link` relation originating from an entity whose name - /// contains "microservice". - private static Specification relationsAsTargetPropertySpec(FilterCriterion criterion) { - return (root, query, cb) -> { - String compositeKey = criterion.key(); - int dotIndex = compositeKey.indexOf('.'); - if (dotIndex < 0) { - throw new IllegalArgumentException("Invalid composite key format: " + compositeKey); - } - String relationName = compositeKey.substring(0, dotIndex); - String propertyName = compositeKey.substring(dotIndex + 1); // "identifier" or "name" - - // Subquery: collect all target identifiers from relations named - // that originate from source entities whose matches. - Subquery subquery = query.subquery(String.class); - Root sourceRoot = subquery.from(EntityJpaEntity.class); - Join relJoin = sourceRoot.join(RELATIONS); - Join targetJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); - subquery.select(targetJoin) - .where( - cb.equal(relJoin.get(NAME), relationName), - buildPredicate(cb, sourceRoot.get(propertyName), criterion.operator(), criterion.value()) - ); - return cb.in(root.get(IDENTIFIER)).value(subquery); - }; - } + private static final String NAME = "name"; + private static final String IDENTIFIER = "identifier"; + private static final String RELATIONS = "relations"; + private static final String TARGET_ENTITY_IDENTIFIERS = "targetEntityIdentifiers"; + + /// Builds a [Specification] that matches entities belonging to the given + /// template identifier + /// and satisfying all criteria in the given filter. + /// + /// @param templateIdentifier the template to scope the query to + /// @param filter the filter to apply; may be empty (no additional predicates) + /// @return a composed [Specification] combining template scope and all filter + /// criteria + public static Specification of(String templateIdentifier, EntityFilter filter) { + var criteriaSpecs = filter.criteria().stream().map(EntityFilterSpecification::fromCriterion); + + return Stream.concat(Stream.of(hasTemplateIdentifier(templateIdentifier)), criteriaSpecs) + .reduce(Specification::and).orElse(hasTemplateIdentifier(templateIdentifier)); + } + + private static Specification hasTemplateIdentifier(String templateIdentifier) { + return (root, query, cb) -> cb.equal(root.get("templateIdentifier"), templateIdentifier); + } + + private static Specification fromCriterion(FilterCriterion criterion) { + return switch (criterion.keyType()) { + case ATTRIBUTE -> attributeSpec(criterion); + case PROPERTY -> propertySpec(criterion); + case RELATION_NAME -> relationNameSpec(criterion); + case RELATION_ENTITY -> relationEntitySpec(criterion); + case RELATION_PROPERTY -> relationPropertySpec(criterion); + case RELATIONS_AS_TARGET_NAME -> relationsAsTargetNameSpec(criterion); + case RELATIONS_AS_TARGET_PROPERTY -> relationsAsTargetPropertySpec(criterion); + }; + } + + private static Specification attributeSpec(FilterCriterion criterion) { + return (root, query, cb) -> buildPredicate(cb, root.get(criterion.key()), criterion.operator(), + criterion.value()); + } + + private static Specification propertySpec(FilterCriterion criterion) { + return (root, query, cb) -> { + query.distinct(true); + Join propJoin = root.join("properties"); + return cb.and(cb.equal(propJoin.get(NAME), criterion.key()), + buildPredicate(cb, propJoin.get("value"), criterion.operator(), criterion.value())); + }; + } + + private static Specification relationEntitySpec(FilterCriterion criterion) { + return (root, query, cb) -> { + query.distinct(true); + Join relJoin = root.join(RELATIONS); + Join targetJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); + return cb.and(cb.equal(relJoin.get(NAME), criterion.key()), + buildPredicate(cb, targetJoin, criterion.operator(), criterion.value())); + }; + } + + private static Specification relationPropertySpec(FilterCriterion criterion) { + return (root, query, cb) -> { + query.distinct(true); + Join relJoin = root.join(RELATIONS); + + String compositeKey = criterion.key(); + int dotIndex = compositeKey.indexOf('.'); + if (dotIndex < 0) { + throw new IllegalArgumentException("Invalid composite key format: " + compositeKey); + } + String relationName = compositeKey.substring(0, dotIndex); + String propertyName = compositeKey.substring(dotIndex + 1); + + // Check if the property is a target entity property (identifier, name) + if (IDENTIFIER.equals(propertyName) || NAME.equals(propertyName)) { + // Join to target entity identifiers first + Join targetIdJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); + // Create a subquery to find the actual target entities and filter by their + // properties + var subquery = query.subquery(String.class); + var subRoot = subquery.from(EntityJpaEntity.class); + subquery.select(subRoot.get(IDENTIFIER)).where( + buildPredicate(cb, subRoot.get(propertyName), criterion.operator(), criterion.value())); + + return cb.and(cb.equal(relJoin.get(NAME), relationName), + cb.in(targetIdJoin).value(subquery)); + } else { + // Direct relation property (shouldn't happen normally as RelationJpaEntity has + // limited properties) + return cb.and(cb.equal(relJoin.get(NAME), relationName), + buildPredicate(cb, relJoin.get(propertyName), criterion.operator(), criterion.value())); + } + }; + } + + private static Predicate buildPredicate(CriteriaBuilder cb, Expression field, + FilterOperator operator, String value) { + return switch (operator) { + case EQUALS -> JpaPredicateBuilder.buildPredicate(cb, field, SearchOperator.EQ, value); + case CONTAINS -> JpaPredicateBuilder.buildPredicate(cb, field, SearchOperator.CONTAINS, + value); + // LESS_THAN / GREATER_THAN keep lexicographic string comparison (System A + // semantics). + case LESS_THAN -> cb.lessThan(field.as(String.class), value); + case GREATER_THAN -> cb.greaterThan(field.as(String.class), value); + }; + } + + private static Specification relationNameSpec(FilterCriterion criterion) { + return (root, query, cb) -> { + query.distinct(true); + Join relJoin = root.join(RELATIONS); + return buildPredicate(cb, relJoin.get(NAME), criterion.operator(), criterion.value()); + }; + } + + private static Specification relationsAsTargetNameSpec( + FilterCriterion criterion) { + return (root, query, cb) -> { + // Find entities whose identifier appears as a target in any relation whose name + // matches. + // Uses a correlated subquery to avoid joining through the entity's own outgoing + // relations. + Subquery subquery = query.subquery(String.class); + Root relRoot = subquery.from(RelationJpaEntity.class); + Join targetJoin = relRoot.join(TARGET_ENTITY_IDENTIFIERS); + subquery.select(targetJoin) + .where(buildPredicate(cb, relRoot.get(NAME), criterion.operator(), criterion.value())); + return cb.in(root.get(IDENTIFIER)).value(subquery); + }; + } + + /// Finds entities whose `identifier` appears as a `targetEntityIdentifier` in + /// any + /// relation whose **source entity** property matches the criterion. + /// + /// Example: `relations_as_target.api-link.name:microservice` returns entities + /// that + /// are targeted by a `api-link` relation originating from an entity whose name + /// contains "microservice". + private static Specification relationsAsTargetPropertySpec( + FilterCriterion criterion) { + return (root, query, cb) -> { + String compositeKey = criterion.key(); + int dotIndex = compositeKey.indexOf('.'); + if (dotIndex < 0) { + throw new IllegalArgumentException("Invalid composite key format: " + compositeKey); + } + String relationName = compositeKey.substring(0, dotIndex); + String propertyName = compositeKey.substring(dotIndex + 1); // "identifier" or "name" + + // Subquery: collect all target identifiers from relations named + // that originate from source entities whose matches. + Subquery subquery = query.subquery(String.class); + Root sourceRoot = subquery.from(EntityJpaEntity.class); + Join relJoin = sourceRoot.join(RELATIONS); + Join targetJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); + subquery.select(targetJoin).where(cb.equal(relJoin.get(NAME), relationName), buildPredicate( + cb, sourceRoot.get(propertyName), criterion.operator(), criterion.value())); + return cb.in(root.get(IDENTIFIER)).value(subquery); + }; + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecification.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecification.java index 659bb5bd..44cd275b 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecification.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecification.java @@ -1,7 +1,14 @@ package com.decathlon.idp_core.infrastructure.adapters.persistence.specification; +import static com.decathlon.idp_core.infrastructure.adapters.persistence.specification.JpaPredicateBuilder.buildPredicate; +import static com.decathlon.idp_core.infrastructure.adapters.persistence.specification.JpaPredicateBuilder.escapeLikeWildcards; + import java.util.List; +import jakarta.persistence.criteria.Join; +import jakarta.persistence.criteria.Root; +import jakarta.persistence.criteria.Subquery; + import org.hibernate.query.criteria.HibernateCriteriaBuilder; import org.springframework.data.jpa.domain.Specification; @@ -10,12 +17,6 @@ import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.EntityJpaEntity; import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.RelationJpaEntity; -import static com.decathlon.idp_core.infrastructure.adapters.persistence.specification.JpaPredicateBuilder.buildPredicate; -import static com.decathlon.idp_core.infrastructure.adapters.persistence.specification.JpaPredicateBuilder.escapeLikeWildcards; - -import jakarta.persistence.criteria.Join; -import jakarta.persistence.criteria.Root; -import jakarta.persistence.criteria.Subquery; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -46,262 +47,263 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public final class EntitySearchSpecification { - private static final String TEMPLATE_IDENTIFIER = "templateIdentifier"; - private static final String IDENTIFIER = "identifier"; - private static final String NAME = "name"; - private static final String RELATION = "relation"; - private static final String RELATIONS = "relations"; - private static final String RELATIONS_AS_TARGET = "relations_as_target"; - private static final String TARGET_ENTITY_IDENTIFIERS = "targetEntityIdentifiers"; - private static final String PROPERTY_PREFIX = "property."; - private static final String RELATION_PREFIX = "relation."; - private static final String RELATIONS_AS_TARGET_PREFIX = "relations_as_target."; - - /// Builds a [Specification] from the root [SearchFilterNode]. - /// - /// @param filter the root of the search filter tree - /// @return a composed [Specification] matching the filter tree - public static Specification of(SearchFilterNode filter) { - return build(filter); - } - - /// Builds a global free-text search [Specification] that matches entities whose - /// `identifier`, `name`, `templateIdentifier`, or any property value contains the given string (case-insensitive). - /// - /// The four conditions are combined with OR so that a match on any field is sufficient. - /// The "any property" branch uses a correlated EXISTS subquery to avoid row multiplication. - /// All comparisons use `ILIKE` so that GIN trigram indexes (V3_5) can be leveraged. - /// - /// @param query the search string; must be non-null and non-blank - /// @return a [Specification] implementing the global text search - public static Specification globalTextSearch(String query) { - // No toLowerCase() needed — ILIKE is inherently case-insensitive. - String escaped = escapeLikeWildcards(query); - String pattern = "%" + escaped + "%"; - - Specification byIdentifier = - (root, q, cb) -> ((HibernateCriteriaBuilder) cb).ilike(root.get(IDENTIFIER), pattern, JpaPredicateBuilder.LIKE_ESCAPE_CHAR); - - Specification byName = - (root, q, cb) -> ((HibernateCriteriaBuilder) cb).ilike(root.get(NAME), pattern, JpaPredicateBuilder.LIKE_ESCAPE_CHAR); - - Specification byTemplate = - (root, q, cb) -> ((HibernateCriteriaBuilder) cb).ilike(root.get(TEMPLATE_IDENTIFIER), pattern, JpaPredicateBuilder.LIKE_ESCAPE_CHAR); - - Specification byAnyProperty = (root, queryCtx, cb) -> { - // Correlated EXISTS: does this entity have at least one property whose value matches? - var sub = queryCtx.subquery(Integer.class); - var subRoot = sub.from(EntityJpaEntity.class); - var propJoin = subRoot.join("properties"); - sub.select(cb.literal(1)) - .where( - cb.equal(subRoot.get("id"), root.get("id")), - ((HibernateCriteriaBuilder) cb).ilike(propJoin.get("value").as(String.class), pattern, JpaPredicateBuilder.LIKE_ESCAPE_CHAR) - ); - return cb.exists(sub); - }; - - return byIdentifier.or(byName).or(byTemplate).or(byAnyProperty); - } - - private static Specification build(SearchFilterNode node) { + private static final String TEMPLATE_IDENTIFIER = "templateIdentifier"; + private static final String IDENTIFIER = "identifier"; + private static final String NAME = "name"; + private static final String RELATION = "relation"; + private static final String RELATIONS = "relations"; + private static final String RELATIONS_AS_TARGET = "relations_as_target"; + private static final String TARGET_ENTITY_IDENTIFIERS = "targetEntityIdentifiers"; + private static final String PROPERTY_PREFIX = "property."; + private static final String RELATION_PREFIX = "relation."; + private static final String RELATIONS_AS_TARGET_PREFIX = "relations_as_target."; + + /// Builds a [Specification] from the root [SearchFilterNode]. + /// + /// @param filter the root of the search filter tree + /// @return a composed [Specification] matching the filter tree + public static Specification of(SearchFilterNode filter) { + return build(filter); + } + + /// Builds a global free-text search [Specification] that matches entities whose + /// `identifier`, `name`, `templateIdentifier`, or any property value contains + /// the given string (case-insensitive). + /// + /// The four conditions are combined with OR so that a match on any field is + /// sufficient. + /// The "any property" branch uses a correlated EXISTS subquery to avoid row + /// multiplication. + /// All comparisons use `ILIKE` so that GIN trigram indexes (V3_5) can be + /// leveraged. + /// + /// @param query the search string; must be non-null and non-blank + /// @return a [Specification] implementing the global text search + public static Specification globalTextSearch(String query) { + // No toLowerCase() needed — ILIKE is inherently case-insensitive. + String escaped = escapeLikeWildcards(query); + String pattern = "%" + escaped + "%"; + + Specification byIdentifier = (root, q, cb) -> ((HibernateCriteriaBuilder) cb) + .ilike(root.get(IDENTIFIER), pattern, JpaPredicateBuilder.LIKE_ESCAPE_CHAR); + + Specification byName = (root, q, cb) -> ((HibernateCriteriaBuilder) cb) + .ilike(root.get(NAME), pattern, JpaPredicateBuilder.LIKE_ESCAPE_CHAR); + + Specification byTemplate = (root, q, cb) -> ((HibernateCriteriaBuilder) cb) + .ilike(root.get(TEMPLATE_IDENTIFIER), pattern, JpaPredicateBuilder.LIKE_ESCAPE_CHAR); + + Specification byAnyProperty = (root, queryCtx, cb) -> { + // Correlated EXISTS: does this entity have at least one property whose value + // matches? + var sub = queryCtx.subquery(Integer.class); + var subRoot = sub.from(EntityJpaEntity.class); + var propJoin = subRoot.join("properties"); + sub.select(cb.literal(1)).where(cb.equal(subRoot.get("id"), root.get("id")), + ((HibernateCriteriaBuilder) cb).ilike(propJoin.get("value").as(String.class), pattern, + JpaPredicateBuilder.LIKE_ESCAPE_CHAR)); + return cb.exists(sub); + }; + + return byIdentifier.or(byName).or(byTemplate).or(byAnyProperty); + } + + private static Specification build(SearchFilterNode node) { return switch (node) { case SearchFilterNode.Group g -> buildGroup(g); case SearchFilterNode.Criterion c -> buildCriterion(c); }; } - private static Specification buildGroup(SearchFilterNode.Group group) { - var nodes = group.nodes(); - if (nodes.isEmpty()) { - return (root, query, cb) -> cb.conjunction(); // empty group matches all - } + private static Specification buildGroup(SearchFilterNode.Group group) { + var nodes = group.nodes(); + if (nodes.isEmpty()) { + return (root, query, cb) -> cb.conjunction(); // empty group matches all + } - List> specs = nodes.stream().map(EntitySearchSpecification::build).toList(); + List> specs = nodes.stream() + .map(EntitySearchSpecification::build).toList(); - return switch (group.connector()) { - case AND -> specs.stream().reduce(Specification::and).orElseThrow(); - case OR -> specs.stream().reduce(Specification::or).orElseThrow(); - }; - } + return switch (group.connector()) { + case AND -> specs.stream().reduce(Specification::and).orElseThrow(); + case OR -> specs.stream().reduce(Specification::or).orElseThrow(); + }; + } - // --- Field-based criterion dispatch --- - - private static Specification buildCriterion(SearchFilterNode.Criterion c) { - var field = c.field(); - if ("template".equals(field)) { - return (root, query, cb) -> buildPredicate(cb, root.get(TEMPLATE_IDENTIFIER), c.operation(), c.value()); - } - if (IDENTIFIER.equals(field)) { - return (root, query, cb) -> buildPredicate(cb, root.get(IDENTIFIER), c.operation(), c.value()); - } - if (NAME.equals(field)) { - return (root, query, cb) -> buildPredicate(cb, root.get(NAME), c.operation(), c.value()); - } - if (field.startsWith(PROPERTY_PREFIX)) { - return propertySpec(c, field.substring(PROPERTY_PREFIX.length())); - } - if (field.startsWith(RELATIONS_AS_TARGET_PREFIX)) { - return relationsAsTargetSpec(c, field.substring(RELATIONS_AS_TARGET_PREFIX.length())); - } - if (RELATIONS_AS_TARGET.equals(field)) { - return relationsAsTargetNameSpec(c); - } - if (RELATION.equals(field)) { - return relationNameSpec(c); - } - if (field.startsWith(RELATION_PREFIX)) { - return relationSpec(c, field.substring(RELATION_PREFIX.length())); - } - throw new IllegalArgumentException("Unknown search field: " + field); - } + // --- Field-based criterion dispatch --- - // --- Property spec --- - - private static Specification propertySpec(SearchFilterNode.Criterion c, String propertyName) { - return (root, query, cb) -> { - // Correlated EXISTS: does this entity have a property with the given name and value? - // Using EXISTS instead of JOIN avoids row multiplication and removes the need for DISTINCT. - var sub = query.subquery(Integer.class); - var subRoot = sub.from(EntityJpaEntity.class); - var propJoin = subRoot.join("properties"); - sub.select(cb.literal(1)) - .where( - cb.equal(subRoot.get("id"), root.get("id")), - cb.equal(propJoin.get(NAME), propertyName), - buildPredicate(cb, propJoin.get("value"), c.operation(), c.value()) - ); - return cb.exists(sub); - }; + private static Specification buildCriterion(SearchFilterNode.Criterion c) { + var field = c.field(); + if ("template".equals(field)) { + return (root, query, cb) -> buildPredicate(cb, root.get(TEMPLATE_IDENTIFIER), c.operation(), + c.value()); } - - // --- Relation specs --- - - private static Specification relationNameSpec(SearchFilterNode.Criterion c) { - return (root, query, cb) -> { - // Correlated EXISTS: does this entity have at least one relation whose name matches? - var sub = query.subquery(Integer.class); - var subRoot = sub.from(EntityJpaEntity.class); - var relJoin = subRoot.join(RELATIONS); - sub.select(cb.literal(1)) - .where( - cb.equal(subRoot.get("id"), root.get("id")), - buildPredicate(cb, relJoin.get(NAME), c.operation(), c.value()) - ); - return cb.exists(sub); - }; + if (IDENTIFIER.equals(field)) { + return (root, query, cb) -> buildPredicate(cb, root.get(IDENTIFIER), c.operation(), + c.value()); } - - private static Specification relationSpec(SearchFilterNode.Criterion c, String relationPart) { - int dotIndex = relationPart.indexOf('.'); - if (dotIndex > 0) { - // relation.{name}.{identifier|name} → filter by target entity property with a subquery - String relationName = relationPart.substring(0, dotIndex); - String property = relationPart.substring(dotIndex + 1); - return relationPropertySpec(c, relationName, property); - } - // relation.{name} → filter by target entity identifier - return relationEntitySpec(c, relationPart); + if (NAME.equals(field)) { + return (root, query, cb) -> buildPredicate(cb, root.get(NAME), c.operation(), c.value()); } - - private static Specification relationEntitySpec(SearchFilterNode.Criterion c, String relationName) { - return (root, query, cb) -> { - // Correlated EXISTS: does this entity have a relation named - // whose target entity identifier matches the criterion? - var sub = query.subquery(Integer.class); - var subRoot = sub.from(EntityJpaEntity.class); - var relJoin = subRoot.join(RELATIONS); - var targetJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); - sub.select(cb.literal(1)) - .where( - cb.equal(subRoot.get("id"), root.get("id")), - cb.equal(relJoin.get(NAME), relationName), - buildPredicate(cb, targetJoin, c.operation(), c.value()) - ); - return cb.exists(sub); - }; + if (field.startsWith(PROPERTY_PREFIX)) { + return propertySpec(c, field.substring(PROPERTY_PREFIX.length())); } - - private static Specification relationPropertySpec( - SearchFilterNode.Criterion c, String relationName, String property) { - return (root, query, cb) -> { - // Correlated EXISTS: does this entity have a relation named - // whose target identifier appears in the set of entity identifiers - // whose matches the criterion? - var sub = query.subquery(Integer.class); - var subRoot = sub.from(EntityJpaEntity.class); - var relJoin = subRoot.join(RELATIONS); - var targetIdJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); - - // Inner scalar subquery: entity identifiers whose identifier/name satisfies the criterion. - var innerSubquery = query.subquery(String.class); - var innerRoot = innerSubquery.from(EntityJpaEntity.class); - innerSubquery.select(innerRoot.get(IDENTIFIER)) - .where(buildPredicate(cb, innerRoot.get(property), c.operation(), c.value())); - - sub.select(cb.literal(1)) - .where( - cb.equal(subRoot.get("id"), root.get("id")), - cb.equal(relJoin.get(NAME), relationName), - cb.in(targetIdJoin).value(innerSubquery) - ); - return cb.exists(sub); - }; + if (field.startsWith(RELATIONS_AS_TARGET_PREFIX)) { + return relationsAsTargetSpec(c, field.substring(RELATIONS_AS_TARGET_PREFIX.length())); } - - // --- Relations-as-target specs --- - - private static Specification relationsAsTargetNameSpec(SearchFilterNode.Criterion c) { - return (root, query, cb) -> { - // Subquery: collect all target entity identifiers from relations whose name matches. - // For NOT_CONTAINS / NEQ (negative operators): use NOT IN with the positive equivalent - // predicate so that the result means "not targeted by any matching reverse relation", - // which is the natural set-membership interpretation of "does not contain". - SearchOperator effectiveOp = switch (c.operation()) { - case NOT_CONTAINS -> SearchOperator.CONTAINS; - case NEQ -> SearchOperator.EQ; - default -> c.operation(); - }; - - Subquery subquery = query.subquery(String.class); - Root sourceRoot = subquery.from(EntityJpaEntity.class); - Join relJoin = sourceRoot.join(RELATIONS); - Join targetJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); - subquery.select(targetJoin) - .where(buildPredicate(cb, relJoin.get(NAME), effectiveOp, c.value())); - - boolean isNegated = c.operation() == SearchOperator.NOT_CONTAINS - || c.operation() == SearchOperator.NEQ; - var membership = cb.in(root.get(IDENTIFIER)).value(subquery); - return isNegated ? cb.not(membership) : membership; - }; + if (RELATIONS_AS_TARGET.equals(field)) { + return relationsAsTargetNameSpec(c); } - - private static Specification relationsAsTargetSpec( - SearchFilterNode.Criterion c, String relPart) { - int dotIndex = relPart.indexOf('.'); - if (dotIndex <= 0) { - throw new IllegalArgumentException( - "Invalid field 'relations_as_target." + relPart - + "': expected form relations_as_target.{relationName}.{identifier|name}"); - } - String relationName = relPart.substring(0, dotIndex); - String property = relPart.substring(dotIndex + 1); // identifier or name - - return (root, query, cb) -> { - // Subquery: collect target identifiers from relations named - // whose source entity's matches the criterion. - Subquery subquery = query.subquery(String.class); - Root sourceRoot = subquery.from(EntityJpaEntity.class); - Join relJoin = sourceRoot.join(RELATIONS); - Join targetJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); - subquery.select(targetJoin) - .where( - cb.equal(relJoin.get(NAME), relationName), - buildPredicate(cb, sourceRoot.get(property), c.operation(), c.value()) - ); - return cb.in(root.get(IDENTIFIER)).value(subquery); - }; + if (RELATION.equals(field)) { + return relationNameSpec(c); + } + if (field.startsWith(RELATION_PREFIX)) { + return relationSpec(c, field.substring(RELATION_PREFIX.length())); + } + throw new IllegalArgumentException("Unknown search field: " + field); + } + + // --- Property spec --- + + private static Specification propertySpec(SearchFilterNode.Criterion c, + String propertyName) { + return (root, query, cb) -> { + // Correlated EXISTS: does this entity have a property with the given name and + // value? + // Using EXISTS instead of JOIN avoids row multiplication and removes the need + // for DISTINCT. + var sub = query.subquery(Integer.class); + var subRoot = sub.from(EntityJpaEntity.class); + var propJoin = subRoot.join("properties"); + sub.select(cb.literal(1)).where(cb.equal(subRoot.get("id"), root.get("id")), + cb.equal(propJoin.get(NAME), propertyName), + buildPredicate(cb, propJoin.get("value"), c.operation(), c.value())); + return cb.exists(sub); + }; + } + + // --- Relation specs --- + + private static Specification relationNameSpec(SearchFilterNode.Criterion c) { + return (root, query, cb) -> { + // Correlated EXISTS: does this entity have at least one relation whose name + // matches? + var sub = query.subquery(Integer.class); + var subRoot = sub.from(EntityJpaEntity.class); + var relJoin = subRoot.join(RELATIONS); + sub.select(cb.literal(1)).where(cb.equal(subRoot.get("id"), root.get("id")), + buildPredicate(cb, relJoin.get(NAME), c.operation(), c.value())); + return cb.exists(sub); + }; + } + + private static Specification relationSpec(SearchFilterNode.Criterion c, + String relationPart) { + int dotIndex = relationPart.indexOf('.'); + if (dotIndex > 0) { + // relation.{name}.{identifier|name} → filter by target entity property with a + // subquery + String relationName = relationPart.substring(0, dotIndex); + String property = relationPart.substring(dotIndex + 1); + return relationPropertySpec(c, relationName, property); + } + // relation.{name} → filter by target entity identifier + return relationEntitySpec(c, relationPart); + } + + private static Specification relationEntitySpec(SearchFilterNode.Criterion c, + String relationName) { + return (root, query, cb) -> { + // Correlated EXISTS: does this entity have a relation named + // whose target entity identifier matches the criterion? + var sub = query.subquery(Integer.class); + var subRoot = sub.from(EntityJpaEntity.class); + var relJoin = subRoot.join(RELATIONS); + var targetJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); + sub.select(cb.literal(1)).where(cb.equal(subRoot.get("id"), root.get("id")), + cb.equal(relJoin.get(NAME), relationName), + buildPredicate(cb, targetJoin, c.operation(), c.value())); + return cb.exists(sub); + }; + } + + private static Specification relationPropertySpec(SearchFilterNode.Criterion c, + String relationName, String property) { + return (root, query, cb) -> { + // Correlated EXISTS: does this entity have a relation named + // whose target identifier appears in the set of entity identifiers + // whose matches the criterion? + var sub = query.subquery(Integer.class); + var subRoot = sub.from(EntityJpaEntity.class); + var relJoin = subRoot.join(RELATIONS); + var targetIdJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); + + // Inner scalar subquery: entity identifiers whose identifier/name satisfies the + // criterion. + var innerSubquery = query.subquery(String.class); + var innerRoot = innerSubquery.from(EntityJpaEntity.class); + innerSubquery.select(innerRoot.get(IDENTIFIER)) + .where(buildPredicate(cb, innerRoot.get(property), c.operation(), c.value())); + + sub.select(cb.literal(1)).where(cb.equal(subRoot.get("id"), root.get("id")), + cb.equal(relJoin.get(NAME), relationName), cb.in(targetIdJoin).value(innerSubquery)); + return cb.exists(sub); + }; + } + + // --- Relations-as-target specs --- + + private static Specification relationsAsTargetNameSpec( + SearchFilterNode.Criterion c) { + return (root, query, cb) -> { + // Subquery: collect all target entity identifiers from relations whose name + // matches. + // For NOT_CONTAINS / NEQ (negative operators): use NOT IN with the positive + // equivalent + // predicate so that the result means "not targeted by any matching reverse + // relation", + // which is the natural set-membership interpretation of "does not contain". + SearchOperator effectiveOp = switch (c.operation()) { + case NOT_CONTAINS -> SearchOperator.CONTAINS; + case NEQ -> SearchOperator.EQ; + default -> c.operation(); + }; + + Subquery subquery = query.subquery(String.class); + Root sourceRoot = subquery.from(EntityJpaEntity.class); + Join relJoin = sourceRoot.join(RELATIONS); + Join targetJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); + subquery.select(targetJoin) + .where(buildPredicate(cb, relJoin.get(NAME), effectiveOp, c.value())); + + boolean isNegated = c.operation() == SearchOperator.NOT_CONTAINS + || c.operation() == SearchOperator.NEQ; + var membership = cb.in(root.get(IDENTIFIER)).value(subquery); + return isNegated ? cb.not(membership) : membership; + }; + } + + private static Specification relationsAsTargetSpec(SearchFilterNode.Criterion c, + String relPart) { + int dotIndex = relPart.indexOf('.'); + if (dotIndex <= 0) { + throw new IllegalArgumentException("Invalid field 'relations_as_target." + relPart + + "': expected form relations_as_target.{relationName}.{identifier|name}"); } + String relationName = relPart.substring(0, dotIndex); + String property = relPart.substring(dotIndex + 1); // identifier or name + + return (root, query, cb) -> { + // Subquery: collect target identifiers from relations named + // whose source entity's matches the criterion. + Subquery subquery = query.subquery(String.class); + Root sourceRoot = subquery.from(EntityJpaEntity.class); + Join relJoin = sourceRoot.join(RELATIONS); + Join targetJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); + subquery.select(targetJoin).where(cb.equal(relJoin.get(NAME), relationName), + buildPredicate(cb, sourceRoot.get(property), c.operation(), c.value())); + return cb.in(root.get(IDENTIFIER)).value(subquery); + }; + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/JpaPredicateBuilder.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/JpaPredicateBuilder.java index b197c1cc..ec66c04b 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/JpaPredicateBuilder.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/JpaPredicateBuilder.java @@ -2,13 +2,14 @@ import java.math.BigDecimal; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.Expression; +import jakarta.persistence.criteria.Predicate; + import org.hibernate.query.criteria.HibernateCriteriaBuilder; import com.decathlon.idp_core.domain.model.enums.SearchOperator; -import jakarta.persistence.criteria.CriteriaBuilder; -import jakarta.persistence.criteria.Expression; -import jakarta.persistence.criteria.Predicate; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -29,88 +30,88 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) final class JpaPredicateBuilder { - static final char LIKE_ESCAPE_CHAR = '\\'; + static final char LIKE_ESCAPE_CHAR = '\\'; - /// Builds a [Predicate] for the given [SearchOperator] against the provided field expression. - /// - /// - EQ / NEQ use `LOWER(field) = LOWER(value)` to leverage functional btree indexes. - /// - CONTAINS / NOT_CONTAINS / STARTS_WITH / ENDS_WITH use `ILIKE` to leverage GIN - /// trigram indexes and avoid redundant client-side lowercasing. - /// - GT / GTE / LT / LTE cast the field to `NUMERIC` before comparing. - /// - /// @param cb the JPA criteria builder (must be a [HibernateCriteriaBuilder] at runtime) - /// @param field the expression to filter on - /// @param operator the comparison operator - /// @param value the user-supplied value (not yet escaped or lowercased) - /// @return a [Predicate] representing the comparison - static Predicate buildPredicate( - CriteriaBuilder cb, - Expression field, - SearchOperator operator, - String value) { - if (isNumericOperator(operator)) { - return buildNumericPredicate(cb, field, operator, new BigDecimal(value)); - } - HibernateCriteriaBuilder hcb = (HibernateCriteriaBuilder) cb; - Expression stringField = field.as(String.class); - return switch (operator) { - case EQ -> cb.equal(cb.lower(stringField), value.toLowerCase()); - case NEQ -> cb.notEqual(cb.lower(stringField), value.toLowerCase()); - case CONTAINS -> { - String escaped = escapeLikeWildcards(value); - yield hcb.ilike(stringField, "%" + escaped + "%", LIKE_ESCAPE_CHAR); - } - case NOT_CONTAINS -> { - String escaped = escapeLikeWildcards(value); - yield hcb.notIlike(stringField, "%" + escaped + "%", LIKE_ESCAPE_CHAR); - } - case STARTS_WITH -> { - String escaped = escapeLikeWildcards(value); - yield hcb.ilike(stringField, escaped + "%", LIKE_ESCAPE_CHAR); - } - case ENDS_WITH -> { - String escaped = escapeLikeWildcards(value); - yield hcb.ilike(stringField, "%" + escaped, LIKE_ESCAPE_CHAR); - } - default -> throw new IllegalStateException("Unhandled operator: " + operator); - }; + /// Builds a [Predicate] for the given [SearchOperator] against the provided + /// field expression. + /// + /// - EQ / NEQ use `LOWER(field) = LOWER(value)` to leverage functional btree + /// indexes. + /// - CONTAINS / NOT_CONTAINS / STARTS_WITH / ENDS_WITH use `ILIKE` to leverage + /// GIN + /// trigram indexes and avoid redundant client-side lowercasing. + /// - GT / GTE / LT / LTE cast the field to `NUMERIC` before comparing. + /// + /// @param cb the JPA criteria builder (must be a [HibernateCriteriaBuilder] at + /// runtime) + /// @param field the expression to filter on + /// @param operator the comparison operator + /// @param value the user-supplied value (not yet escaped or lowercased) + /// @return a [Predicate] representing the comparison + static Predicate buildPredicate(CriteriaBuilder cb, Expression field, SearchOperator operator, + String value) { + if (isNumericOperator(operator)) { + return buildNumericPredicate(cb, field, operator, new BigDecimal(value)); } + HibernateCriteriaBuilder hcb = (HibernateCriteriaBuilder) cb; + Expression stringField = field.as(String.class); + return switch (operator) { + case EQ -> cb.equal(cb.lower(stringField), value.toLowerCase()); + case NEQ -> cb.notEqual(cb.lower(stringField), value.toLowerCase()); + case CONTAINS -> { + String escaped = escapeLikeWildcards(value); + yield hcb.ilike(stringField, "%" + escaped + "%", LIKE_ESCAPE_CHAR); + } + case NOT_CONTAINS -> { + String escaped = escapeLikeWildcards(value); + yield hcb.notIlike(stringField, "%" + escaped + "%", LIKE_ESCAPE_CHAR); + } + case STARTS_WITH -> { + String escaped = escapeLikeWildcards(value); + yield hcb.ilike(stringField, escaped + "%", LIKE_ESCAPE_CHAR); + } + case ENDS_WITH -> { + String escaped = escapeLikeWildcards(value); + yield hcb.ilike(stringField, "%" + escaped, LIKE_ESCAPE_CHAR); + } + default -> throw new IllegalStateException("Unhandled operator: " + operator); + }; + } - static boolean isNumericOperator(SearchOperator operator) { - return switch (operator) { - case GT, GTE, LT, LTE -> true; - default -> false; - }; - } + static boolean isNumericOperator(SearchOperator operator) { + return switch (operator) { + case GT, GTE, LT, LTE -> true; + default -> false; + }; + } - static Predicate buildNumericPredicate( - CriteriaBuilder cb, - Expression field, - SearchOperator operator, - BigDecimal numericValue) { - // Explicit SQL CAST(field AS NUMERIC): the property value column is VARCHAR; without - // this cast PostgreSQL would reject the comparison with a numeric literal. - Expression numericField = - ((HibernateCriteriaBuilder) cb).cast( - (org.hibernate.query.criteria.JpaExpression) field, BigDecimal.class); - return switch (operator) { - case GT -> cb.greaterThan(numericField, numericValue); - case GTE -> cb.greaterThanOrEqualTo(numericField, numericValue); - case LT -> cb.lessThan(numericField, numericValue); - case LTE -> cb.lessThanOrEqualTo(numericField, numericValue); - default -> throw new IllegalStateException("Not a numeric operator: " + operator); - }; - } + static Predicate buildNumericPredicate(CriteriaBuilder cb, Expression field, + SearchOperator operator, BigDecimal numericValue) { + // Explicit SQL CAST(field AS NUMERIC): the property value column is VARCHAR; + // without + // this cast PostgreSQL would reject the comparison with a numeric literal. + Expression numericField = ((HibernateCriteriaBuilder) cb) + .cast((org.hibernate.query.criteria.JpaExpression) field, BigDecimal.class); + return switch (operator) { + case GT -> cb.greaterThan(numericField, numericValue); + case GTE -> cb.greaterThanOrEqualTo(numericField, numericValue); + case LT -> cb.lessThan(numericField, numericValue); + case LTE -> cb.lessThanOrEqualTo(numericField, numericValue); + default -> throw new IllegalStateException("Not a numeric operator: " + operator); + }; + } - /// Escapes SQL LIKE wildcards (`%` and `_`) in the given value so they are treated as - /// literal characters rather than pattern metacharacters. - /// - /// Used by all ILIKE-based operators. The value does not need to be pre-lowercased - /// because `ILIKE` handles case-insensitivity natively. - static String escapeLikeWildcards(String value) { - return value - .replace(String.valueOf(LIKE_ESCAPE_CHAR), LIKE_ESCAPE_CHAR + String.valueOf(LIKE_ESCAPE_CHAR)) - .replace("%", LIKE_ESCAPE_CHAR + "%") - .replace("_", LIKE_ESCAPE_CHAR + "_"); - } + /// Escapes SQL LIKE wildcards (`%` and `_`) in the given value so they are + /// treated as + /// literal characters rather than pattern metacharacters. + /// + /// Used by all ILIKE-based operators. The value does not need to be + /// pre-lowercased + /// because `ILIKE` handles case-insensitivity natively. + static String escapeLikeWildcards(String value) { + return value + .replace(String.valueOf(LIKE_ESCAPE_CHAR), + LIKE_ESCAPE_CHAR + String.valueOf(LIKE_ESCAPE_CHAR)) + .replace("%", LIKE_ESCAPE_CHAR + "%").replace("_", LIKE_ESCAPE_CHAR + "_"); + } } diff --git a/src/test/java/com/decathlon/idp_core/AbstractIntegrationTest.java b/src/test/java/com/decathlon/idp_core/AbstractIntegrationTest.java index 8a7adc13..00356b45 100644 --- a/src/test/java/com/decathlon/idp_core/AbstractIntegrationTest.java +++ b/src/test/java/com/decathlon/idp_core/AbstractIntegrationTest.java @@ -157,7 +157,6 @@ private static HttpRequest getRequestDefinition(HttpMethod httpMethod, String pa return requestDefinition; } - @SneakyThrows public static String getJsonTestFileContent(String path) { try (var inputStream = new ClassPathResource(path).getInputStream()) { diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java index 6c5312ee..1a3b3d94 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java @@ -38,10 +38,10 @@ import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.model.enums.LogicalConnector; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; -import com.decathlon.idp_core.domain.service.filter.EntityFilterDslParser; -import com.decathlon.idp_core.domain.service.search.SearchFilterValidationService; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; +import com.decathlon.idp_core.domain.service.filter.EntityFilterDslParser; +import com.decathlon.idp_core.domain.service.search.SearchFilterValidationService; @ExtendWith(MockitoExtension.class) @DisplayName("EntityService Tests") @@ -299,9 +299,8 @@ void shouldSearchEntitiesWithValidSort() { @DisplayName("Should reject page size exceeding maximum") void shouldRejectPageSizeExceedingMaximum() { var filter = emptyFilter(); - assertThrows(InvalidSearchQueryException.class, - () -> entityService.searchEntities(filter, null, 0, SearchConstraints.MAX_PAGE_SIZE + 1, - null)); + assertThrows(InvalidSearchQueryException.class, () -> entityService.searchEntities(filter, null, + 0, SearchConstraints.MAX_PAGE_SIZE + 1, null)); } @Test diff --git a/src/test/java/com/decathlon/idp_core/domain/service/filter/EntityFilterDslParserTest.java b/src/test/java/com/decathlon/idp_core/domain/service/filter/EntityFilterDslParserTest.java index befb47c2..d738173f 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/filter/EntityFilterDslParserTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/filter/EntityFilterDslParserTest.java @@ -5,7 +5,6 @@ import java.util.stream.Stream; -import com.decathlon.idp_core.domain.exception.filter.InvalidFilterDslException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -14,8 +13,9 @@ import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; -import com.decathlon.idp_core.domain.constant.ValidationMessages; import com.decathlon.idp_core.domain.constant.FilterConstraints; +import com.decathlon.idp_core.domain.constant.ValidationMessages; +import com.decathlon.idp_core.domain.exception.filter.InvalidFilterDslException; import com.decathlon.idp_core.domain.model.entity.EntityFilter; import com.decathlon.idp_core.domain.model.entity.FilterCriterion; import com.decathlon.idp_core.domain.model.enums.FilterKeyType; @@ -25,504 +25,498 @@ @SuppressWarnings("java:S2187") class EntityFilterDslParserTest { - private final EntityFilterDslParser parser = new EntityFilterDslParser(); - - private void assertSingleCriterion( - EntityFilter result, - FilterKeyType expectedKeyType, - String expectedKeyName, - FilterOperator expectedOperator, - String expectedValue) { - assertThat(result.criteria()).hasSize(1); - assertCriterion(result.criteria().getFirst(), expectedKeyType, expectedKeyName, expectedOperator, expectedValue); - } - - private void assertCriterion( - FilterCriterion criterion, - FilterKeyType expectedKeyType, - String expectedKeyName, - FilterOperator expectedOperator, - String expectedValue) { - assertThat(criterion.keyType()).isEqualTo(expectedKeyType); - assertThat(criterion.key()).isEqualTo(expectedKeyName); - assertThat(criterion.operator()).isEqualTo(expectedOperator); - assertThat(criterion.value()).isEqualTo(expectedValue); - } - - @Nested - @DisplayName("Attribute filters") - class AttributeFilterTests { - - @Test - @DisplayName("identifier equals") - void parse_attributeIdentifierEquals() { - var result = parser.parse("identifier=web-api-1"); - assertSingleCriterion(result, FilterKeyType.ATTRIBUTE, "identifier", FilterOperator.EQUALS, "web-api-1"); - } - - @Test - @DisplayName("name contains") - void parse_attributeNameContains() { - var result = parser.parse("name:API"); - assertSingleCriterion(result, FilterKeyType.ATTRIBUTE, "name", FilterOperator.CONTAINS, "API"); - } - } - - @Nested - @DisplayName("Property filters") - class PropertyFilterTests { - - @Test - @DisplayName("property equals") - void parse_propertyEquals() { - var result = parser.parse("property.language=JAVA"); - assertSingleCriterion(result, FilterKeyType.PROPERTY, "language", FilterOperator.EQUALS, "JAVA"); - } - - @Test - @DisplayName("property contains") - void parse_propertyContains() { - var result = parser.parse("property.version:1.0"); - assertSingleCriterion(result, FilterKeyType.PROPERTY, "version", FilterOperator.CONTAINS, "1.0"); - } - - @Test - @DisplayName("property less than") - void parse_propertyLessThan() { - var result = parser.parse("property.port<9000"); - assertSingleCriterion(result, FilterKeyType.PROPERTY, "port", FilterOperator.LESS_THAN, "9000"); - } - - @Test - @DisplayName("property greater than") - void parse_propertyGreaterThan() { - var result = parser.parse("property.port>1000"); - assertSingleCriterion(result, FilterKeyType.PROPERTY, "port", FilterOperator.GREATER_THAN, "1000"); - } - } - - @Nested - @DisplayName("Relation name filters") - class RelationNameFilterTests { - - @Test - @DisplayName("relation name equals") - void parse_relationNameEquals() { - var result = parser.parse("relation=api-link"); - assertSingleCriterion(result, FilterKeyType.RELATION_NAME, "", FilterOperator.EQUALS, "api-link"); - } - - @Test - @DisplayName("relation name contains") - void parse_relationNameContains() { - var result = parser.parse("relation:rover"); - assertSingleCriterion(result, FilterKeyType.RELATION_NAME, "", FilterOperator.CONTAINS, "rover"); - } - } - - @Nested - @DisplayName("Relation entity filters") - class RelationEntityFilterTests { - - @Test - @DisplayName("relation entity equals") - void parse_relationEntityEquals() { - var result = parser.parse("relation.database=my-db"); - assertSingleCriterion(result, FilterKeyType.RELATION_ENTITY, "database", FilterOperator.EQUALS, "my-db"); - } - - @Test - @DisplayName("relation entity contains") - void parse_relationEntityContains() { - var result = parser.parse("relation.database:my"); - assertSingleCriterion(result, FilterKeyType.RELATION_ENTITY, "database", FilterOperator.CONTAINS, "my"); - } - } - - @Nested - @DisplayName("Relation property filters") - class RelationPropertyFilterTests { - - @Test - @DisplayName("relation property equals") - void parse_relationPropertyEquals() { - var result = parser.parse("relation.api-link.identifier=microservice-1"); - assertSingleCriterion(result, FilterKeyType.RELATION_PROPERTY, "api-link.identifier", FilterOperator.EQUALS, "microservice-1"); - } - - @Test - @DisplayName("relation property contains") - void parse_relationPropertyContains() { - var result = parser.parse("relation.api-link.name:microservice"); - assertSingleCriterion(result, FilterKeyType.RELATION_PROPERTY, "api-link.name", FilterOperator.CONTAINS, "microservice"); - } - - @Test - @DisplayName("throws InvalidFilterDslException for unsupported property in relation (custom-prop is not identifier or name)") - void parse_relationPropertyUnsupported_throwsException() { - assertThatThrownBy(() -> parser.parse("relation.my-link.custom-prop=value")) - .isInstanceOf(InvalidFilterDslException.class) - .hasMessageContaining("custom-prop") - .hasMessageContaining("identifier") - .hasMessageContaining("name"); - } - } - - @Nested - @DisplayName("Relations as target filters") - class RelationsAsTargetFilterTests { - - @Test - @DisplayName("relations_as_target name equals") - void parse_relationsAsTargetNameEquals() { - var result = parser.parse("relations_as_target=api-link"); - assertSingleCriterion(result, FilterKeyType.RELATIONS_AS_TARGET_NAME, "", FilterOperator.EQUALS, "api-link"); - } - - @Test - @DisplayName("relations_as_target name contains") - void parse_relationsAsTargetNameContains() { - var result = parser.parse("relations_as_target:rover"); - assertSingleCriterion(result, FilterKeyType.RELATIONS_AS_TARGET_NAME, "", FilterOperator.CONTAINS, "rover"); - } - - @Test - @DisplayName("relations_as_target property identifier equals") - void parse_relationsAsTargetPropertyIdentifierEquals() { - var result = parser.parse("relations_as_target.api-link.identifier=web-api-1"); - assertSingleCriterion(result, FilterKeyType.RELATIONS_AS_TARGET_PROPERTY, "api-link.identifier", FilterOperator.EQUALS, "web-api-1"); - } - - @Test - @DisplayName("relations_as_target property name contains") - void parse_relationsAsTargetPropertyNameContains() { - var result = parser.parse("relations_as_target.api-link.name:microservice"); - assertSingleCriterion(result, FilterKeyType.RELATIONS_AS_TARGET_PROPERTY, "api-link.name", FilterOperator.CONTAINS, "microservice"); - } - - @Test - @DisplayName("throws exception for unsupported property in relations_as_target") - void parse_relationsAsTargetInvalidProperty_throwsException() { - assertThatThrownBy(() -> parser.parse("relations_as_target.api-link.language=JAVA")) - .isInstanceOf(InvalidFilterDslException.class) - .hasMessageContaining("only 'identifier' and 'name' are supported"); - } - - @Test - @DisplayName("throws exception for relations_as_target without property") - void parse_relationsAsTargetWithoutProperty_throwsException() { - assertThatThrownBy(() -> parser.parse("relations_as_target.api-link=web-api-1")) - .isInstanceOf(InvalidFilterDslException.class) - .hasMessageContaining("relations_as_target requires the form"); - } - } - - @Nested - @DisplayName("Combined AND criteria") - class CombinedCriteriaTests { - - @Test - @DisplayName("two criteria separated by semicolon") - void parse_twoCriteriaWithSemicolon() { - var result = parser.parse("name:API;property.language=JAVA"); - assertThat(result.criteria()).hasSize(2); - assertCriterion(result.criteria().get(0), FilterKeyType.ATTRIBUTE, "name", FilterOperator.CONTAINS, "API"); - assertCriterion(result.criteria().get(1), FilterKeyType.PROPERTY, "language", FilterOperator.EQUALS, "JAVA"); - } - - @Test - @DisplayName("four criteria of different key types") - void parse_fourCriteria() { - var result = parser.parse("name:API;property.language=JAVA;relation.database=my-db;relation.api-link.identifier=service-1"); - assertThat(result.criteria()).hasSize(4); - assertCriterion(result.criteria().get(0), FilterKeyType.ATTRIBUTE, "name", FilterOperator.CONTAINS, "API"); - assertCriterion(result.criteria().get(1), FilterKeyType.PROPERTY, "language", FilterOperator.EQUALS, "JAVA"); - assertCriterion(result.criteria().get(2), FilterKeyType.RELATION_ENTITY, "database", FilterOperator.EQUALS, "my-db"); - assertCriterion(result.criteria().get(3), FilterKeyType.RELATION_PROPERTY, "api-link.identifier", FilterOperator.EQUALS, "service-1"); - } - - @Test - @DisplayName("five criteria including relation property and reverse relation") - void parse_fiveCriteriaWithRelationProperty() { - var result = parser.parse("name:API;property.language=JAVA;relation.database=my-db;relation.api-link.identifier=service-1;relations_as_target.owned_by.name:platform"); - assertThat(result.criteria()).hasSize(5); - assertCriterion(result.criteria().get(0), FilterKeyType.ATTRIBUTE, "name", FilterOperator.CONTAINS, "API"); - assertCriterion(result.criteria().get(1), FilterKeyType.PROPERTY, "language", FilterOperator.EQUALS, "JAVA"); - assertCriterion(result.criteria().get(2), FilterKeyType.RELATION_ENTITY, "database", FilterOperator.EQUALS, "my-db"); - assertCriterion(result.criteria().get(3), FilterKeyType.RELATION_PROPERTY, "api-link.identifier", FilterOperator.EQUALS, "service-1"); - assertCriterion(result.criteria().get(4), FilterKeyType.RELATIONS_AS_TARGET_PROPERTY, "owned_by.name", FilterOperator.CONTAINS, "platform"); - } - } - - @Nested - @DisplayName("Invalid query syntax") - class InvalidQueryTests { - - @ParameterizedTest(name = "missing operator in: ''{0}''") - @ValueSource(strings = {"noOperatorHere", "property.lang", "relation.db"}) - @DisplayName("throws InvalidFilterDslException when operator is missing") - void parse_missingOperator_throwsException(String query) { - assertThatThrownBy(() -> parser.parse(query)) - .isInstanceOf(InvalidFilterDslException.class) - .hasMessage(ValidationMessages.FILTER_INVALID_FORMAT); - } - - @Test - @DisplayName("throws InvalidFilterDslException for unknown attribute") - void parse_unknownAttribute_throwsException() { - assertThatThrownBy(() -> parser.parse("unknownField=value")) - .isInstanceOf(InvalidFilterDslException.class) - .hasMessageContaining("Unknown attribute"); - } - - @Test - @DisplayName("throws InvalidFilterDslException for blank value") - void parse_blankValue_throwsException() { - assertThatThrownBy(() -> parser.parse("name=")) - .isInstanceOf(InvalidFilterDslException.class) - .hasMessageContaining("value must not be blank"); - } - - @Test - @DisplayName("throws InvalidFilterDslException for blank key") - void parse_blankKey_throwsException() { - assertThatThrownBy(() -> parser.parse("=value")) - .isInstanceOf(InvalidFilterDslException.class) - .hasMessageContaining("key must not be blank"); - } - - @Test - @DisplayName("throws InvalidFilterDslException for blank property name after prefix") - void parse_blankPropertyName_throwsException() { - assertThatThrownBy(() -> parser.parse("property.=JAVA")) - .isInstanceOf(InvalidFilterDslException.class) - .hasMessageContaining("key name must not be blank"); - } - } - - @Nested - @DisplayName("Security constraints") - class SecurityConstraintTests { - - @Test - @DisplayName("throws InvalidFilterDslException when criteria count exceeds limit") - void parse_tooManyCriteria_throwsException() { - var query = "property.a=1;property.b=2;property.c=3;property.d=4;property.e=5;" - + "property.f=6;property.g=7;property.h=8;property.i=9;property.j=10;" - + "property.k=11"; - assertThatThrownBy(() -> parser.parse(query)) - .isInstanceOf(InvalidFilterDslException.class) - .hasMessageContaining("maximum of %d".formatted(FilterConstraints.MAX_CRITERIA_COUNT)); - } - - @Test - @DisplayName("accepts exactly the maximum number of criteria") - void parse_exactlyMaxCriteria_succeeds() { - var query = "property.a=1;property.b=2;property.c=3;property.d=4;property.e=5;" - + "property.f=6;property.g=7;property.h=8;property.i=9;property.j=10"; - var result = parser.parse(query); - assertThat(result.criteria()).hasSize(FilterConstraints.MAX_CRITERIA_COUNT); - } - - @Test - @DisplayName("throws InvalidFilterDslException when value exceeds max length") - void parse_valueTooLong_throwsException() { - var longValue = "a".repeat(FilterConstraints.MAX_KEY_VALUE_LENGTH + 1); - assertThatThrownBy(() -> parser.parse("name=" + longValue)) - .isInstanceOf(InvalidFilterDslException.class) - .hasMessageContaining("must not exceed %d".formatted(FilterConstraints.MAX_KEY_VALUE_LENGTH)); - } - - @Test - @DisplayName("throws InvalidFilterDslException when key exceeds max length") - void parse_keyTooLong_throwsException() { - var longKey = "property." + "a".repeat(FilterConstraints.MAX_KEY_VALUE_LENGTH); - assertThatThrownBy(() -> parser.parse(longKey + "=value")) - .isInstanceOf(InvalidFilterDslException.class) - .hasMessageContaining("must not exceed %d".formatted(FilterConstraints.MAX_KEY_VALUE_LENGTH)); - } - - @ParameterizedTest(name = "valid key name: ''{0}''") - @ValueSource(strings = { - "property.language=JAVA", - "property.my-key=value", - "property.my_key=value", - "property.key123=value", - "property.lang@ge=JAVA", - "property.my key=JAVA", - "property.lang/age=JAVA", - "relation.database=my-db", - "relation.db$name=my-db", - "relation.my-cache.identifier=redis-1" - }) - @DisplayName("accepts valid key name characters") - void parse_validKeyNameChars_succeeds(String query) { - var result = parser.parse(query); - assertThat(result.criteria()).hasSize(1); - } - } - - @Nested - @DisplayName("Duplicate criterion detection") - class DuplicateCriterionTests { - - @ParameterizedTest(name = "duplicate criterion in: ''{0}''") - @ValueSource(strings = { - "name=A;name=B", - "property.language=JAVA;property.language=PYTHON", - "relation=api-link;relation=database" - }) - @DisplayName("throws InvalidFilterDslException for duplicate criteria") - void parse_duplicateCriterion_throwsException(String query) { - assertThatThrownBy(() -> parser.parse(query)) - .isInstanceOf(InvalidFilterDslException.class) - .hasMessage(ValidationMessages.FILTER_DUPLICATE_CRITERION); - } - - @Test - @DisplayName("accepts distinct attribute criteria") - void parse_distinctAttributeCriteria_succeeds() { - var result = parser.parse("identifier=web-api-1;name=Web API 1"); - assertThat(result.criteria()).hasSize(2); - } - - @Test - @DisplayName("accepts distinct property criteria") - void parse_distinctPropertyCriteria_succeeds() { - var result = parser.parse("property.language=JAVA;property.environment=PROD"); - assertThat(result.criteria()).hasSize(2); - } - } - - @Nested - @DisplayName("Type mismatch validation") - class TypeMismatchTests { - - @ParameterizedTest(name = "comparison operator on: ''{0}''") - @ValueSource(strings = {"relationapi-link"}) - @DisplayName("throws InvalidFilterDslException for less/greater than on relation name") - void parse_comparisonOnRelationName_throwsException(String query) { - assertThatThrownBy(() -> parser.parse(query)) - .isInstanceOf(InvalidFilterDslException.class) - .hasMessageContaining("is not applicable for field"); - } - - @ParameterizedTest(name = "comparison operator on: ''{0}''") - @ValueSource(strings = {"relation.databasemy-db"}) - @DisplayName("throws InvalidFilterDslException for less/greater than on relation entity") - void parse_comparisonOnRelationEntity_throwsException(String query) { - assertThatThrownBy(() -> parser.parse(query)) - .isInstanceOf(InvalidFilterDslException.class) - .hasMessageContaining("is not applicable for field"); - } - - @ParameterizedTest(name = "comparison operator on: ''{0}''") - @ValueSource(strings = {"relation.database.templatepostgresql"}) - @DisplayName("throws InvalidFilterDslException for unsupported property on relation (template is not a valid relation property)") - void parse_comparisonOnRelationTemplate_throwsException(String query) { - assertThatThrownBy(() -> parser.parse(query)) - .isInstanceOf(InvalidFilterDslException.class) - .hasMessageContaining("template"); - } - - @Test - @DisplayName("throws InvalidFilterDslException for unsupported property on relation with equals operator") - void parse_equalsOnRelationTemplate_throwsException() { - assertThatThrownBy(() -> parser.parse("relation.database.template=postgresql")) - .isInstanceOf(InvalidFilterDslException.class) - .hasMessageContaining("template") - .hasMessageContaining("identifier") - .hasMessageContaining("name"); - } - - @ParameterizedTest(name = "comparison operator on: ''{0}''") - @ValueSource(strings = {"relation.api-link.identifiermicroservice-1"}) - @DisplayName("throws InvalidFilterDslException for less/greater than on relation property") - void parse_comparisonOnRelationProperty_throwsException(String query) { - assertThatThrownBy(() -> parser.parse(query)) - .isInstanceOf(InvalidFilterDslException.class) - .hasMessageContaining("is not applicable for field"); - } - - @ParameterizedTest(name = "comparison operator on: ''{0}''") - @ValueSource(strings = {"relations_as_target.api-link.namemicroservice"}) - @DisplayName("throws InvalidFilterDslException for less/greater than on relations_as_target property") - void parse_comparisonOnRelationsAsTargetProperty_throwsException(String query) { - assertThatThrownBy(() -> parser.parse(query)) - .isInstanceOf(InvalidFilterDslException.class) - .hasMessageContaining("is not applicable for field"); - } - - @ParameterizedTest(name = "comparison operator on: ''{0}''") - @ValueSource(strings = {"nameA", "identifier parser.parse(query)) - .isInstanceOf(InvalidFilterDslException.class) - .hasMessageContaining("is not applicable for field"); - } - - @ParameterizedTest(name = "comparison operator on: ''{0}''") - @ValueSource(strings = {"property.port<9000", "property.port>1000"}) - @DisplayName("accepts less/greater than on NUMBER properties (type check is deferred to EntityService)") - void parse_comparisonOnProperty_succeeds(String query) { - var result = parser.parse(query); - assertThat(result.criteria()).hasSize(1); - } - } - - @Nested - @DisplayName("Edge cases") - class EdgeCaseTests { - - @Test - @DisplayName("consecutive semicolons produce empty filter") - void parse_consecutiveSemicolons_ignoresEmptyTokens() { - var result = parser.parse("name=API;;property.lang=JAVA"); - assertThat(result.criteria()).hasSize(2); - } - - @Test - @DisplayName("trailing semicolon is ignored") - void parse_trailingSemicolon_ignored() { - var result = parser.parse("name=API;"); - assertThat(result.criteria()).hasSize(1); - } - - @Test - @DisplayName("leading semicolon is ignored") - void parse_leadingSemicolon_ignored() { - var result = parser.parse(";name=API"); - assertThat(result.criteria()).hasSize(1); - } - - @Test - @DisplayName("values containing SQL LIKE wildcards are accepted") - void parse_valuesWithLikeWildcards_accepted() { - var result = parser.parse("name:100%_success"); - assertSingleCriterion(result, FilterKeyType.ATTRIBUTE, "name", FilterOperator.CONTAINS, "100%_success"); - } - } - - @Nested - @DisplayName("Null or blank query") - class NullOrBlankQueryTests { - - @ParameterizedTest(name = "returns empty filter for: {0}") - @MethodSource("provideNullOrBlankQueries") - @DisplayName("parse(null/empty/blank) returns empty filter with no criteria") - void parse_nullOrBlankQuery_returnsEmptyFilter(String query) { - var result = parser.parse(query); - assertThat(result.criteria()).isEmpty(); - } - - private static Stream provideNullOrBlankQueries() { - return Stream.of( - Arguments.of((String) null), - Arguments.of(""), - Arguments.of(" ") - ); - } + private final EntityFilterDslParser parser = new EntityFilterDslParser(); + + private void assertSingleCriterion(EntityFilter result, FilterKeyType expectedKeyType, + String expectedKeyName, FilterOperator expectedOperator, String expectedValue) { + assertThat(result.criteria()).hasSize(1); + assertCriterion(result.criteria().getFirst(), expectedKeyType, expectedKeyName, + expectedOperator, expectedValue); + } + + private void assertCriterion(FilterCriterion criterion, FilterKeyType expectedKeyType, + String expectedKeyName, FilterOperator expectedOperator, String expectedValue) { + assertThat(criterion.keyType()).isEqualTo(expectedKeyType); + assertThat(criterion.key()).isEqualTo(expectedKeyName); + assertThat(criterion.operator()).isEqualTo(expectedOperator); + assertThat(criterion.value()).isEqualTo(expectedValue); + } + + @Nested + @DisplayName("Attribute filters") + class AttributeFilterTests { + + @Test + @DisplayName("identifier equals") + void parse_attributeIdentifierEquals() { + var result = parser.parse("identifier=web-api-1"); + assertSingleCriterion(result, FilterKeyType.ATTRIBUTE, "identifier", FilterOperator.EQUALS, + "web-api-1"); + } + + @Test + @DisplayName("name contains") + void parse_attributeNameContains() { + var result = parser.parse("name:API"); + assertSingleCriterion(result, FilterKeyType.ATTRIBUTE, "name", FilterOperator.CONTAINS, + "API"); + } + } + + @Nested + @DisplayName("Property filters") + class PropertyFilterTests { + + @Test + @DisplayName("property equals") + void parse_propertyEquals() { + var result = parser.parse("property.language=JAVA"); + assertSingleCriterion(result, FilterKeyType.PROPERTY, "language", FilterOperator.EQUALS, + "JAVA"); + } + + @Test + @DisplayName("property contains") + void parse_propertyContains() { + var result = parser.parse("property.version:1.0"); + assertSingleCriterion(result, FilterKeyType.PROPERTY, "version", FilterOperator.CONTAINS, + "1.0"); + } + + @Test + @DisplayName("property less than") + void parse_propertyLessThan() { + var result = parser.parse("property.port<9000"); + assertSingleCriterion(result, FilterKeyType.PROPERTY, "port", FilterOperator.LESS_THAN, + "9000"); + } + + @Test + @DisplayName("property greater than") + void parse_propertyGreaterThan() { + var result = parser.parse("property.port>1000"); + assertSingleCriterion(result, FilterKeyType.PROPERTY, "port", FilterOperator.GREATER_THAN, + "1000"); + } + } + + @Nested + @DisplayName("Relation name filters") + class RelationNameFilterTests { + + @Test + @DisplayName("relation name equals") + void parse_relationNameEquals() { + var result = parser.parse("relation=api-link"); + assertSingleCriterion(result, FilterKeyType.RELATION_NAME, "", FilterOperator.EQUALS, + "api-link"); + } + + @Test + @DisplayName("relation name contains") + void parse_relationNameContains() { + var result = parser.parse("relation:rover"); + assertSingleCriterion(result, FilterKeyType.RELATION_NAME, "", FilterOperator.CONTAINS, + "rover"); + } + } + + @Nested + @DisplayName("Relation entity filters") + class RelationEntityFilterTests { + + @Test + @DisplayName("relation entity equals") + void parse_relationEntityEquals() { + var result = parser.parse("relation.database=my-db"); + assertSingleCriterion(result, FilterKeyType.RELATION_ENTITY, "database", + FilterOperator.EQUALS, "my-db"); + } + + @Test + @DisplayName("relation entity contains") + void parse_relationEntityContains() { + var result = parser.parse("relation.database:my"); + assertSingleCriterion(result, FilterKeyType.RELATION_ENTITY, "database", + FilterOperator.CONTAINS, "my"); + } + } + + @Nested + @DisplayName("Relation property filters") + class RelationPropertyFilterTests { + + @Test + @DisplayName("relation property equals") + void parse_relationPropertyEquals() { + var result = parser.parse("relation.api-link.identifier=microservice-1"); + assertSingleCriterion(result, FilterKeyType.RELATION_PROPERTY, "api-link.identifier", + FilterOperator.EQUALS, "microservice-1"); + } + + @Test + @DisplayName("relation property contains") + void parse_relationPropertyContains() { + var result = parser.parse("relation.api-link.name:microservice"); + assertSingleCriterion(result, FilterKeyType.RELATION_PROPERTY, "api-link.name", + FilterOperator.CONTAINS, "microservice"); + } + + @Test + @DisplayName("throws InvalidFilterDslException for unsupported property in relation (custom-prop is not identifier or name)") + void parse_relationPropertyUnsupported_throwsException() { + assertThatThrownBy(() -> parser.parse("relation.my-link.custom-prop=value")) + .isInstanceOf(InvalidFilterDslException.class).hasMessageContaining("custom-prop") + .hasMessageContaining("identifier").hasMessageContaining("name"); + } + } + + @Nested + @DisplayName("Relations as target filters") + class RelationsAsTargetFilterTests { + + @Test + @DisplayName("relations_as_target name equals") + void parse_relationsAsTargetNameEquals() { + var result = parser.parse("relations_as_target=api-link"); + assertSingleCriterion(result, FilterKeyType.RELATIONS_AS_TARGET_NAME, "", + FilterOperator.EQUALS, "api-link"); + } + + @Test + @DisplayName("relations_as_target name contains") + void parse_relationsAsTargetNameContains() { + var result = parser.parse("relations_as_target:rover"); + assertSingleCriterion(result, FilterKeyType.RELATIONS_AS_TARGET_NAME, "", + FilterOperator.CONTAINS, "rover"); + } + + @Test + @DisplayName("relations_as_target property identifier equals") + void parse_relationsAsTargetPropertyIdentifierEquals() { + var result = parser.parse("relations_as_target.api-link.identifier=web-api-1"); + assertSingleCriterion(result, FilterKeyType.RELATIONS_AS_TARGET_PROPERTY, + "api-link.identifier", FilterOperator.EQUALS, "web-api-1"); + } + + @Test + @DisplayName("relations_as_target property name contains") + void parse_relationsAsTargetPropertyNameContains() { + var result = parser.parse("relations_as_target.api-link.name:microservice"); + assertSingleCriterion(result, FilterKeyType.RELATIONS_AS_TARGET_PROPERTY, "api-link.name", + FilterOperator.CONTAINS, "microservice"); + } + + @Test + @DisplayName("throws exception for unsupported property in relations_as_target") + void parse_relationsAsTargetInvalidProperty_throwsException() { + assertThatThrownBy(() -> parser.parse("relations_as_target.api-link.language=JAVA")) + .isInstanceOf(InvalidFilterDslException.class) + .hasMessageContaining("only 'identifier' and 'name' are supported"); + } + + @Test + @DisplayName("throws exception for relations_as_target without property") + void parse_relationsAsTargetWithoutProperty_throwsException() { + assertThatThrownBy(() -> parser.parse("relations_as_target.api-link=web-api-1")) + .isInstanceOf(InvalidFilterDslException.class) + .hasMessageContaining("relations_as_target requires the form"); + } + } + + @Nested + @DisplayName("Combined AND criteria") + class CombinedCriteriaTests { + + @Test + @DisplayName("two criteria separated by semicolon") + void parse_twoCriteriaWithSemicolon() { + var result = parser.parse("name:API;property.language=JAVA"); + assertThat(result.criteria()).hasSize(2); + assertCriterion(result.criteria().get(0), FilterKeyType.ATTRIBUTE, "name", + FilterOperator.CONTAINS, "API"); + assertCriterion(result.criteria().get(1), FilterKeyType.PROPERTY, "language", + FilterOperator.EQUALS, "JAVA"); + } + + @Test + @DisplayName("four criteria of different key types") + void parse_fourCriteria() { + var result = parser.parse( + "name:API;property.language=JAVA;relation.database=my-db;relation.api-link.identifier=service-1"); + assertThat(result.criteria()).hasSize(4); + assertCriterion(result.criteria().get(0), FilterKeyType.ATTRIBUTE, "name", + FilterOperator.CONTAINS, "API"); + assertCriterion(result.criteria().get(1), FilterKeyType.PROPERTY, "language", + FilterOperator.EQUALS, "JAVA"); + assertCriterion(result.criteria().get(2), FilterKeyType.RELATION_ENTITY, "database", + FilterOperator.EQUALS, "my-db"); + assertCriterion(result.criteria().get(3), FilterKeyType.RELATION_PROPERTY, + "api-link.identifier", FilterOperator.EQUALS, "service-1"); + } + + @Test + @DisplayName("five criteria including relation property and reverse relation") + void parse_fiveCriteriaWithRelationProperty() { + var result = parser.parse( + "name:API;property.language=JAVA;relation.database=my-db;relation.api-link.identifier=service-1;relations_as_target.owned_by.name:platform"); + assertThat(result.criteria()).hasSize(5); + assertCriterion(result.criteria().get(0), FilterKeyType.ATTRIBUTE, "name", + FilterOperator.CONTAINS, "API"); + assertCriterion(result.criteria().get(1), FilterKeyType.PROPERTY, "language", + FilterOperator.EQUALS, "JAVA"); + assertCriterion(result.criteria().get(2), FilterKeyType.RELATION_ENTITY, "database", + FilterOperator.EQUALS, "my-db"); + assertCriterion(result.criteria().get(3), FilterKeyType.RELATION_PROPERTY, + "api-link.identifier", FilterOperator.EQUALS, "service-1"); + assertCriterion(result.criteria().get(4), FilterKeyType.RELATIONS_AS_TARGET_PROPERTY, + "owned_by.name", FilterOperator.CONTAINS, "platform"); + } + } + + @Nested + @DisplayName("Invalid query syntax") + class InvalidQueryTests { + + @ParameterizedTest(name = "missing operator in: ''{0}''") + @ValueSource(strings = {"noOperatorHere", "property.lang", "relation.db"}) + @DisplayName("throws InvalidFilterDslException when operator is missing") + void parse_missingOperator_throwsException(String query) { + assertThatThrownBy(() -> parser.parse(query)).isInstanceOf(InvalidFilterDslException.class) + .hasMessage(ValidationMessages.FILTER_INVALID_FORMAT); + } + + @Test + @DisplayName("throws InvalidFilterDslException for unknown attribute") + void parse_unknownAttribute_throwsException() { + assertThatThrownBy(() -> parser.parse("unknownField=value")) + .isInstanceOf(InvalidFilterDslException.class).hasMessageContaining("Unknown attribute"); + } + + @Test + @DisplayName("throws InvalidFilterDslException for blank value") + void parse_blankValue_throwsException() { + assertThatThrownBy(() -> parser.parse("name=")).isInstanceOf(InvalidFilterDslException.class) + .hasMessageContaining("value must not be blank"); + } + + @Test + @DisplayName("throws InvalidFilterDslException for blank key") + void parse_blankKey_throwsException() { + assertThatThrownBy(() -> parser.parse("=value")).isInstanceOf(InvalidFilterDslException.class) + .hasMessageContaining("key must not be blank"); + } + + @Test + @DisplayName("throws InvalidFilterDslException for blank property name after prefix") + void parse_blankPropertyName_throwsException() { + assertThatThrownBy(() -> parser.parse("property.=JAVA")) + .isInstanceOf(InvalidFilterDslException.class) + .hasMessageContaining("key name must not be blank"); + } + } + + @Nested + @DisplayName("Security constraints") + class SecurityConstraintTests { + + @Test + @DisplayName("throws InvalidFilterDslException when criteria count exceeds limit") + void parse_tooManyCriteria_throwsException() { + var query = "property.a=1;property.b=2;property.c=3;property.d=4;property.e=5;" + + "property.f=6;property.g=7;property.h=8;property.i=9;property.j=10;" + "property.k=11"; + assertThatThrownBy(() -> parser.parse(query)).isInstanceOf(InvalidFilterDslException.class) + .hasMessageContaining("maximum of %d".formatted(FilterConstraints.MAX_CRITERIA_COUNT)); + } + + @Test + @DisplayName("accepts exactly the maximum number of criteria") + void parse_exactlyMaxCriteria_succeeds() { + var query = "property.a=1;property.b=2;property.c=3;property.d=4;property.e=5;" + + "property.f=6;property.g=7;property.h=8;property.i=9;property.j=10"; + var result = parser.parse(query); + assertThat(result.criteria()).hasSize(FilterConstraints.MAX_CRITERIA_COUNT); + } + + @Test + @DisplayName("throws InvalidFilterDslException when value exceeds max length") + void parse_valueTooLong_throwsException() { + var longValue = "a".repeat(FilterConstraints.MAX_KEY_VALUE_LENGTH + 1); + assertThatThrownBy(() -> parser.parse("name=" + longValue)) + .isInstanceOf(InvalidFilterDslException.class).hasMessageContaining( + "must not exceed %d".formatted(FilterConstraints.MAX_KEY_VALUE_LENGTH)); + } + + @Test + @DisplayName("throws InvalidFilterDslException when key exceeds max length") + void parse_keyTooLong_throwsException() { + var longKey = "property." + "a".repeat(FilterConstraints.MAX_KEY_VALUE_LENGTH); + assertThatThrownBy(() -> parser.parse(longKey + "=value")) + .isInstanceOf(InvalidFilterDslException.class).hasMessageContaining( + "must not exceed %d".formatted(FilterConstraints.MAX_KEY_VALUE_LENGTH)); + } + + @ParameterizedTest(name = "valid key name: ''{0}''") + @ValueSource(strings = {"property.language=JAVA", "property.my-key=value", + "property.my_key=value", "property.key123=value", "property.lang@ge=JAVA", + "property.my key=JAVA", "property.lang/age=JAVA", "relation.database=my-db", + "relation.db$name=my-db", "relation.my-cache.identifier=redis-1"}) + @DisplayName("accepts valid key name characters") + void parse_validKeyNameChars_succeeds(String query) { + var result = parser.parse(query); + assertThat(result.criteria()).hasSize(1); + } + } + + @Nested + @DisplayName("Duplicate criterion detection") + class DuplicateCriterionTests { + + @ParameterizedTest(name = "duplicate criterion in: ''{0}''") + @ValueSource(strings = {"name=A;name=B", "property.language=JAVA;property.language=PYTHON", + "relation=api-link;relation=database"}) + @DisplayName("throws InvalidFilterDslException for duplicate criteria") + void parse_duplicateCriterion_throwsException(String query) { + assertThatThrownBy(() -> parser.parse(query)).isInstanceOf(InvalidFilterDslException.class) + .hasMessage(ValidationMessages.FILTER_DUPLICATE_CRITERION); + } + + @Test + @DisplayName("accepts distinct attribute criteria") + void parse_distinctAttributeCriteria_succeeds() { + var result = parser.parse("identifier=web-api-1;name=Web API 1"); + assertThat(result.criteria()).hasSize(2); + } + + @Test + @DisplayName("accepts distinct property criteria") + void parse_distinctPropertyCriteria_succeeds() { + var result = parser.parse("property.language=JAVA;property.environment=PROD"); + assertThat(result.criteria()).hasSize(2); + } + } + + @Nested + @DisplayName("Type mismatch validation") + class TypeMismatchTests { + + @ParameterizedTest(name = "comparison operator on: ''{0}''") + @ValueSource(strings = {"relationapi-link"}) + @DisplayName("throws InvalidFilterDslException for less/greater than on relation name") + void parse_comparisonOnRelationName_throwsException(String query) { + assertThatThrownBy(() -> parser.parse(query)).isInstanceOf(InvalidFilterDslException.class) + .hasMessageContaining("is not applicable for field"); + } + + @ParameterizedTest(name = "comparison operator on: ''{0}''") + @ValueSource(strings = {"relation.databasemy-db"}) + @DisplayName("throws InvalidFilterDslException for less/greater than on relation entity") + void parse_comparisonOnRelationEntity_throwsException(String query) { + assertThatThrownBy(() -> parser.parse(query)).isInstanceOf(InvalidFilterDslException.class) + .hasMessageContaining("is not applicable for field"); + } + + @ParameterizedTest(name = "comparison operator on: ''{0}''") + @ValueSource(strings = {"relation.database.templatepostgresql"}) + @DisplayName("throws InvalidFilterDslException for unsupported property on relation (template is not a valid relation property)") + void parse_comparisonOnRelationTemplate_throwsException(String query) { + assertThatThrownBy(() -> parser.parse(query)).isInstanceOf(InvalidFilterDslException.class) + .hasMessageContaining("template"); + } + + @Test + @DisplayName("throws InvalidFilterDslException for unsupported property on relation with equals operator") + void parse_equalsOnRelationTemplate_throwsException() { + assertThatThrownBy(() -> parser.parse("relation.database.template=postgresql")) + .isInstanceOf(InvalidFilterDslException.class).hasMessageContaining("template") + .hasMessageContaining("identifier").hasMessageContaining("name"); + } + + @ParameterizedTest(name = "comparison operator on: ''{0}''") + @ValueSource(strings = {"relation.api-link.identifiermicroservice-1"}) + @DisplayName("throws InvalidFilterDslException for less/greater than on relation property") + void parse_comparisonOnRelationProperty_throwsException(String query) { + assertThatThrownBy(() -> parser.parse(query)).isInstanceOf(InvalidFilterDslException.class) + .hasMessageContaining("is not applicable for field"); + } + + @ParameterizedTest(name = "comparison operator on: ''{0}''") + @ValueSource(strings = {"relations_as_target.api-link.namemicroservice"}) + @DisplayName("throws InvalidFilterDslException for less/greater than on relations_as_target property") + void parse_comparisonOnRelationsAsTargetProperty_throwsException(String query) { + assertThatThrownBy(() -> parser.parse(query)).isInstanceOf(InvalidFilterDslException.class) + .hasMessageContaining("is not applicable for field"); + } + + @ParameterizedTest(name = "comparison operator on: ''{0}''") + @ValueSource(strings = {"nameA", "identifier parser.parse(query)).isInstanceOf(InvalidFilterDslException.class) + .hasMessageContaining("is not applicable for field"); + } + + @ParameterizedTest(name = "comparison operator on: ''{0}''") + @ValueSource(strings = {"property.port<9000", "property.port>1000"}) + @DisplayName("accepts less/greater than on NUMBER properties (type check is deferred to EntityService)") + void parse_comparisonOnProperty_succeeds(String query) { + var result = parser.parse(query); + assertThat(result.criteria()).hasSize(1); + } + } + + @Nested + @DisplayName("Edge cases") + class EdgeCaseTests { + + @Test + @DisplayName("consecutive semicolons produce empty filter") + void parse_consecutiveSemicolons_ignoresEmptyTokens() { + var result = parser.parse("name=API;;property.lang=JAVA"); + assertThat(result.criteria()).hasSize(2); + } + + @Test + @DisplayName("trailing semicolon is ignored") + void parse_trailingSemicolon_ignored() { + var result = parser.parse("name=API;"); + assertThat(result.criteria()).hasSize(1); + } + + @Test + @DisplayName("leading semicolon is ignored") + void parse_leadingSemicolon_ignored() { + var result = parser.parse(";name=API"); + assertThat(result.criteria()).hasSize(1); + } + + @Test + @DisplayName("values containing SQL LIKE wildcards are accepted") + void parse_valuesWithLikeWildcards_accepted() { + var result = parser.parse("name:100%_success"); + assertSingleCriterion(result, FilterKeyType.ATTRIBUTE, "name", FilterOperator.CONTAINS, + "100%_success"); + } + } + + @Nested + @DisplayName("Null or blank query") + class NullOrBlankQueryTests { + + @ParameterizedTest(name = "returns empty filter for: {0}") + @MethodSource("provideNullOrBlankQueries") + @DisplayName("parse(null/empty/blank) returns empty filter with no criteria") + void parse_nullOrBlankQuery_returnsEmptyFilter(String query) { + var result = parser.parse(query); + assertThat(result.criteria()).isEmpty(); + } + + private static Stream provideNullOrBlankQueries() { + return Stream.of(Arguments.of((String) null), Arguments.of(""), Arguments.of(" ")); } + } } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/search/SearchFilterParserTest.java b/src/test/java/com/decathlon/idp_core/domain/service/search/SearchFilterParserTest.java index 03c79b43..63cb6016 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/search/SearchFilterParserTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/search/SearchFilterParserTest.java @@ -24,195 +24,184 @@ @DisplayName("SearchFilterParser") class SearchFilterParserTest { - private final SearchFilterParser parser = new SearchFilterParser(); - - @Nested - @DisplayName("parse() — null and empty inputs") - class NullAndEmptyTests { - - @Test - @DisplayName("null input returns empty AND group") - void null_returnsEmptyAndGroup() { - var result = parser.parse(null); - assertThat(result).isInstanceOf(SearchFilterNode.Group.class); - var group = (SearchFilterNode.Group) result; - assertThat(group.connector()).isEqualTo(LogicalConnector.AND); - assertThat(group.nodes()).isEmpty(); - } - } - - @Nested - @DisplayName("parse() — criterion leaf node") - class CriterionTests { - - @Test - @DisplayName("valid criterion is correctly parsed") - void validCriterion_parsed() { - var raw = new RawSearchFilterNode.Criterion("template", "EQ", "microservice"); - var result = parser.parse(raw); - assertThat(result).isInstanceOf(SearchFilterNode.Criterion.class); - var criterion = (SearchFilterNode.Criterion) result; - assertThat(criterion.field()).isEqualTo("template"); - assertThat(criterion.operation()).isEqualTo(SearchOperator.EQ); - assertThat(criterion.value()).isEqualTo("microservice"); - } - - @Test - @DisplayName("operation is case-insensitive") - void operation_caseInsensitive() { - var raw = new RawSearchFilterNode.Criterion("identifier", "contains", "api"); - var result = (SearchFilterNode.Criterion) parser.parse(raw); - assertThat(result.operation()).isEqualTo(SearchOperator.CONTAINS); - } - - @Test - @DisplayName("throws when field is null") - void nullField_throws() { - var raw = new RawSearchFilterNode.Criterion(null, "EQ", "value"); - assertThatThrownBy(() -> parser.parse(raw)) - .isInstanceOf(InvalidSearchQueryException.class) - .hasMessageContaining("field"); - } - - @Test - @DisplayName("throws when field is blank") - void blankField_throws() { - var raw = new RawSearchFilterNode.Criterion(" ", "EQ", "value"); - assertThatThrownBy(() -> parser.parse(raw)) - .isInstanceOf(InvalidSearchQueryException.class) - .hasMessageContaining("field"); - } - - @Test - @DisplayName("throws when operation is null") - void nullOperation_throws() { - var raw = new RawSearchFilterNode.Criterion("identifier", null, "value"); - assertThatThrownBy(() -> parser.parse(raw)) - .isInstanceOf(InvalidSearchQueryException.class) - .hasMessageContaining("operation"); - } - - @Test - @DisplayName("throws when value is null") - void nullValue_throws() { - var raw = new RawSearchFilterNode.Criterion("identifier", "EQ", null); - assertThatThrownBy(() -> parser.parse(raw)) - .isInstanceOf(InvalidSearchQueryException.class) - .hasMessageContaining("value"); - } - - @Test - @DisplayName("throws for invalid operation string") - void invalidOperation_throws() { - var raw = new RawSearchFilterNode.Criterion("identifier", "LIKE", "api"); - assertThatThrownBy(() -> parser.parse(raw)) - .isInstanceOf(InvalidSearchQueryException.class) - .hasMessageContaining("LIKE"); - } - - @Test - @DisplayName("unknown field is accepted by the parser — semantic validation is deferred to SearchFilterValidationService") - void unknownField_acceptedByParser() { - var raw = new RawSearchFilterNode.Criterion("badField", "EQ", "value"); - assertThat(parser.parse(raw)).isInstanceOf(SearchFilterNode.Criterion.class); - } - } - - @Nested - @DisplayName("parse() — group nodes") - class GroupTests { - - @Test - @DisplayName("valid AND group is correctly parsed") - void validAndGroup_parsed() { - var child1 = new RawSearchFilterNode.Criterion("template", "EQ", "microservice"); - var child2 = new RawSearchFilterNode.Criterion("identifier", "CONTAINS", "api"); - var raw = new RawSearchFilterNode.Group("AND", List.of(child1, child2)); - - var result = parser.parse(raw); - assertThat(result).isInstanceOf(SearchFilterNode.Group.class); - var group = (SearchFilterNode.Group) result; - assertThat(group.connector()).isEqualTo(LogicalConnector.AND); - assertThat(group.nodes()).hasSize(2); - } - - @Test - @DisplayName("connector is case-insensitive") - void connector_caseInsensitive() { - var child = new RawSearchFilterNode.Criterion("template", "EQ", "microservice"); - var raw = new RawSearchFilterNode.Group("or", List.of(child)); - var group = (SearchFilterNode.Group) parser.parse(raw); - assertThat(group.connector()).isEqualTo(LogicalConnector.OR); - } - - @Test - @DisplayName("'IN' connector is rejected") - void inConnector_rejected() { - var child = new RawSearchFilterNode.Criterion("template", "EQ", "microservice"); - var raw = new RawSearchFilterNode.Group("IN", List.of(child)); - assertThatThrownBy(() -> parser.parse(raw)) - .isInstanceOf(InvalidSearchQueryException.class) - .hasMessageContaining("IN"); - } - - @Test - @DisplayName("throws for missing connector in group") - void missingConnector_throws() { - var child = new RawSearchFilterNode.Criterion("template", "EQ", "microservice"); - var raw = new RawSearchFilterNode.Group(null, List.of(child)); - assertThatThrownBy(() -> parser.parse(raw)) - .isInstanceOf(InvalidSearchQueryException.class) - .hasMessageContaining("connector"); - } - - @Test - @DisplayName("throws for empty criteria list in group") - void emptyCriteria_throws() { - var raw = new RawSearchFilterNode.Group("AND", List.of()); - assertThatThrownBy(() -> parser.parse(raw)) - .isInstanceOf(InvalidSearchQueryException.class) - .hasMessageContaining("criteria"); - } - - @Test - @DisplayName("throws for invalid connector string") - void invalidConnector_throws() { - var child = new RawSearchFilterNode.Criterion("template", "EQ", "microservice"); - var raw = new RawSearchFilterNode.Group("NAND", List.of(child)); - assertThatThrownBy(() -> parser.parse(raw)) - .isInstanceOf(InvalidSearchQueryException.class) - .hasMessageContaining("NAND"); - } - } - - @Nested - @DisplayName("parse() — safety limits") - class SafetyLimitsTests { - - @Test - @DisplayName("throws when total criteria exceed maximum") - void tooManyCriteria_throws() { - var innerCriteria = new ArrayList(); - for (int i = 0; i <= SearchConstraints.MAX_TOTAL_CRITERIA; i++) { - innerCriteria.add(new RawSearchFilterNode.Criterion("template", "EQ", "v" + i)); - } - var raw = new RawSearchFilterNode.Group("OR", innerCriteria); - assertThatThrownBy(() -> parser.parse(raw)) - .isInstanceOf(InvalidSearchQueryException.class) - .hasMessageContaining(String.valueOf(SearchConstraints.MAX_TOTAL_CRITERIA)); - } - - @Test - @DisplayName("throws when nesting exceeds maximum depth") - void nestingTooDeep_throws() { - RawSearchFilterNode node = new RawSearchFilterNode.Criterion("template", "EQ", "v"); - for (int i = 0; i <= SearchConstraints.MAX_NESTING_DEPTH; i++) { - node = new RawSearchFilterNode.Group("AND", List.of(node)); - } - var root = node; - assertThatThrownBy(() -> parser.parse(root)) - .isInstanceOf(InvalidSearchQueryException.class) - .hasMessageContaining(String.valueOf(SearchConstraints.MAX_NESTING_DEPTH)); - } + private final SearchFilterParser parser = new SearchFilterParser(); + + @Nested + @DisplayName("parse() — null and empty inputs") + class NullAndEmptyTests { + + @Test + @DisplayName("null input returns empty AND group") + void null_returnsEmptyAndGroup() { + var result = parser.parse(null); + assertThat(result).isInstanceOf(SearchFilterNode.Group.class); + var group = (SearchFilterNode.Group) result; + assertThat(group.connector()).isEqualTo(LogicalConnector.AND); + assertThat(group.nodes()).isEmpty(); } + } + + @Nested + @DisplayName("parse() — criterion leaf node") + class CriterionTests { + + @Test + @DisplayName("valid criterion is correctly parsed") + void validCriterion_parsed() { + var raw = new RawSearchFilterNode.Criterion("template", "EQ", "microservice"); + var result = parser.parse(raw); + assertThat(result).isInstanceOf(SearchFilterNode.Criterion.class); + var criterion = (SearchFilterNode.Criterion) result; + assertThat(criterion.field()).isEqualTo("template"); + assertThat(criterion.operation()).isEqualTo(SearchOperator.EQ); + assertThat(criterion.value()).isEqualTo("microservice"); + } + + @Test + @DisplayName("operation is case-insensitive") + void operation_caseInsensitive() { + var raw = new RawSearchFilterNode.Criterion("identifier", "contains", "api"); + var result = (SearchFilterNode.Criterion) parser.parse(raw); + assertThat(result.operation()).isEqualTo(SearchOperator.CONTAINS); + } + + @Test + @DisplayName("throws when field is null") + void nullField_throws() { + var raw = new RawSearchFilterNode.Criterion(null, "EQ", "value"); + assertThatThrownBy(() -> parser.parse(raw)).isInstanceOf(InvalidSearchQueryException.class) + .hasMessageContaining("field"); + } + + @Test + @DisplayName("throws when field is blank") + void blankField_throws() { + var raw = new RawSearchFilterNode.Criterion(" ", "EQ", "value"); + assertThatThrownBy(() -> parser.parse(raw)).isInstanceOf(InvalidSearchQueryException.class) + .hasMessageContaining("field"); + } + + @Test + @DisplayName("throws when operation is null") + void nullOperation_throws() { + var raw = new RawSearchFilterNode.Criterion("identifier", null, "value"); + assertThatThrownBy(() -> parser.parse(raw)).isInstanceOf(InvalidSearchQueryException.class) + .hasMessageContaining("operation"); + } + + @Test + @DisplayName("throws when value is null") + void nullValue_throws() { + var raw = new RawSearchFilterNode.Criterion("identifier", "EQ", null); + assertThatThrownBy(() -> parser.parse(raw)).isInstanceOf(InvalidSearchQueryException.class) + .hasMessageContaining("value"); + } + + @Test + @DisplayName("throws for invalid operation string") + void invalidOperation_throws() { + var raw = new RawSearchFilterNode.Criterion("identifier", "LIKE", "api"); + assertThatThrownBy(() -> parser.parse(raw)).isInstanceOf(InvalidSearchQueryException.class) + .hasMessageContaining("LIKE"); + } + + @Test + @DisplayName("unknown field is accepted by the parser — semantic validation is deferred to SearchFilterValidationService") + void unknownField_acceptedByParser() { + var raw = new RawSearchFilterNode.Criterion("badField", "EQ", "value"); + assertThat(parser.parse(raw)).isInstanceOf(SearchFilterNode.Criterion.class); + } + } + + @Nested + @DisplayName("parse() — group nodes") + class GroupTests { + + @Test + @DisplayName("valid AND group is correctly parsed") + void validAndGroup_parsed() { + var child1 = new RawSearchFilterNode.Criterion("template", "EQ", "microservice"); + var child2 = new RawSearchFilterNode.Criterion("identifier", "CONTAINS", "api"); + var raw = new RawSearchFilterNode.Group("AND", List.of(child1, child2)); + + var result = parser.parse(raw); + assertThat(result).isInstanceOf(SearchFilterNode.Group.class); + var group = (SearchFilterNode.Group) result; + assertThat(group.connector()).isEqualTo(LogicalConnector.AND); + assertThat(group.nodes()).hasSize(2); + } + + @Test + @DisplayName("connector is case-insensitive") + void connector_caseInsensitive() { + var child = new RawSearchFilterNode.Criterion("template", "EQ", "microservice"); + var raw = new RawSearchFilterNode.Group("or", List.of(child)); + var group = (SearchFilterNode.Group) parser.parse(raw); + assertThat(group.connector()).isEqualTo(LogicalConnector.OR); + } + + @Test + @DisplayName("'IN' connector is rejected") + void inConnector_rejected() { + var child = new RawSearchFilterNode.Criterion("template", "EQ", "microservice"); + var raw = new RawSearchFilterNode.Group("IN", List.of(child)); + assertThatThrownBy(() -> parser.parse(raw)).isInstanceOf(InvalidSearchQueryException.class) + .hasMessageContaining("IN"); + } + + @Test + @DisplayName("throws for missing connector in group") + void missingConnector_throws() { + var child = new RawSearchFilterNode.Criterion("template", "EQ", "microservice"); + var raw = new RawSearchFilterNode.Group(null, List.of(child)); + assertThatThrownBy(() -> parser.parse(raw)).isInstanceOf(InvalidSearchQueryException.class) + .hasMessageContaining("connector"); + } + + @Test + @DisplayName("throws for empty criteria list in group") + void emptyCriteria_throws() { + var raw = new RawSearchFilterNode.Group("AND", List.of()); + assertThatThrownBy(() -> parser.parse(raw)).isInstanceOf(InvalidSearchQueryException.class) + .hasMessageContaining("criteria"); + } + + @Test + @DisplayName("throws for invalid connector string") + void invalidConnector_throws() { + var child = new RawSearchFilterNode.Criterion("template", "EQ", "microservice"); + var raw = new RawSearchFilterNode.Group("NAND", List.of(child)); + assertThatThrownBy(() -> parser.parse(raw)).isInstanceOf(InvalidSearchQueryException.class) + .hasMessageContaining("NAND"); + } + } + + @Nested + @DisplayName("parse() — safety limits") + class SafetyLimitsTests { + + @Test + @DisplayName("throws when total criteria exceed maximum") + void tooManyCriteria_throws() { + var innerCriteria = new ArrayList(); + for (int i = 0; i <= SearchConstraints.MAX_TOTAL_CRITERIA; i++) { + innerCriteria.add(new RawSearchFilterNode.Criterion("template", "EQ", "v" + i)); + } + var raw = new RawSearchFilterNode.Group("OR", innerCriteria); + assertThatThrownBy(() -> parser.parse(raw)).isInstanceOf(InvalidSearchQueryException.class) + .hasMessageContaining(String.valueOf(SearchConstraints.MAX_TOTAL_CRITERIA)); + } + + @Test + @DisplayName("throws when nesting exceeds maximum depth") + void nestingTooDeep_throws() { + RawSearchFilterNode node = new RawSearchFilterNode.Criterion("template", "EQ", "v"); + for (int i = 0; i <= SearchConstraints.MAX_NESTING_DEPTH; i++) { + node = new RawSearchFilterNode.Group("AND", List.of(node)); + } + var root = node; + assertThatThrownBy(() -> parser.parse(root)).isInstanceOf(InvalidSearchQueryException.class) + .hasMessageContaining(String.valueOf(SearchConstraints.MAX_NESTING_DEPTH)); + } + } } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/search/SearchFilterValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/search/SearchFilterValidationServiceTest.java index 11f60172..ab0f8184 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/search/SearchFilterValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/search/SearchFilterValidationServiceTest.java @@ -27,383 +27,374 @@ @DisplayName("SearchFilterValidationService") class SearchFilterValidationServiceTest { - private final EntityTemplateRepositoryPort repository = mock(EntityTemplateRepositoryPort.class); - private final SearchFilterValidationService service = new SearchFilterValidationService(repository); - - private static final SearchFilterNode EMPTY_FILTER = - new SearchFilterNode.Group(LogicalConnector.AND, List.of()); - - private PropertyDefinition prop(String name, PropertyType type) { - return new PropertyDefinition(UUID.randomUUID(), name, "desc", type, false, null); - } - - private EntityTemplate template(String identifier, PropertyDefinition... props) { - return new EntityTemplate(UUID.randomUUID(), identifier, identifier, null, List.of(props), List.of()); - } - - // ========================================================================= - // Query string length validation - // ========================================================================= - - @Nested - @DisplayName("Query string length validation") - class QueryLengthTests { - - @Test - @DisplayName("null query does not throw") - void nullQuery_doesNotThrow() { - assertThatCode(() -> service.validate(EMPTY_FILTER, null)).doesNotThrowAnyException(); - } - - @Test - @DisplayName("short query does not throw") - void shortQuery_doesNotThrow() { - assertThatCode(() -> service.validate(EMPTY_FILTER, "checkout")).doesNotThrowAnyException(); - } - - @Test - @DisplayName("query at exact limit does not throw") - void queryAtLimit_doesNotThrow() { - String atLimit = "x".repeat(SearchConstraints.MAX_QUERY_LENGTH); - assertThatCode(() -> service.validate(EMPTY_FILTER, atLimit)).doesNotThrowAnyException(); - } - - @Test - @DisplayName("query exceeding limit throws") - void queryOverLimit_throws() { - String tooLong = "x".repeat(SearchConstraints.MAX_QUERY_LENGTH + 1); - assertThatThrownBy(() -> service.validate(EMPTY_FILTER, tooLong)) - .isInstanceOf(InvalidSearchQueryException.class) - .hasMessageContaining(String.valueOf(SearchConstraints.MAX_QUERY_LENGTH)); - } - } - - // ========================================================================= - // Field name grammar validation - // ========================================================================= - - @Nested - @DisplayName("Field name validation") - class FieldNameTests { - - @Test - @DisplayName("'template' field is accepted") - void template_accepted() { - assertThatCode(() -> service.validate(criterion("template", SearchOperator.EQ, "val"), null)) - .doesNotThrowAnyException(); - } - - @Test - @DisplayName("'identifier' field is accepted") - void identifier_accepted() { - assertThatCode(() -> service.validate(criterion("identifier", SearchOperator.EQ, "val"), null)) - .doesNotThrowAnyException(); - } - - @Test - @DisplayName("'name' field is accepted") - void name_accepted() { - assertThatCode(() -> service.validate(criterion("name", SearchOperator.EQ, "val"), null)) - .doesNotThrowAnyException(); - } - - @Test - @DisplayName("'relation' bare field is accepted") - void relation_accepted() { - assertThatCode(() -> service.validate(criterion("relation", SearchOperator.EQ, "val"), null)) - .doesNotThrowAnyException(); - } - - @Test - @DisplayName("'relations_as_target' bare field is accepted") - void relationsAsTarget_accepted() { - assertThatCode(() -> service.validate(criterion("relations_as_target", SearchOperator.EQ, "val"), null)) - .doesNotThrowAnyException(); - } - - @Test - @DisplayName("'property.{name}' field is accepted") - void propertyField_accepted() { - assertThatCode(() -> service.validate(criterion("property.language", SearchOperator.EQ, "val"), null)) - .doesNotThrowAnyException(); - } - - @Test - @DisplayName("'relation.{name}' field is accepted") - void relationField_accepted() { - assertThatCode(() -> service.validate(criterion("relation.api-link", SearchOperator.EQ, "val"), null)) - .doesNotThrowAnyException(); - } - - @Test - @DisplayName("'relations_as_target.{name}.identifier' field is accepted") - void relationsAsTargetIdentifierField_accepted() { - assertThatCode(() -> service.validate( - criterion("relations_as_target.api-link.identifier", SearchOperator.EQ, "val"), null)) - .doesNotThrowAnyException(); - } - - @Test - @DisplayName("'relations_as_target.{name}.name' field is accepted") - void relationsAsTargetNameField_accepted() { - assertThatCode(() -> service.validate( - criterion("relations_as_target.api-link.name", SearchOperator.EQ, "val"), null)) - .doesNotThrowAnyException(); - } - - @Test - @DisplayName("unknown field throws") - void unknownField_throws() { - assertThatThrownBy(() -> service.validate(criterion("badField", SearchOperator.EQ, "val"), null)) - .isInstanceOf(InvalidSearchQueryException.class) - .hasMessageContaining("badField"); - } - - @Test - @DisplayName("'relations_as_target' without subfield throws") - void relationsAsTarget_missingSubfield_throws() { - assertThatThrownBy(() -> service.validate( - criterion("relations_as_target.api-link", SearchOperator.EQ, "val"), null)) - .isInstanceOf(InvalidSearchQueryException.class); - } - - @Test - @DisplayName("field validation applies to criteria nested inside groups") - void invalidField_nestedInGroup_throws() { - var filter = new SearchFilterNode.Group(LogicalConnector.AND, List.of( - criterion("template", SearchOperator.EQ, "svc"), - criterion("unknownField", SearchOperator.EQ, "val") - )); - assertThatThrownBy(() -> service.validate(filter, null)) - .isInstanceOf(InvalidSearchQueryException.class) - .hasMessageContaining("unknownField"); - } - } - - // ========================================================================= - // Numeric operator constraint validation - // ========================================================================= - - @Nested - @DisplayName("Numeric operator constraints") - class NumericOperatorTests { - - @Test - @DisplayName("GT on property.{name} with a numeric value is accepted") - void gt_onProperty_numericValue_accepted() { - assertThatCode(() -> service.validate(criterion("property.port", SearchOperator.GT, "8080"), null)) - .doesNotThrowAnyException(); - } - - @Test - @DisplayName("GTE on property.{name} with a decimal value is accepted") - void gte_onProperty_decimalValue_accepted() { - assertThatCode(() -> service.validate(criterion("property.score", SearchOperator.GTE, "1.5"), null)) - .doesNotThrowAnyException(); - } - - @Test - @DisplayName("GT on 'template' field throws — numeric ops only on property.{name}") - void gt_onTemplateField_throws() { - assertThatThrownBy(() -> service.validate(criterion("template", SearchOperator.GT, "5"), null)) - .isInstanceOf(InvalidSearchQueryException.class) - .hasMessageContaining("GT"); - } - - @Test - @DisplayName("LT on 'identifier' field throws — numeric ops only on property.{name}") - void lt_onIdentifierField_throws() { - assertThatThrownBy(() -> service.validate(criterion("identifier", SearchOperator.LT, "5"), null)) - .isInstanceOf(InvalidSearchQueryException.class) - .hasMessageContaining("LT"); - } - - @Test - @DisplayName("GT on property.{name} with a non-numeric value throws") - void gt_nonNumericValue_throws() { - assertThatThrownBy(() -> service.validate(criterion("property.port", SearchOperator.GT, "abc"), null)) - .isInstanceOf(InvalidSearchQueryException.class) - .hasMessageContaining("abc") - .hasMessageContaining("GT"); - } - - @Test - @DisplayName("LTE on property.{name} with alphanumeric non-numeric value throws") - void lte_nonNumericValue_throws() { - assertThatThrownBy(() -> service.validate(criterion("property.size", SearchOperator.LTE, "10MB"), null)) - .isInstanceOf(InvalidSearchQueryException.class) - .hasMessageContaining("10MB"); - } - } - - // ========================================================================= - // Template-scoped property-type validation - // ========================================================================= - - @Nested - @DisplayName("No numeric operators — no validation triggered") - class NoNumericOperatorsTests { - - @Test - @DisplayName("filter with only EQ operators does not throw") - void eq_only_doesNotThrow() { - var filter = new SearchFilterNode.Group(LogicalConnector.AND, List.of( - new SearchFilterNode.Criterion("template", SearchOperator.EQ, "microservice"), - new SearchFilterNode.Criterion("property.lifecycle", SearchOperator.EQ, "production") - )); - assertThatCode(() -> service.validate(filter, null)).doesNotThrowAnyException(); - } - - @Test - @DisplayName("empty filter does not throw") - void emptyFilter_doesNotThrow() { - assertThatCode(() -> service.validate(EMPTY_FILTER, null)).doesNotThrowAnyException(); - } - } - - @Nested - @DisplayName("Numeric operators without a template constraint") - class NoTemplateConstraintTests { - - @Test - @DisplayName("GT on property without template constraint does not throw (can't validate)") - void gt_noTemplateConstraint_doesNotThrow() { - assertThatCode(() -> service.validate( - new SearchFilterNode.Criterion("property.port", SearchOperator.GT, "8080"), null)) - .doesNotThrowAnyException(); - } - - @Test - @DisplayName("GT on property with only non-EQ template constraint does not throw") - void gt_templateConstraintNotEq_doesNotThrow() { - var filter = new SearchFilterNode.Group(LogicalConnector.AND, List.of( - new SearchFilterNode.Criterion("template", SearchOperator.CONTAINS, "service"), - new SearchFilterNode.Criterion("property.port", SearchOperator.GT, "8080") - )); - assertThatCode(() -> service.validate(filter, null)).doesNotThrowAnyException(); - } - } - - @Nested - @DisplayName("Numeric operators with a template constraint (type check enabled)") - class WithTemplateConstraintTests { - - @Test - @DisplayName("GT on a NUMBER property does not throw") - void gt_numberProperty_doesNotThrow() { - when(repository.findByIdentifier("web-service")) - .thenReturn(Optional.of(template("web-service", prop("port", PropertyType.NUMBER)))); - - var filter = new SearchFilterNode.Group(LogicalConnector.AND, List.of( - new SearchFilterNode.Criterion("template", SearchOperator.EQ, "web-service"), - new SearchFilterNode.Criterion("property.port", SearchOperator.GT, "8080") - )); - assertThatCode(() -> service.validate(filter, null)).doesNotThrowAnyException(); - } - - @Test - @DisplayName("GTE, LT, LTE on a NUMBER property do not throw") - void allNumericOperators_numberProperty_doesNotThrow() { - when(repository.findByIdentifier("ws")) - .thenReturn(Optional.of(template("ws", prop("score", PropertyType.NUMBER)))); - - for (SearchOperator op : List.of(SearchOperator.GTE, SearchOperator.LT, SearchOperator.LTE)) { - var filter = new SearchFilterNode.Group(LogicalConnector.AND, List.of( - new SearchFilterNode.Criterion("template", SearchOperator.EQ, "ws"), - new SearchFilterNode.Criterion("property.score", op, "5") - )); - assertThatCode(() -> service.validate(filter, null)) - .as("operator %s should not throw for NUMBER property", op) - .doesNotThrowAnyException(); - } - } - - @Test - @DisplayName("GT on a STRING property throws") - void gt_stringProperty_throws() { - when(repository.findByIdentifier("web-service")) - .thenReturn(Optional.of(template("web-service", - prop("programmingLanguage", PropertyType.STRING), - prop("port", PropertyType.NUMBER)))); - - var filter = new SearchFilterNode.Group(LogicalConnector.AND, List.of( - new SearchFilterNode.Criterion("template", SearchOperator.EQ, "web-service"), - new SearchFilterNode.Criterion("property.programmingLanguage", SearchOperator.GT, "5") - )); - assertThatThrownBy(() -> service.validate(filter, null)) - .isInstanceOf(InvalidSearchQueryException.class) - .hasMessageContaining("programmingLanguage") - .hasMessageContaining("web-service") - .hasMessageContaining("STRING"); - } - - @Test - @DisplayName("GT on a BOOLEAN property throws") - void gt_booleanProperty_throws() { - when(repository.findByIdentifier("svc")) - .thenReturn(Optional.of(template("svc", prop("isActive", PropertyType.BOOLEAN)))); - - var filter = new SearchFilterNode.Group(LogicalConnector.AND, List.of( - new SearchFilterNode.Criterion("template", SearchOperator.EQ, "svc"), - new SearchFilterNode.Criterion("property.isActive", SearchOperator.LTE, "1") - )); - assertThatThrownBy(() -> service.validate(filter, null)) - .isInstanceOf(InvalidSearchQueryException.class) - .hasMessageContaining("isActive") - .hasMessageContaining("BOOLEAN"); - } - - @Test - @DisplayName("unknown template does not throw — template may not exist yet") - void unknownTemplate_doesNotThrow() { - when(repository.findByIdentifier("unknown")).thenReturn(Optional.empty()); - - var filter = new SearchFilterNode.Group(LogicalConnector.AND, List.of( - new SearchFilterNode.Criterion("template", SearchOperator.EQ, "unknown"), - new SearchFilterNode.Criterion("property.port", SearchOperator.GT, "80") - )); - assertThatCode(() -> service.validate(filter, null)).doesNotThrowAnyException(); - } - - @Test - @DisplayName("property not defined in template does not throw — may be optional") - void propertyNotInTemplate_doesNotThrow() { - when(repository.findByIdentifier("ws")) - .thenReturn(Optional.of(template("ws", prop("port", PropertyType.NUMBER)))); - - var filter = new SearchFilterNode.Group(LogicalConnector.AND, List.of( - new SearchFilterNode.Criterion("template", SearchOperator.EQ, "ws"), - new SearchFilterNode.Criterion("property.undefinedProp", SearchOperator.GT, "5") - )); - assertThatCode(() -> service.validate(filter, null)).doesNotThrowAnyException(); - } - } - - @Nested - @DisplayName("Nested filter trees") - class NestedTreeTests { - - @Test - @DisplayName("GT on STRING property nested inside OR group throws") - void gt_stringProperty_nestedInOr_throws() { - when(repository.findByIdentifier("svc")) - .thenReturn(Optional.of(template("svc", prop("name", PropertyType.STRING)))); - - var inner = new SearchFilterNode.Group(LogicalConnector.OR, List.of( - new SearchFilterNode.Criterion("property.name", SearchOperator.GT, "5") - )); - var filter = new SearchFilterNode.Group(LogicalConnector.AND, List.of( - new SearchFilterNode.Criterion("template", SearchOperator.EQ, "svc"), - inner - )); - assertThatThrownBy(() -> service.validate(filter, null)) - .isInstanceOf(InvalidSearchQueryException.class) - .hasMessageContaining("name") - .hasMessageContaining("STRING"); - } - } - - private static SearchFilterNode.Criterion criterion(String field, SearchOperator op, String value) { - return new SearchFilterNode.Criterion(field, op, value); + private final EntityTemplateRepositoryPort repository = mock(EntityTemplateRepositoryPort.class); + private final SearchFilterValidationService service = new SearchFilterValidationService( + repository); + + private static final SearchFilterNode EMPTY_FILTER = new SearchFilterNode.Group( + LogicalConnector.AND, List.of()); + + private PropertyDefinition prop(String name, PropertyType type) { + return new PropertyDefinition(UUID.randomUUID(), name, "desc", type, false, null); + } + + private EntityTemplate template(String identifier, PropertyDefinition... props) { + return new EntityTemplate(UUID.randomUUID(), identifier, identifier, null, List.of(props), + List.of()); + } + + // ========================================================================= + // Query string length validation + // ========================================================================= + + @Nested + @DisplayName("Query string length validation") + class QueryLengthTests { + + @Test + @DisplayName("null query does not throw") + void nullQuery_doesNotThrow() { + assertThatCode(() -> service.validate(EMPTY_FILTER, null)).doesNotThrowAnyException(); } + + @Test + @DisplayName("short query does not throw") + void shortQuery_doesNotThrow() { + assertThatCode(() -> service.validate(EMPTY_FILTER, "checkout")).doesNotThrowAnyException(); + } + + @Test + @DisplayName("query at exact limit does not throw") + void queryAtLimit_doesNotThrow() { + String atLimit = "x".repeat(SearchConstraints.MAX_QUERY_LENGTH); + assertThatCode(() -> service.validate(EMPTY_FILTER, atLimit)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("query exceeding limit throws") + void queryOverLimit_throws() { + String tooLong = "x".repeat(SearchConstraints.MAX_QUERY_LENGTH + 1); + assertThatThrownBy(() -> service.validate(EMPTY_FILTER, tooLong)) + .isInstanceOf(InvalidSearchQueryException.class) + .hasMessageContaining(String.valueOf(SearchConstraints.MAX_QUERY_LENGTH)); + } + } + + // ========================================================================= + // Field name grammar validation + // ========================================================================= + + @Nested + @DisplayName("Field name validation") + class FieldNameTests { + + @Test + @DisplayName("'template' field is accepted") + void template_accepted() { + assertThatCode(() -> service.validate(criterion("template", SearchOperator.EQ, "val"), null)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("'identifier' field is accepted") + void identifier_accepted() { + assertThatCode( + () -> service.validate(criterion("identifier", SearchOperator.EQ, "val"), null)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("'name' field is accepted") + void name_accepted() { + assertThatCode(() -> service.validate(criterion("name", SearchOperator.EQ, "val"), null)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("'relation' bare field is accepted") + void relation_accepted() { + assertThatCode(() -> service.validate(criterion("relation", SearchOperator.EQ, "val"), null)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("'relations_as_target' bare field is accepted") + void relationsAsTarget_accepted() { + assertThatCode( + () -> service.validate(criterion("relations_as_target", SearchOperator.EQ, "val"), null)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("'property.{name}' field is accepted") + void propertyField_accepted() { + assertThatCode( + () -> service.validate(criterion("property.language", SearchOperator.EQ, "val"), null)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("'relation.{name}' field is accepted") + void relationField_accepted() { + assertThatCode( + () -> service.validate(criterion("relation.api-link", SearchOperator.EQ, "val"), null)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("'relations_as_target.{name}.identifier' field is accepted") + void relationsAsTargetIdentifierField_accepted() { + assertThatCode(() -> service.validate( + criterion("relations_as_target.api-link.identifier", SearchOperator.EQ, "val"), null)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("'relations_as_target.{name}.name' field is accepted") + void relationsAsTargetNameField_accepted() { + assertThatCode(() -> service + .validate(criterion("relations_as_target.api-link.name", SearchOperator.EQ, "val"), null)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("unknown field throws") + void unknownField_throws() { + assertThatThrownBy( + () -> service.validate(criterion("badField", SearchOperator.EQ, "val"), null)) + .isInstanceOf(InvalidSearchQueryException.class).hasMessageContaining("badField"); + } + + @Test + @DisplayName("'relations_as_target' without subfield throws") + void relationsAsTarget_missingSubfield_throws() { + assertThatThrownBy(() -> service + .validate(criterion("relations_as_target.api-link", SearchOperator.EQ, "val"), null)) + .isInstanceOf(InvalidSearchQueryException.class); + } + + @Test + @DisplayName("field validation applies to criteria nested inside groups") + void invalidField_nestedInGroup_throws() { + var filter = new SearchFilterNode.Group(LogicalConnector.AND, + List.of(criterion("template", SearchOperator.EQ, "svc"), + criterion("unknownField", SearchOperator.EQ, "val"))); + assertThatThrownBy(() -> service.validate(filter, null)) + .isInstanceOf(InvalidSearchQueryException.class).hasMessageContaining("unknownField"); + } + } + + // ========================================================================= + // Numeric operator constraint validation + // ========================================================================= + + @Nested + @DisplayName("Numeric operator constraints") + class NumericOperatorTests { + + @Test + @DisplayName("GT on property.{name} with a numeric value is accepted") + void gt_onProperty_numericValue_accepted() { + assertThatCode( + () -> service.validate(criterion("property.port", SearchOperator.GT, "8080"), null)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("GTE on property.{name} with a decimal value is accepted") + void gte_onProperty_decimalValue_accepted() { + assertThatCode( + () -> service.validate(criterion("property.score", SearchOperator.GTE, "1.5"), null)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("GT on 'template' field throws — numeric ops only on property.{name}") + void gt_onTemplateField_throws() { + assertThatThrownBy( + () -> service.validate(criterion("template", SearchOperator.GT, "5"), null)) + .isInstanceOf(InvalidSearchQueryException.class).hasMessageContaining("GT"); + } + + @Test + @DisplayName("LT on 'identifier' field throws — numeric ops only on property.{name}") + void lt_onIdentifierField_throws() { + assertThatThrownBy( + () -> service.validate(criterion("identifier", SearchOperator.LT, "5"), null)) + .isInstanceOf(InvalidSearchQueryException.class).hasMessageContaining("LT"); + } + + @Test + @DisplayName("GT on property.{name} with a non-numeric value throws") + void gt_nonNumericValue_throws() { + assertThatThrownBy( + () -> service.validate(criterion("property.port", SearchOperator.GT, "abc"), null)) + .isInstanceOf(InvalidSearchQueryException.class).hasMessageContaining("abc") + .hasMessageContaining("GT"); + } + + @Test + @DisplayName("LTE on property.{name} with alphanumeric non-numeric value throws") + void lte_nonNumericValue_throws() { + assertThatThrownBy( + () -> service.validate(criterion("property.size", SearchOperator.LTE, "10MB"), null)) + .isInstanceOf(InvalidSearchQueryException.class).hasMessageContaining("10MB"); + } + } + + // ========================================================================= + // Template-scoped property-type validation + // ========================================================================= + + @Nested + @DisplayName("No numeric operators — no validation triggered") + class NoNumericOperatorsTests { + + @Test + @DisplayName("filter with only EQ operators does not throw") + void eq_only_doesNotThrow() { + var filter = new SearchFilterNode.Group(LogicalConnector.AND, List.of( + new SearchFilterNode.Criterion("template", SearchOperator.EQ, "microservice"), + new SearchFilterNode.Criterion("property.lifecycle", SearchOperator.EQ, "production"))); + assertThatCode(() -> service.validate(filter, null)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("empty filter does not throw") + void emptyFilter_doesNotThrow() { + assertThatCode(() -> service.validate(EMPTY_FILTER, null)).doesNotThrowAnyException(); + } + } + + @Nested + @DisplayName("Numeric operators without a template constraint") + class NoTemplateConstraintTests { + + @Test + @DisplayName("GT on property without template constraint does not throw (can't validate)") + void gt_noTemplateConstraint_doesNotThrow() { + assertThatCode(() -> service.validate( + new SearchFilterNode.Criterion("property.port", SearchOperator.GT, "8080"), null)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("GT on property with only non-EQ template constraint does not throw") + void gt_templateConstraintNotEq_doesNotThrow() { + var filter = new SearchFilterNode.Group(LogicalConnector.AND, + List.of(new SearchFilterNode.Criterion("template", SearchOperator.CONTAINS, "service"), + new SearchFilterNode.Criterion("property.port", SearchOperator.GT, "8080"))); + assertThatCode(() -> service.validate(filter, null)).doesNotThrowAnyException(); + } + } + + @Nested + @DisplayName("Numeric operators with a template constraint (type check enabled)") + class WithTemplateConstraintTests { + + @Test + @DisplayName("GT on a NUMBER property does not throw") + void gt_numberProperty_doesNotThrow() { + when(repository.findByIdentifier("web-service")) + .thenReturn(Optional.of(template("web-service", prop("port", PropertyType.NUMBER)))); + + var filter = new SearchFilterNode.Group(LogicalConnector.AND, + List.of(new SearchFilterNode.Criterion("template", SearchOperator.EQ, "web-service"), + new SearchFilterNode.Criterion("property.port", SearchOperator.GT, "8080"))); + assertThatCode(() -> service.validate(filter, null)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("GTE, LT, LTE on a NUMBER property do not throw") + void allNumericOperators_numberProperty_doesNotThrow() { + when(repository.findByIdentifier("ws")) + .thenReturn(Optional.of(template("ws", prop("score", PropertyType.NUMBER)))); + + for (SearchOperator op : List.of(SearchOperator.GTE, SearchOperator.LT, SearchOperator.LTE)) { + var filter = new SearchFilterNode.Group(LogicalConnector.AND, + List.of(new SearchFilterNode.Criterion("template", SearchOperator.EQ, "ws"), + new SearchFilterNode.Criterion("property.score", op, "5"))); + assertThatCode(() -> service.validate(filter, null)) + .as("operator %s should not throw for NUMBER property", op).doesNotThrowAnyException(); + } + } + + @Test + @DisplayName("GT on a STRING property throws") + void gt_stringProperty_throws() { + when(repository.findByIdentifier("web-service")).thenReturn( + Optional.of(template("web-service", prop("programmingLanguage", PropertyType.STRING), + prop("port", PropertyType.NUMBER)))); + + var filter = new SearchFilterNode.Group(LogicalConnector.AND, List.of( + new SearchFilterNode.Criterion("template", SearchOperator.EQ, "web-service"), + new SearchFilterNode.Criterion("property.programmingLanguage", SearchOperator.GT, "5"))); + assertThatThrownBy(() -> service.validate(filter, null)) + .isInstanceOf(InvalidSearchQueryException.class) + .hasMessageContaining("programmingLanguage").hasMessageContaining("web-service") + .hasMessageContaining("STRING"); + } + + @Test + @DisplayName("GT on a BOOLEAN property throws") + void gt_booleanProperty_throws() { + when(repository.findByIdentifier("svc")) + .thenReturn(Optional.of(template("svc", prop("isActive", PropertyType.BOOLEAN)))); + + var filter = new SearchFilterNode.Group(LogicalConnector.AND, + List.of(new SearchFilterNode.Criterion("template", SearchOperator.EQ, "svc"), + new SearchFilterNode.Criterion("property.isActive", SearchOperator.LTE, "1"))); + assertThatThrownBy(() -> service.validate(filter, null)) + .isInstanceOf(InvalidSearchQueryException.class).hasMessageContaining("isActive") + .hasMessageContaining("BOOLEAN"); + } + + @Test + @DisplayName("unknown template does not throw — template may not exist yet") + void unknownTemplate_doesNotThrow() { + when(repository.findByIdentifier("unknown")).thenReturn(Optional.empty()); + + var filter = new SearchFilterNode.Group(LogicalConnector.AND, + List.of(new SearchFilterNode.Criterion("template", SearchOperator.EQ, "unknown"), + new SearchFilterNode.Criterion("property.port", SearchOperator.GT, "80"))); + assertThatCode(() -> service.validate(filter, null)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("property not defined in template does not throw — may be optional") + void propertyNotInTemplate_doesNotThrow() { + when(repository.findByIdentifier("ws")) + .thenReturn(Optional.of(template("ws", prop("port", PropertyType.NUMBER)))); + + var filter = new SearchFilterNode.Group(LogicalConnector.AND, + List.of(new SearchFilterNode.Criterion("template", SearchOperator.EQ, "ws"), + new SearchFilterNode.Criterion("property.undefinedProp", SearchOperator.GT, "5"))); + assertThatCode(() -> service.validate(filter, null)).doesNotThrowAnyException(); + } + } + + @Nested + @DisplayName("Nested filter trees") + class NestedTreeTests { + + @Test + @DisplayName("GT on STRING property nested inside OR group throws") + void gt_stringProperty_nestedInOr_throws() { + when(repository.findByIdentifier("svc")) + .thenReturn(Optional.of(template("svc", prop("name", PropertyType.STRING)))); + + var inner = new SearchFilterNode.Group(LogicalConnector.OR, + List.of(new SearchFilterNode.Criterion("property.name", SearchOperator.GT, "5"))); + var filter = new SearchFilterNode.Group(LogicalConnector.AND, + List.of(new SearchFilterNode.Criterion("template", SearchOperator.EQ, "svc"), inner)); + assertThatThrownBy(() -> service.validate(filter, null)) + .isInstanceOf(InvalidSearchQueryException.class).hasMessageContaining("name") + .hasMessageContaining("STRING"); + } + } + + private static SearchFilterNode.Criterion criterion(String field, SearchOperator op, + String value) { + return new SearchFilterNode.Criterion(field, op, value); + } } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java index 939b70f2..965e0944 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java @@ -762,7 +762,6 @@ void putEntity_403_without_csrf() throws Exception { } - @Nested @DisplayName("POST /api/v1/entities/search") class SearchEntitiesTests { @@ -772,14 +771,10 @@ class SearchEntitiesTests { @Test @DisplayName("Should return 401 without authentication") void search_401_withoutAuth() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(""" + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(""" { "page": 0, "size": 20 } - """)) - .andExpect(status().isUnauthorized()); + """)).andExpect(status().isUnauthorized()); } @Test @@ -787,10 +782,7 @@ void search_401_withoutAuth() throws Exception { @WithMockUser void search_200_byTemplateAndProperty() throws Exception { var result = mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(""" + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(""" { "filter": { "connector": "AND", @@ -801,13 +793,11 @@ void search_200_byTemplateAndProperty() throws Exception { }, "page": 0, "size": 20, "sort": "identifier:asc" } - """)) - .andExpect(status().isOk()) - .andReturn(); + """)).andExpect(status().isOk()).andReturn(); JSONAssert.assertEquals( - getJsonTestFileContent(ENTITY_JSON_FILES_TEST_PATH + "searchEntities_200_byTemplateAndProperty.json"), - result.getResponse().getContentAsString(), - JSONCompareMode.STRICT); + getJsonTestFileContent( + ENTITY_JSON_FILES_TEST_PATH + "searchEntities_200_byTemplateAndProperty.json"), + result.getResponse().getContentAsString(), JSONCompareMode.STRICT); } @Test @@ -815,10 +805,7 @@ void search_200_byTemplateAndProperty() throws Exception { @WithMockUser void search_200_orTemplates() throws Exception { var result = mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(""" + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(""" { "filter": { "connector": "OR", @@ -829,41 +816,37 @@ void search_200_orTemplates() throws Exception { }, "page": 0, "size": 20, "sort": "identifier:asc" } - """)) - .andExpect(status().isOk()) - .andReturn(); + """)).andExpect(status().isOk()).andReturn(); JSONAssert.assertEquals( - getJsonTestFileContent(ENTITY_JSON_FILES_TEST_PATH + "searchEntities_200_orTemplates.json"), - result.getResponse().getContentAsString(), - JSONCompareMode.STRICT); + getJsonTestFileContent( + ENTITY_JSON_FILES_TEST_PATH + "searchEntities_200_orTemplates.json"), + result.getResponse().getContentAsString(), JSONCompareMode.STRICT); } @Test @DisplayName("Should search entities by relations_as_target identifier") @WithMockUser void search_200_byRelationsAsTarget() throws Exception { - var result = mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(""" - { - "filter": { - "connector": "AND", - "criteria": [ - { "field": "template", "operation": "EQ", "value": "microservice" }, - { "field": "relations_as_target.api-link.identifier", "operation": "EQ", "value": "web-api-1" } - ] - }, - "page": 0, "size": 20 - } - """)) - .andExpect(status().isOk()) - .andReturn(); + var result = mockMvc + .perform(MockMvcRequestBuilders.post(SEARCH_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content( + """ + { + "filter": { + "connector": "AND", + "criteria": [ + { "field": "template", "operation": "EQ", "value": "microservice" }, + { "field": "relations_as_target.api-link.identifier", "operation": "EQ", "value": "web-api-1" } + ] + }, + "page": 0, "size": 20 + } + """)) + .andExpect(status().isOk()).andReturn(); JSONAssert.assertEquals( - getJsonTestFileContent(ENTITY_JSON_FILES_TEST_PATH + "searchEntities_200_byRelationsAsTarget.json"), - result.getResponse().getContentAsString(), - JSONCompareMode.STRICT); + getJsonTestFileContent( + ENTITY_JSON_FILES_TEST_PATH + "searchEntities_200_byRelationsAsTarget.json"), + result.getResponse().getContentAsString(), JSONCompareMode.STRICT); } @Test @@ -871,10 +854,7 @@ void search_200_byRelationsAsTarget() throws Exception { @WithMockUser void search_200_byRelationsAsTargetPresence() throws Exception { var result = mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(""" + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(""" { "filter": { "connector": "AND", @@ -885,26 +865,22 @@ void search_200_byRelationsAsTargetPresence() throws Exception { }, "page": 0, "size": 20 } - """)) - .andExpect(status().isOk()) - .andReturn(); + """)).andExpect(status().isOk()).andReturn(); JSONAssert.assertEquals( - getJsonTestFileContent(ENTITY_JSON_FILES_TEST_PATH + "searchEntities_200_byRelationsAsTargetPresence.json"), - result.getResponse().getContentAsString(), - JSONCompareMode.STRICT); + getJsonTestFileContent( + ENTITY_JSON_FILES_TEST_PATH + "searchEntities_200_byRelationsAsTargetPresence.json"), + result.getResponse().getContentAsString(), JSONCompareMode.STRICT); } @Test @DisplayName("Should search entities by bare relations_as_target absence (NOT_CONTAINS)") @WithMockUser void search_200_byRelationsAsTargetAbsence() throws Exception { - // web-api-1 and web-api-2 are web-service entities not targeted by any 'uses' relation, + // web-api-1 and web-api-2 are web-service entities not targeted by any 'uses' + // relation, // so NOT_CONTAINS must include them in the results. - mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(""" + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(""" { "filter": { "connector": "AND", @@ -915,8 +891,7 @@ void search_200_byRelationsAsTargetAbsence() throws Exception { }, "page": 0, "size": 20 } - """)) - .andExpect(status().isOk()) + """)).andExpect(status().isOk()) .andExpect(jsonPath("$.content[*].identifier", hasItem("web-api-1"))) .andExpect(jsonPath("$.content[*].identifier", hasItem("web-api-2"))); } @@ -926,10 +901,7 @@ void search_200_byRelationsAsTargetAbsence() throws Exception { @WithMockUser void search_200_startsWith() throws Exception { var result = mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(""" + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(""" { "filter": { "connector": "AND", @@ -940,13 +912,11 @@ void search_200_startsWith() throws Exception { }, "page": 0, "size": 20 } - """)) - .andExpect(status().isOk()) - .andReturn(); + """)).andExpect(status().isOk()).andReturn(); JSONAssert.assertEquals( - getJsonTestFileContent(ENTITY_JSON_FILES_TEST_PATH + "searchEntities_200_startsWith.json"), - result.getResponse().getContentAsString(), - JSONCompareMode.STRICT); + getJsonTestFileContent( + ENTITY_JSON_FILES_TEST_PATH + "searchEntities_200_startsWith.json"), + result.getResponse().getContentAsString(), JSONCompareMode.STRICT); } @Test @@ -954,10 +924,7 @@ void search_200_startsWith() throws Exception { @WithMockUser void search_200_neq() throws Exception { var result = mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(""" + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(""" { "filter": { "connector": "AND", @@ -969,36 +936,30 @@ void search_200_neq() throws Exception { }, "page": 0, "size": 20 } - """)) - .andExpect(status().isOk()) - .andReturn(); + """)).andExpect(status().isOk()).andReturn(); JSONAssert.assertEquals( getJsonTestFileContent(ENTITY_JSON_FILES_TEST_PATH + "searchEntities_200_neq.json"), - result.getResponse().getContentAsString(), - JSONCompareMode.STRICT); + result.getResponse().getContentAsString(), JSONCompareMode.STRICT); } @Test @DisplayName("Should return empty content when no entities match") @WithMockUser void search_200_noMatch() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(""" - { - "filter": { - "field": "identifier", - "operation": "EQ", - "value": "non-existent-entity-xyz" - }, - "page": 0, - "size": 20 - } - """)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.content.length()").value(0)) + mockMvc + .perform(MockMvcRequestBuilders.post(SEARCH_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(""" + { + "filter": { + "field": "identifier", + "operation": "EQ", + "value": "non-existent-entity-xyz" + }, + "page": 0, + "size": 20 + } + """)) + .andExpect(status().isOk()).andExpect(jsonPath("$.content.length()").value(0)) .andExpect(jsonPath("$.page.total_elements").value(0)); } @@ -1006,18 +967,15 @@ void search_200_noMatch() throws Exception { @DisplayName("Should return all entities when no filter is applied") @WithMockUser void search_200_nullFilter() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(""" - { - "page": 0, - "size": 5 - } - """)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.content").isArray()) + mockMvc + .perform(MockMvcRequestBuilders.post(SEARCH_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(""" + { + "page": 0, + "size": 5 + } + """)) + .andExpect(status().isOk()).andExpect(jsonPath("$.content").isArray()) .andExpect(jsonPath("$.content.length()").value(5)); } @@ -1025,23 +983,20 @@ void search_200_nullFilter() throws Exception { @DisplayName("Should return paginated results respecting size parameter") @WithMockUser void search_200_paginated() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(""" - { - "filter": { - "field": "template", - "operation": "EQ", - "value": "monitoring-service" - }, - "page": 0, - "size": 3 - } - """)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.content.length()").value(3)) + mockMvc + .perform(MockMvcRequestBuilders.post(SEARCH_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(""" + { + "filter": { + "field": "template", + "operation": "EQ", + "value": "monitoring-service" + }, + "page": 0, + "size": 3 + } + """)) + .andExpect(status().isOk()).andExpect(jsonPath("$.content.length()").value(3)) .andExpect(jsonPath("$.page.size").value(3)) .andExpect(jsonPath("$.page.total_elements").value(6)) .andExpect(jsonPath("$.page.total_pages").value(2)); @@ -1051,58 +1006,51 @@ void search_200_paginated() throws Exception { @DisplayName("Should return 400 for invalid connector value") @WithMockUser void search_400_invalidConnector() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(""" - { - "filter": { - "connector": "INVALID_CONNECTOR", - "criteria": [ - { "field": "template", "operation": "EQ", "value": "microservice" } - ] - }, - "page": 0, - "size": 20 - } - """)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error_description").value("Invalid connector 'INVALID_CONNECTOR'. Supported values: AND, OR")); + mockMvc + .perform(MockMvcRequestBuilders.post(SEARCH_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(""" + { + "filter": { + "connector": "INVALID_CONNECTOR", + "criteria": [ + { "field": "template", "operation": "EQ", "value": "microservice" } + ] + }, + "page": 0, + "size": 20 + } + """)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error_description") + .value("Invalid connector 'INVALID_CONNECTOR'. Supported values: AND, OR")); } @Test @DisplayName("Should return 400 for invalid operation value") @WithMockUser void search_400_invalidOperation() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(""" - { - "filter": { - "field": "identifier", - "operation": "LIKE", - "value": "api" - }, - "page": 0, - "size": 20 - } - """)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error_description").value("Invalid operation 'LIKE'. Supported values: EQ, NEQ, CONTAINS, NOT_CONTAINS, STARTS_WITH, ENDS_WITH, GT, GTE, LT, LTE")); + mockMvc + .perform(MockMvcRequestBuilders.post(SEARCH_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(""" + { + "filter": { + "field": "identifier", + "operation": "LIKE", + "value": "api" + }, + "page": 0, + "size": 20 + } + """)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error_description").value( + "Invalid operation 'LIKE'. Supported values: EQ, NEQ, CONTAINS, NOT_CONTAINS, STARTS_WITH, ENDS_WITH, GT, GTE, LT, LTE")); } @Test @DisplayName("Should return 400 for invalid field name") @WithMockUser void search_400_invalidField() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(""" + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(""" { "filter": { "field": "unknownField", @@ -1112,75 +1060,67 @@ void search_400_invalidField() throws Exception { "page": 0, "size": 20 } - """)) - .andExpect(status().isBadRequest()); + """)).andExpect(status().isBadRequest()); } @Test @DisplayName("Should return 400 when criterion is missing field") @WithMockUser void search_400_missingField() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(""" - { - "filter": { - "operation": "EQ", - "value": "microservice" - }, - "page": 0, - "size": 20 - } - """)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error_description").value("A criterion node must have a non-blank 'field'")); + mockMvc + .perform(MockMvcRequestBuilders.post(SEARCH_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(""" + { + "filter": { + "operation": "EQ", + "value": "microservice" + }, + "page": 0, + "size": 20 + } + """)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error_description") + .value("A criterion node must have a non-blank 'field'")); } @Test @DisplayName("Should return 400 when group is missing criteria") @WithMockUser void search_400_groupMissingCriteria() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(""" - { - "filter": { - "connector": "AND" - }, - "page": 0, - "size": 20 - } - """)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error_description").value("A group node must have a non-empty 'criteria' list")); + mockMvc + .perform(MockMvcRequestBuilders.post(SEARCH_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(""" + { + "filter": { + "connector": "AND" + }, + "page": 0, + "size": 20 + } + """)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error_description") + .value("A group node must have a non-empty 'criteria' list")); } @Test @DisplayName("Should support sort parameter") @WithMockUser void search_200_withSort() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(""" - { - "filter": { - "field": "template", - "operation": "EQ", - "value": "monitoring-service" - }, - "page": 0, - "size": 6, - "sort": "name:desc" - } - """)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.content.length()").value(6)) + mockMvc + .perform(MockMvcRequestBuilders.post(SEARCH_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(""" + { + "filter": { + "field": "template", + "operation": "EQ", + "value": "monitoring-service" + }, + "page": 0, + "size": 6, + "sort": "name:desc" + } + """)) + .andExpect(status().isOk()).andExpect(jsonPath("$.content.length()").value(6)) .andExpect(jsonPath("$.content[0].name").value("Monitoring Service 6")); } @@ -1188,31 +1128,28 @@ void search_200_withSort() throws Exception { @DisplayName("Should support nested AND/OR filter composition") @WithMockUser void search_200_nestedFilter() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(""" - { - "filter": { - "connector": "AND", - "criteria": [ - { - "connector": "OR", + mockMvc + .perform(MockMvcRequestBuilders.post(SEARCH_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(""" + { + "filter": { + "connector": "AND", "criteria": [ - { "field": "template", "operation": "EQ", "value": "microservice" }, - { "field": "template", "operation": "EQ", "value": "batch-job" } + { + "connector": "OR", + "criteria": [ + { "field": "template", "operation": "EQ", "value": "microservice" }, + { "field": "template", "operation": "EQ", "value": "batch-job" } + ] + }, + { "field": "identifier", "operation": "EQ", "value": "microservice-1" } ] }, - { "field": "identifier", "operation": "EQ", "value": "microservice-1" } - ] - }, - "page": 0, - "size": 20 - } - """)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.content.length()").value(1)) + "page": 0, + "size": 20 + } + """)) + .andExpect(status().isOk()).andExpect(jsonPath("$.content.length()").value(1)) .andExpect(jsonPath("$.content[0].identifier").value("microservice-1")); } @@ -1220,15 +1157,12 @@ void search_200_nestedFilter() throws Exception { @DisplayName("Should find entities by query matching identifier") @WithMockUser void search_200_queryByIdentifier() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(""" - { "query": "web-api", "page": 0, "size": 20 } - """)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.content.length()").value(2)) + mockMvc + .perform(MockMvcRequestBuilders.post(SEARCH_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(""" + { "query": "web-api", "page": 0, "size": 20 } + """)) + .andExpect(status().isOk()).andExpect(jsonPath("$.content.length()").value(2)) .andExpect(jsonPath("$.page.total_elements").value(2)); } @@ -1236,15 +1170,12 @@ void search_200_queryByIdentifier() throws Exception { @DisplayName("Should find entities by query matching name (case-insensitive)") @WithMockUser void search_200_queryByName() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(""" - { "query": "Web API", "page": 0, "size": 20 } - """)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.content.length()").value(2)) + mockMvc + .perform(MockMvcRequestBuilders.post(SEARCH_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(""" + { "query": "Web API", "page": 0, "size": 20 } + """)) + .andExpect(status().isOk()).andExpect(jsonPath("$.content.length()").value(2)) .andExpect(jsonPath("$.page.total_elements").value(2)); } @@ -1252,15 +1183,12 @@ void search_200_queryByName() throws Exception { @DisplayName("Should find entities by query matching a property value") @WithMockUser void search_200_queryByPropertyValue() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(""" - { "query": "JAVA", "page": 0, "size": 20 } - """)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.content.length()").value(2)) + mockMvc + .perform(MockMvcRequestBuilders.post(SEARCH_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(""" + { "query": "JAVA", "page": 0, "size": 20 } + """)) + .andExpect(status().isOk()).andExpect(jsonPath("$.content.length()").value(2)) .andExpect(jsonPath("$.content[*].identifier", hasItem("web-api-1"))) .andExpect(jsonPath("$.content[*].identifier", hasItem("web-service-valid-1"))); } @@ -1269,24 +1197,21 @@ void search_200_queryByPropertyValue() throws Exception { @DisplayName("Should combine query and filter with AND semantics") @WithMockUser void search_200_queryAndFilter() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(""" - { - "query": "JAVA", - "filter": { - "field": "template", - "operation": "EQ", - "value": "web-service" - }, - "page": 0, - "size": 20 - } - """)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.content.length()").value(2)) + mockMvc + .perform(MockMvcRequestBuilders.post(SEARCH_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(""" + { + "query": "JAVA", + "filter": { + "field": "template", + "operation": "EQ", + "value": "web-service" + }, + "page": 0, + "size": 20 + } + """)) + .andExpect(status().isOk()).andExpect(jsonPath("$.content.length()").value(2)) .andExpect(jsonPath("$.content[*].identifier", hasItem("web-api-1"))) .andExpect(jsonPath("$.content[*].identifier", hasItem("web-service-valid-1"))); } @@ -1295,15 +1220,12 @@ void search_200_queryAndFilter() throws Exception { @DisplayName("Should treat blank query as no-op and return all entities") @WithMockUser void search_200_blankQueryIsNoOp() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(""" - { "query": " ", "page": 0, "size": 5 } - """)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.content.length()").value(5)); + mockMvc + .perform(MockMvcRequestBuilders.post(SEARCH_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(""" + { "query": " ", "page": 0, "size": 5 } + """)) + .andExpect(status().isOk()).andExpect(jsonPath("$.content.length()").value(5)); } @Test @@ -1311,115 +1233,103 @@ void search_200_blankQueryIsNoOp() throws Exception { @WithMockUser void search_400_queryTooLong() throws Exception { var tooLong = "x".repeat(256); - mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(""" - { "query": "%s", "page": 0, "size": 20 } - """.formatted(tooLong))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error_description").value("Search query must not exceed 255 characters")); + mockMvc + .perform(MockMvcRequestBuilders.post(SEARCH_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(""" + { "query": "%s", "page": 0, "size": 20 } + """.formatted(tooLong))) + .andExpect(status().isBadRequest()).andExpect( + jsonPath("$.error_description").value("Search query must not exceed 255 characters")); } @Test @WithMockUser @DisplayName("Should return 400 when GT operator is used on a non-property field") void search_400_numericOperator_onNonPropertyField() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(""" - { - "filter": { - "connector": "AND", - "criteria": [ - { "field": "template", "operation": "GT", "value": "5" } - ] - }, - "page": 0, "size": 20 - } - """)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error_description").value( - org.hamcrest.Matchers.containsString("GT"))); + mockMvc + .perform(MockMvcRequestBuilders.post(SEARCH_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(""" + { + "filter": { + "connector": "AND", + "criteria": [ + { "field": "template", "operation": "GT", "value": "5" } + ] + }, + "page": 0, "size": 20 + } + """)) + .andExpect(status().isBadRequest()).andExpect( + jsonPath("$.error_description").value(org.hamcrest.Matchers.containsString("GT"))); } @Test @WithMockUser @DisplayName("Should return 400 when GT operator is used with a non-numeric value") void search_400_numericOperator_nonNumericValue() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(""" - { - "filter": { - "connector": "AND", - "criteria": [ - { "field": "property.port", "operation": "GT", "value": "not-a-number" } - ] - }, - "page": 0, "size": 20 - } - """)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error_description").value( - org.hamcrest.Matchers.containsString("not-a-number"))); + mockMvc + .perform(MockMvcRequestBuilders.post(SEARCH_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(""" + { + "filter": { + "connector": "AND", + "criteria": [ + { "field": "property.port", "operation": "GT", "value": "not-a-number" } + ] + }, + "page": 0, "size": 20 + } + """)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error_description") + .value(org.hamcrest.Matchers.containsString("not-a-number"))); } @Test @WithMockUser @DisplayName("Should return 400 when GTE is used on a STRING-typed property with a known template") void search_400_numericOperator_onStringProperty() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(""" - { - "filter": { - "connector": "AND", - "criteria": [ - { "field": "template", "operation": "EQ", "value": "web-service" }, - { "field": "property.programmingLanguage", "operation": "GTE", "value": "5" } - ] - }, - "page": 0, "size": 20 - } - """)) + mockMvc + .perform(MockMvcRequestBuilders.post(SEARCH_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content( + """ + { + "filter": { + "connector": "AND", + "criteria": [ + { "field": "template", "operation": "EQ", "value": "web-service" }, + { "field": "property.programmingLanguage", "operation": "GTE", "value": "5" } + ] + }, + "page": 0, "size": 20 + } + """)) .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error_description").value( - org.hamcrest.Matchers.allOf( - org.hamcrest.Matchers.containsString("programmingLanguage"), - org.hamcrest.Matchers.containsString("STRING")))); + .andExpect(jsonPath("$.error_description").value(org.hamcrest.Matchers.allOf( + org.hamcrest.Matchers.containsString("programmingLanguage"), + org.hamcrest.Matchers.containsString("STRING")))); } @Test @WithMockUser @DisplayName("Should return 200 and match correct entities when GT used on a NUMBER property") void search_200_numericGt_onNumberProperty() throws Exception { - // web-api-1 has port=8080, web-api-2 has port=9090; GT 8085 should return only web-api-2 - mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(""" - { - "filter": { - "connector": "AND", - "criteria": [ - { "field": "template", "operation": "EQ", "value": "web-service" }, - { "field": "property.port", "operation": "GT", "value": "8085" } - ] - }, - "page": 0, "size": 20 - } - """)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.page.total_elements").value(1)) + // web-api-1 has port=8080, web-api-2 has port=9090; GT 8085 should return only + // web-api-2 + mockMvc + .perform(MockMvcRequestBuilders.post(SEARCH_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(""" + { + "filter": { + "connector": "AND", + "criteria": [ + { "field": "template", "operation": "EQ", "value": "web-service" }, + { "field": "property.port", "operation": "GT", "value": "8085" } + ] + }, + "page": 0, "size": 20 + } + """)) + .andExpect(status().isOk()).andExpect(jsonPath("$.page.total_elements").value(1)) .andExpect(jsonPath("$.content[0].identifier").value("web-api-2")); } @@ -1428,13 +1338,12 @@ void search_200_numericGt_onNumberProperty() throws Exception { @DisplayName("Should return 200 and match all seeded entities when LTE used with upper bound covering all") void search_200_numericLte_onNumberProperty_allMatch() throws Exception { // Both web-api-1 (port=8080) and web-api-2 (port=9090) are <= 9999. - // Other test methods (e.g. postEntity_201) may create additional web-service entities - // in the same shared DB, so we only assert at-least-2 rather than an exact count. - mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(""" + // Other test methods (e.g. postEntity_201) may create additional web-service + // entities + // in the same shared DB, so we only assert at-least-2 rather than an exact + // count. + mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(""" { "filter": { "connector": "AND", @@ -1445,34 +1354,30 @@ void search_200_numericLte_onNumberProperty_allMatch() throws Exception { }, "page": 0, "size": 20 } - """)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.page.total_elements", - org.hamcrest.Matchers.greaterThanOrEqualTo(2))); + """)).andExpect(status().isOk()).andExpect( + jsonPath("$.page.total_elements", org.hamcrest.Matchers.greaterThanOrEqualTo(2))); } @Test @WithMockUser @DisplayName("Should return 200 when page and size are omitted from the request body") void search_200_noPageOrSize_usesDefaults() throws Exception { - // Omitting page and size must not cause a 400 JSON parse error (primitive int vs null). + // Omitting page and size must not cause a 400 JSON parse error (primitive int + // vs null). // The record defaults should kick in: page=0, size=20. - mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(""" - { - "filter": { - "connector": "AND", - "criteria": [ - { "field": "template", "operation": "EQ", "value": "web-service" } - ] - } - } - """)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.page.size").value(20)) + mockMvc + .perform(MockMvcRequestBuilders.post(SEARCH_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(""" + { + "filter": { + "connector": "AND", + "criteria": [ + { "field": "template", "operation": "EQ", "value": "web-service" } + ] + } + } + """)) + .andExpect(status().isOk()).andExpect(jsonPath("$.page.size").value(20)) .andExpect(jsonPath("$.page.number").value(0)); } @@ -1480,15 +1385,12 @@ void search_200_noPageOrSize_usesDefaults() throws Exception { @WithMockUser @DisplayName("Should return 400 when size exceeds the maximum allowed value") void search_400_pageSizeTooLarge() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(""" - { "page": 0, "size": 501 } - """)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error_description") + mockMvc + .perform(MockMvcRequestBuilders.post(SEARCH_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(""" + { "page": 0, "size": 501 } + """)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error_description") .value("Page size must not exceed %d".formatted(SearchConstraints.MAX_PAGE_SIZE))); } @@ -1496,15 +1398,13 @@ void search_400_pageSizeTooLarge() throws Exception { @WithMockUser @DisplayName("Should return 400 when sort field is not in the allowed list") void search_400_invalidSortField() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(""" - { "page": 0, "size": 20, "sort": "badField:asc" } - """)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error_description").value("Invalid sort field 'badField'. Supported fields: identifier, name, templateIdentifier")); + mockMvc + .perform(MockMvcRequestBuilders.post(SEARCH_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()).content(""" + { "page": 0, "size": 20, "sort": "badField:asc" } + """)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error_description").value( + "Invalid sort field 'badField'. Supported fields: identifier, name, templateIdentifier")); } @Test @@ -1513,21 +1413,16 @@ void search_400_invalidSortField() throws Exception { void search_200_byRelationNameEq() throws Exception { // web-api-1 has relation "api-link"; web-api-2 does not var result = mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(""" + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(""" { "filter": { "field": "relation", "operation": "EQ", "value": "api-link" }, "page": 0, "size": 20 } - """)) - .andExpect(status().isOk()) - .andReturn(); + """)).andExpect(status().isOk()).andReturn(); JSONAssert.assertEquals( - getJsonTestFileContent(ENTITY_JSON_FILES_TEST_PATH + "searchEntities_200_byRelationNameEq.json"), - result.getResponse().getContentAsString(), - JSONCompareMode.STRICT); + getJsonTestFileContent( + ENTITY_JSON_FILES_TEST_PATH + "searchEntities_200_byRelationNameEq.json"), + result.getResponse().getContentAsString(), JSONCompareMode.STRICT); } @Test @@ -1536,21 +1431,16 @@ void search_200_byRelationNameEq() throws Exception { void search_200_byRelationNameContains() throws Exception { // both web-api-1 and web-api-2 have a relation named "database" var result = mockMvc.perform(MockMvcRequestBuilders.post(SEARCH_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(""" + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(""" { "filter": { "field": "relation", "operation": "CONTAINS", "value": "database" }, "page": 0, "size": 20, "sort": "identifier:asc" } - """)) - .andExpect(status().isOk()) - .andReturn(); + """)).andExpect(status().isOk()).andReturn(); JSONAssert.assertEquals( - getJsonTestFileContent(ENTITY_JSON_FILES_TEST_PATH + "searchEntities_200_byRelationNameContains.json"), - result.getResponse().getContentAsString(), - JSONCompareMode.STRICT); + getJsonTestFileContent( + ENTITY_JSON_FILES_TEST_PATH + "searchEntities_200_byRelationNameContains.json"), + result.getResponse().getContentAsString(), JSONCompareMode.STRICT); } } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecificationTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecificationTest.java index 1cb3b721..e15af22a 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecificationTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecificationTest.java @@ -23,235 +23,235 @@ @DisplayName("EntitySearchSpecification") class EntitySearchSpecificationTest { - @Nested - @DisplayName("of() — empty and null filter") - class EmptyFilterTests { - - @Test - @DisplayName("empty group returns non-null specification") - void emptyGroup_returnsSpec() { - var filter = new SearchFilterNode.Group(LogicalConnector.AND, List.of()); - Specification spec = EntitySearchSpecification.of(filter); - assertThat(spec).isNotNull(); - } - - @Test - @DisplayName("single criterion returns non-null specification") - void singleCriterion_returnsSpec() { - var filter = new SearchFilterNode.Criterion("template", SearchOperator.EQ, "microservice"); - Specification spec = EntitySearchSpecification.of(filter); - assertThat(spec).isNotNull(); - } + @Nested + @DisplayName("of() — empty and null filter") + class EmptyFilterTests { + + @Test + @DisplayName("empty group returns non-null specification") + void emptyGroup_returnsSpec() { + var filter = new SearchFilterNode.Group(LogicalConnector.AND, List.of()); + Specification spec = EntitySearchSpecification.of(filter); + assertThat(spec).isNotNull(); } - @Nested - @DisplayName("of() — group connectors") - class GroupConnectorTests { - - @Test - @DisplayName("AND group returns non-null specification") - void andGroup_returnsSpec() { - var filter = new SearchFilterNode.Group(LogicalConnector.AND, List.of( - new SearchFilterNode.Criterion("template", SearchOperator.EQ, "microservice"), - new SearchFilterNode.Criterion("identifier", SearchOperator.CONTAINS, "api") - )); - assertThat(EntitySearchSpecification.of(filter)).isNotNull(); - } - - @Test - @DisplayName("OR group returns non-null specification") - void orGroup_returnsSpec() { - var filter = new SearchFilterNode.Group(LogicalConnector.OR, List.of( - new SearchFilterNode.Criterion("template", SearchOperator.EQ, "microservice"), - new SearchFilterNode.Criterion("template", SearchOperator.EQ, "web-service") - )); - assertThat(EntitySearchSpecification.of(filter)).isNotNull(); - } - - @Test - @DisplayName("nested group returns non-null specification") - void nestedGroup_returnsSpec() { - var inner = new SearchFilterNode.Group(LogicalConnector.OR, List.of( - new SearchFilterNode.Criterion("template", SearchOperator.EQ, "microservice"), - new SearchFilterNode.Criterion("template", SearchOperator.EQ, "web-service") - )); - var outer = new SearchFilterNode.Group(LogicalConnector.AND, List.of( - inner, - new SearchFilterNode.Criterion("property.language", SearchOperator.EQ, "JAVA") - )); - assertThat(EntitySearchSpecification.of(outer)).isNotNull(); - } + @Test + @DisplayName("single criterion returns non-null specification") + void singleCriterion_returnsSpec() { + var filter = new SearchFilterNode.Criterion("template", SearchOperator.EQ, "microservice"); + Specification spec = EntitySearchSpecification.of(filter); + assertThat(spec).isNotNull(); + } + } + + @Nested + @DisplayName("of() — group connectors") + class GroupConnectorTests { + + @Test + @DisplayName("AND group returns non-null specification") + void andGroup_returnsSpec() { + var filter = new SearchFilterNode.Group(LogicalConnector.AND, + List.of(new SearchFilterNode.Criterion("template", SearchOperator.EQ, "microservice"), + new SearchFilterNode.Criterion("identifier", SearchOperator.CONTAINS, "api"))); + assertThat(EntitySearchSpecification.of(filter)).isNotNull(); + } + + @Test + @DisplayName("OR group returns non-null specification") + void orGroup_returnsSpec() { + var filter = new SearchFilterNode.Group(LogicalConnector.OR, + List.of(new SearchFilterNode.Criterion("template", SearchOperator.EQ, "microservice"), + new SearchFilterNode.Criterion("template", SearchOperator.EQ, "web-service"))); + assertThat(EntitySearchSpecification.of(filter)).isNotNull(); + } + + @Test + @DisplayName("nested group returns non-null specification") + void nestedGroup_returnsSpec() { + var inner = new SearchFilterNode.Group(LogicalConnector.OR, + List.of(new SearchFilterNode.Criterion("template", SearchOperator.EQ, "microservice"), + new SearchFilterNode.Criterion("template", SearchOperator.EQ, "web-service"))); + var outer = new SearchFilterNode.Group(LogicalConnector.AND, List.of(inner, + new SearchFilterNode.Criterion("property.language", SearchOperator.EQ, "JAVA"))); + assertThat(EntitySearchSpecification.of(outer)).isNotNull(); + } + } + + @Nested + @DisplayName("of() — field types") + class FieldTypeTests { + + @Test + @DisplayName("template field returns non-null spec") + void templateField_returnsSpec() { + assertThat(specFor("template", SearchOperator.EQ, "microservice")).isNotNull(); + } + + @Test + @DisplayName("identifier field returns non-null spec") + void identifierField_returnsSpec() { + assertThat(specFor("identifier", SearchOperator.EQ, "my-entity")).isNotNull(); + } + + @Test + @DisplayName("name field returns non-null spec") + void nameField_returnsSpec() { + assertThat(specFor("name", SearchOperator.CONTAINS, "Service")).isNotNull(); + } + + @Test + @DisplayName("property.{name} field returns non-null spec") + void propertyField_returnsSpec() { + assertThat(specFor("property.language", SearchOperator.EQ, "JAVA")).isNotNull(); + } + + @Test + @DisplayName("relation.{name} field returns non-null spec") + void relationField_returnsSpec() { + assertThat(specFor("relation.api-link", SearchOperator.EQ, "microservice-1")).isNotNull(); + } + + @Test + @DisplayName("relation.{name}.identifier field returns non-null spec") + void relationIdentifierField_returnsSpec() { + assertThat(specFor("relation.api-link.identifier", SearchOperator.EQ, "microservice-1")) + .isNotNull(); + } + + @Test + @DisplayName("relation.{name}.name field returns non-null spec") + void relationNameField_returnsSpec() { + assertThat(specFor("relation.api-link.name", SearchOperator.CONTAINS, "Microservice")) + .isNotNull(); + } + + @Test + @DisplayName("relations_as_target.{name}.identifier field returns non-null spec") + void relationsAsTargetIdentifierField_returnsSpec() { + assertThat(specFor("relations_as_target.api-link.identifier", SearchOperator.EQ, "web-api-1")) + .isNotNull(); + } + + @Test + @DisplayName("relations_as_target.{name}.name field returns non-null spec") + void relationsAsTargetNameField_returnsSpec() { + assertThat(specFor("relations_as_target.api-link.name", SearchOperator.CONTAINS, "Web")) + .isNotNull(); + } + + @Test + @DisplayName("bare 'relations_as_target' field (filter on reverse relation name) returns non-null spec") + void bareRelationsAsTargetField_returnsSpec() { + assertThat(specFor("relations_as_target", SearchOperator.NOT_CONTAINS, "used_by")) + .isNotNull(); } - @Nested - @DisplayName("of() — field types") - class FieldTypeTests { - - @Test - @DisplayName("template field returns non-null spec") - void templateField_returnsSpec() { - assertThat(specFor("template", SearchOperator.EQ, "microservice")).isNotNull(); - } - - @Test - @DisplayName("identifier field returns non-null spec") - void identifierField_returnsSpec() { - assertThat(specFor("identifier", SearchOperator.EQ, "my-entity")).isNotNull(); - } - - @Test - @DisplayName("name field returns non-null spec") - void nameField_returnsSpec() { - assertThat(specFor("name", SearchOperator.CONTAINS, "Service")).isNotNull(); - } - - @Test - @DisplayName("property.{name} field returns non-null spec") - void propertyField_returnsSpec() { - assertThat(specFor("property.language", SearchOperator.EQ, "JAVA")).isNotNull(); - } - - @Test - @DisplayName("relation.{name} field returns non-null spec") - void relationField_returnsSpec() { - assertThat(specFor("relation.api-link", SearchOperator.EQ, "microservice-1")).isNotNull(); - } - - @Test - @DisplayName("relation.{name}.identifier field returns non-null spec") - void relationIdentifierField_returnsSpec() { - assertThat(specFor("relation.api-link.identifier", SearchOperator.EQ, "microservice-1")).isNotNull(); - } - - @Test - @DisplayName("relation.{name}.name field returns non-null spec") - void relationNameField_returnsSpec() { - assertThat(specFor("relation.api-link.name", SearchOperator.CONTAINS, "Microservice")).isNotNull(); - } - - @Test - @DisplayName("relations_as_target.{name}.identifier field returns non-null spec") - void relationsAsTargetIdentifierField_returnsSpec() { - assertThat(specFor("relations_as_target.api-link.identifier", SearchOperator.EQ, "web-api-1")).isNotNull(); - } - - @Test - @DisplayName("relations_as_target.{name}.name field returns non-null spec") - void relationsAsTargetNameField_returnsSpec() { - assertThat(specFor("relations_as_target.api-link.name", SearchOperator.CONTAINS, "Web")).isNotNull(); - } - - @Test - @DisplayName("bare 'relations_as_target' field (filter on reverse relation name) returns non-null spec") - void bareRelationsAsTargetField_returnsSpec() { - assertThat(specFor("relations_as_target", SearchOperator.NOT_CONTAINS, "used_by")).isNotNull(); - } - - @Test - @DisplayName("bare 'relation' field (filter on relation name) returns non-null spec") - void bareRelationField_returnsSpec() { - assertThat(specFor("relation", SearchOperator.CONTAINS, "api-link")).isNotNull(); - } - - @Test - @DisplayName("unknown field throws IllegalArgumentException") - void unknownField_throwsException() { - var criterion = new SearchFilterNode.Criterion("unknown_field", SearchOperator.EQ, "value"); - assertThatThrownBy(() -> EntitySearchSpecification.of(criterion)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("unknown_field"); - } + @Test + @DisplayName("bare 'relation' field (filter on relation name) returns non-null spec") + void bareRelationField_returnsSpec() { + assertThat(specFor("relation", SearchOperator.CONTAINS, "api-link")).isNotNull(); } - @Nested - @DisplayName("of() — all operators") - class OperatorTests { - - @Test - @DisplayName("EQ operator returns non-null spec") - void eq_returnsSpec() { - assertThat(specFor("identifier", SearchOperator.EQ, "val")).isNotNull(); - } - - @Test - @DisplayName("NEQ operator returns non-null spec") - void neq_returnsSpec() { - assertThat(specFor("identifier", SearchOperator.NEQ, "val")).isNotNull(); - } - - @Test - @DisplayName("CONTAINS operator returns non-null spec") - void contains_returnsSpec() { - assertThat(specFor("name", SearchOperator.CONTAINS, "service")).isNotNull(); - } - - @Test - @DisplayName("NOT_CONTAINS operator returns non-null spec") - void notContains_returnsSpec() { - assertThat(specFor("name", SearchOperator.NOT_CONTAINS, "legacy")).isNotNull(); - } - - @Test - @DisplayName("STARTS_WITH operator returns non-null spec") - void startsWith_returnsSpec() { - assertThat(specFor("name", SearchOperator.STARTS_WITH, "Web")).isNotNull(); - } - - @Test - @DisplayName("ENDS_WITH operator returns non-null spec") - void endsWith_returnsSpec() { - assertThat(specFor("name", SearchOperator.ENDS_WITH, "Service")).isNotNull(); - } - - @Test - @DisplayName("GT operator returns non-null spec") - void gt_returnsSpec() { - assertThat(specFor("property.version", SearchOperator.GT, "1.0")).isNotNull(); - } - - @Test - @DisplayName("GTE operator returns non-null spec") - void gte_returnsSpec() { - assertThat(specFor("property.version", SearchOperator.GTE, "1.0")).isNotNull(); - } - - @Test - @DisplayName("LT operator returns non-null spec") - void lt_returnsSpec() { - assertThat(specFor("property.version", SearchOperator.LT, "2.0")).isNotNull(); - } - - @Test - @DisplayName("LTE operator returns non-null spec") - void lte_returnsSpec() { - assertThat(specFor("property.version", SearchOperator.LTE, "2.0")).isNotNull(); - } + @Test + @DisplayName("unknown field throws IllegalArgumentException") + void unknownField_throwsException() { + var criterion = new SearchFilterNode.Criterion("unknown_field", SearchOperator.EQ, "value"); + assertThatThrownBy(() -> EntitySearchSpecification.of(criterion)) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("unknown_field"); + } + } + + @Nested + @DisplayName("of() — all operators") + class OperatorTests { + + @Test + @DisplayName("EQ operator returns non-null spec") + void eq_returnsSpec() { + assertThat(specFor("identifier", SearchOperator.EQ, "val")).isNotNull(); + } + + @Test + @DisplayName("NEQ operator returns non-null spec") + void neq_returnsSpec() { + assertThat(specFor("identifier", SearchOperator.NEQ, "val")).isNotNull(); + } + + @Test + @DisplayName("CONTAINS operator returns non-null spec") + void contains_returnsSpec() { + assertThat(specFor("name", SearchOperator.CONTAINS, "service")).isNotNull(); + } + + @Test + @DisplayName("NOT_CONTAINS operator returns non-null spec") + void notContains_returnsSpec() { + assertThat(specFor("name", SearchOperator.NOT_CONTAINS, "legacy")).isNotNull(); + } + + @Test + @DisplayName("STARTS_WITH operator returns non-null spec") + void startsWith_returnsSpec() { + assertThat(specFor("name", SearchOperator.STARTS_WITH, "Web")).isNotNull(); + } + + @Test + @DisplayName("ENDS_WITH operator returns non-null spec") + void endsWith_returnsSpec() { + assertThat(specFor("name", SearchOperator.ENDS_WITH, "Service")).isNotNull(); + } + + @Test + @DisplayName("GT operator returns non-null spec") + void gt_returnsSpec() { + assertThat(specFor("property.version", SearchOperator.GT, "1.0")).isNotNull(); + } + + @Test + @DisplayName("GTE operator returns non-null spec") + void gte_returnsSpec() { + assertThat(specFor("property.version", SearchOperator.GTE, "1.0")).isNotNull(); + } + + @Test + @DisplayName("LT operator returns non-null spec") + void lt_returnsSpec() { + assertThat(specFor("property.version", SearchOperator.LT, "2.0")).isNotNull(); + } + + @Test + @DisplayName("LTE operator returns non-null spec") + void lte_returnsSpec() { + assertThat(specFor("property.version", SearchOperator.LTE, "2.0")).isNotNull(); + } + } + + private static Specification specFor(String field, SearchOperator op, + String value) { + return EntitySearchSpecification.of(new SearchFilterNode.Criterion(field, op, value)); + } + + @Nested + @DisplayName("globalTextSearch()") + class GlobalTextSearchTests { + + @Test + @DisplayName("returns non-null specification for a plain query") + void plainQuery_returnsSpec() { + assertThat(EntitySearchSpecification.globalTextSearch("checkout")).isNotNull(); } - private static Specification specFor(String field, SearchOperator op, String value) { - return EntitySearchSpecification.of(new SearchFilterNode.Criterion(field, op, value)); + @Test + @DisplayName("returns non-null specification for a query with LIKE wildcards") + void queryWithWildcards_returnsSpec() { + assertThat(EntitySearchSpecification.globalTextSearch("a%b_c")).isNotNull(); } - @Nested - @DisplayName("globalTextSearch()") - class GlobalTextSearchTests { - - @Test - @DisplayName("returns non-null specification for a plain query") - void plainQuery_returnsSpec() { - assertThat(EntitySearchSpecification.globalTextSearch("checkout")).isNotNull(); - } - - @Test - @DisplayName("returns non-null specification for a query with LIKE wildcards") - void queryWithWildcards_returnsSpec() { - assertThat(EntitySearchSpecification.globalTextSearch("a%b_c")).isNotNull(); - } - - @Test - @DisplayName("returns non-null specification for an upper-case query") - void upperCaseQuery_returnsSpec() { - assertThat(EntitySearchSpecification.globalTextSearch("JAVA")).isNotNull(); - } + @Test + @DisplayName("returns non-null specification for an upper-case query") + void upperCaseQuery_returnsSpec() { + assertThat(EntitySearchSpecification.globalTextSearch("JAVA")).isNotNull(); } + } } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/JpaPredicateBuilderTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/JpaPredicateBuilderTest.java index 552127a6..bdc8de6c 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/JpaPredicateBuilderTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/JpaPredicateBuilderTest.java @@ -15,56 +15,51 @@ @DisplayName("JpaPredicateBuilder") class JpaPredicateBuilderTest { - @Nested - @DisplayName("escapeLikeWildcards") - class EscapeLikeWildcardsTests { + @Nested + @DisplayName("escapeLikeWildcards") + class EscapeLikeWildcardsTests { - @Test - @DisplayName("escapes percent sign") - void escapes_percent() { - assertThat(JpaPredicateBuilder.escapeLikeWildcards("100%")) - .isEqualTo("100\\%"); - } + @Test + @DisplayName("escapes percent sign") + void escapes_percent() { + assertThat(JpaPredicateBuilder.escapeLikeWildcards("100%")).isEqualTo("100\\%"); + } - @Test - @DisplayName("escapes underscore") - void escapes_underscore() { - assertThat(JpaPredicateBuilder.escapeLikeWildcards("my_value")) - .isEqualTo("my\\_value"); - } + @Test + @DisplayName("escapes underscore") + void escapes_underscore() { + assertThat(JpaPredicateBuilder.escapeLikeWildcards("my_value")).isEqualTo("my\\_value"); + } - @Test - @DisplayName("escapes backslash before other wildcards") - void escapes_backslash() { - assertThat(JpaPredicateBuilder.escapeLikeWildcards("path\\to%file")) - .isEqualTo("path\\\\to\\%file"); - } + @Test + @DisplayName("escapes backslash before other wildcards") + void escapes_backslash() { + assertThat(JpaPredicateBuilder.escapeLikeWildcards("path\\to%file")) + .isEqualTo("path\\\\to\\%file"); + } - @Test - @DisplayName("escapes multiple wildcards") - void escapes_multipleWildcards() { - assertThat(JpaPredicateBuilder.escapeLikeWildcards("100%_success")) - .isEqualTo("100\\%\\_success"); - } + @Test + @DisplayName("escapes multiple wildcards") + void escapes_multipleWildcards() { + assertThat(JpaPredicateBuilder.escapeLikeWildcards("100%_success")) + .isEqualTo("100\\%\\_success"); + } - @Test - @DisplayName("returns plain string unchanged") - void leaves_plainString_unchanged() { - assertThat(JpaPredicateBuilder.escapeLikeWildcards("hello")) - .isEqualTo("hello"); - } + @Test + @DisplayName("returns plain string unchanged") + void leaves_plainString_unchanged() { + assertThat(JpaPredicateBuilder.escapeLikeWildcards("hello")).isEqualTo("hello"); + } - @ParameterizedTest(name = "escapes ''{0}'' correctly") - @ValueSource(strings = {"%", "_", "%%", "__", "%_", "_%"}) - @DisplayName("escapes various wildcard combinations") - void escapes_wildcardCombinations(String input) { - String escaped = JpaPredicateBuilder.escapeLikeWildcards(input); - // Strip all valid escape sequences, then verify no bare wildcards remain - String stripped = escaped.replace("\\%", "").replace("\\_", "").replace("\\\\", ""); - assertThat(stripped) - .doesNotContain("%") - .doesNotContain("_"); - assertThat(escaped).contains("\\"); - } + @ParameterizedTest(name = "escapes ''{0}'' correctly") + @ValueSource(strings = {"%", "_", "%%", "__", "%_", "_%"}) + @DisplayName("escapes various wildcard combinations") + void escapes_wildcardCombinations(String input) { + String escaped = JpaPredicateBuilder.escapeLikeWildcards(input); + // Strip all valid escape sequences, then verify no bare wildcards remain + String stripped = escaped.replace("\\%", "").replace("\\_", "").replace("\\\\", ""); + assertThat(stripped).doesNotContain("%").doesNotContain("_"); + assertThat(escaped).contains("\\"); } + } } From 6334a57fe038bb888d1472cf1820ab1b8b6b4d4f Mon Sep 17 00:00:00 2001 From: evebrnd Date: Fri, 29 May 2026 17:34:49 +0200 Subject: [PATCH 4/7] fix(test): fix integration test file assertions --- .../api/controller/EntityControllerTest.java | 14 ++++++---- ...chEntities_200_byRelationNameContains.json | 20 +++++--------- ...rchEntities_200_byTemplateAndProperty.json | 26 +++++++++++++++++-- .../entity/v1/searchEntities_200_neq.json | 18 ++++++++----- 4 files changed, 51 insertions(+), 27 deletions(-) diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java index 965e0944..c6595756 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java @@ -1188,8 +1188,9 @@ void search_200_queryByPropertyValue() throws Exception { .accept(APPLICATION_JSON).with(csrf()).content(""" { "query": "JAVA", "page": 0, "size": 20 } """)) - .andExpect(status().isOk()).andExpect(jsonPath("$.content.length()").value(2)) + .andExpect(status().isOk()).andExpect(jsonPath("$.content.length()").value(3)) .andExpect(jsonPath("$.content[*].identifier", hasItem("web-api-1"))) + .andExpect(jsonPath("$.content[*].identifier", hasItem("web-api-2"))) .andExpect(jsonPath("$.content[*].identifier", hasItem("web-service-valid-1"))); } @@ -1211,8 +1212,9 @@ void search_200_queryAndFilter() throws Exception { "size": 20 } """)) - .andExpect(status().isOk()).andExpect(jsonPath("$.content.length()").value(2)) + .andExpect(status().isOk()).andExpect(jsonPath("$.content.length()").value(3)) .andExpect(jsonPath("$.content[*].identifier", hasItem("web-api-1"))) + .andExpect(jsonPath("$.content[*].identifier", hasItem("web-api-2"))) .andExpect(jsonPath("$.content[*].identifier", hasItem("web-service-valid-1"))); } @@ -1323,14 +1325,16 @@ void search_200_numericGt_onNumberProperty() throws Exception { "connector": "AND", "criteria": [ { "field": "template", "operation": "EQ", "value": "web-service" }, - { "field": "property.port", "operation": "GT", "value": "8085" } + { "field": "property.port", "operation": "GT", "value": "8079" } ] }, "page": 0, "size": 20 } """)) - .andExpect(status().isOk()).andExpect(jsonPath("$.page.total_elements").value(1)) - .andExpect(jsonPath("$.content[0].identifier").value("web-api-2")); + .andExpect(status().isOk()).andExpect(jsonPath("$.page.total_elements").value(3)) + .andExpect(jsonPath("$.content[0].identifier").value("web-api-1")) + .andExpect(jsonPath("$.content[1].identifier").value("web-service-valid-1")) + .andExpect(jsonPath("$.content[2].identifier").value("web-api-2")); } @Test diff --git a/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byRelationNameContains.json b/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byRelationNameContains.json index 9a2a8a18..a5a0b8f8 100644 --- a/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byRelationNameContains.json +++ b/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byRelationNameContains.json @@ -3,7 +3,11 @@ { "identifier": "web-api-1", "name": "Web API 1", - "properties": { "environment": "PROD", "programmingLanguage": "JAVA", "port": 8080.0 }, + "properties": { + "environment": "PROD", + "port": 8080.0, + "programmingLanguage": "JAVA" + }, "relations": { "database": [ { "identifier": "database-service-1", "name": "Database Service 1" } @@ -14,19 +18,7 @@ }, "relations_as_target": {}, "template_identifier": "web-service" - }, - { - "identifier": "web-api-2", - "name": "Web API 2", - "properties": { "environment": "DEV", "programmingLanguage": "PYTHON", "port": 9090.0 }, - "relations": { - "database": [ - { "identifier": "cache-service-1", "name": "Cache Service 1" } - ] - }, - "relations_as_target": {}, - "template_identifier": "web-service" } ], - "page": { "size": 20, "number": 0, "total_elements": 2, "total_pages": 1 } + "page": { "size": 20, "number": 0, "total_elements": 1, "total_pages": 1 } } diff --git a/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byTemplateAndProperty.json b/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byTemplateAndProperty.json index ec8b9e1b..2d9de923 100644 --- a/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byTemplateAndProperty.json +++ b/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byTemplateAndProperty.json @@ -3,7 +3,11 @@ { "identifier": "web-api-1", "name": "Web API 1", - "properties": { "environment": "PROD", "programmingLanguage": "JAVA", "port": 8080.0 }, + "properties": { + "environment": "PROD", + "port": 8080.0, + "programmingLanguage": "JAVA" + }, "relations": { "database": [ { "identifier": "database-service-1", "name": "Database Service 1" } @@ -15,6 +19,24 @@ "relations_as_target": {}, "template_identifier": "web-service" }, + { + "identifier": "web-api-2", + "name": "Web API 2 Updated", + "properties": { + "teamName": "platform-team", + "environment": "DEV", + "baseUrl": "https://catalog.example.com", + "protocol": "HTTP", + "port": 8080.0, + "programmingLanguage": "JAVA", + "version": "1.2.3", + "applicationName": "catalog-api", + "ownerEmail": "owner@example.com" + }, + "relations": {}, + "relations_as_target": {}, + "template_identifier": "web-service" + }, { "identifier": "web-service-valid-1", "name": "web-service-valid-1", @@ -34,5 +56,5 @@ "template_identifier": "web-service" } ], - "page": { "size": 20, "number": 0, "total_elements": 2, "total_pages": 1 } + "page": { "size": 20, "number": 0, "total_elements": 3, "total_pages": 1 } } diff --git a/src/test/resources/integration_test/json/entity/v1/searchEntities_200_neq.json b/src/test/resources/integration_test/json/entity/v1/searchEntities_200_neq.json index 06baa547..642d3c75 100644 --- a/src/test/resources/integration_test/json/entity/v1/searchEntities_200_neq.json +++ b/src/test/resources/integration_test/json/entity/v1/searchEntities_200_neq.json @@ -2,13 +2,19 @@ "content": [ { "identifier": "web-api-2", - "name": "Web API 2", - "properties": { "environment": "DEV", "programmingLanguage": "PYTHON", "port": 9090.0 }, - "relations": { - "database": [ - { "identifier": "cache-service-1", "name": "Cache Service 1" } - ] + "name": "Web API 2 Updated", + "properties": { + "teamName": "platform-team", + "environment": "DEV", + "baseUrl": "https://catalog.example.com", + "protocol": "HTTP", + "port": 8080.0, + "programmingLanguage": "JAVA", + "version": "1.2.3", + "applicationName": "catalog-api", + "ownerEmail": "owner@example.com" }, + "relations": {}, "relations_as_target": {}, "template_identifier": "web-service" } From 90ec1415f68d240fe44ce83ac3e4894ec6520400 Mon Sep 17 00:00:00 2001 From: evebrnd Date: Fri, 29 May 2026 17:53:30 +0200 Subject: [PATCH 5/7] fix: fix sonar issues --- .github/instructions/java.instructions.md | 1 + .../service/search/SearchFilterParser.java | 12 +++++------ .../search/SearchFilterValidationService.java | 8 +++---- .../api/dto/in/EntitySearchRequestDtoIn.java | 21 ++++++++----------- .../specification/JpaPredicateBuilder.java | 3 --- .../EntityFilterSpecificationTest.java | 12 ----------- 6 files changed, 19 insertions(+), 38 deletions(-) delete mode 100644 src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntityFilterSpecificationTest.java diff --git a/.github/instructions/java.instructions.md b/.github/instructions/java.instructions.md index 18438154..08e92dd9 100644 --- a/.github/instructions/java.instructions.md +++ b/.github/instructions/java.instructions.md @@ -12,6 +12,7 @@ applyTo: '**/*.java' - Focus on readability, maintainability, and performance when refactoring identified issues. - Use IDE and code editor warnings and suggestions to catch common patterns early in development. - Add JavaDoc comments for all public classes and methods to enhance code understandability. +- Use Markdown syntax in JavaDoc instead of HTML tags. - Follow the Java Language Specification and standard conventions for code style and formatting. ## Best practices diff --git a/src/main/java/com/decathlon/idp_core/domain/service/search/SearchFilterParser.java b/src/main/java/com/decathlon/idp_core/domain/service/search/SearchFilterParser.java index 8dae2a6a..d201bf1c 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/search/SearchFilterParser.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/search/SearchFilterParser.java @@ -16,13 +16,11 @@ /// /// **Responsibility:** Parses the raw string representation produced by the infrastructure mapper and /// enforces all structural and safety business rules: -///
    -///
  • Maximum nesting depth ([SearchConstraints#MAX_NESTING_DEPTH])
  • -///
  • Maximum total criteria count ([SearchConstraints#MAX_TOTAL_CRITERIA])
  • -///
  • Required fields on criterion nodes (field, operation, value)
  • -///
  • Required fields on group nodes (connector, non-empty criteria)
  • -///
  • Valid enum values for connectors and operators
  • -///
+/// - Maximum nesting depth ([SearchConstraints#MAX_NESTING_DEPTH]) +/// - Maximum total criteria count ([SearchConstraints#MAX_TOTAL_CRITERIA]) +/// - Required fields on criterion nodes (field, operation, value) +/// - Required fields on group nodes (connector, non-empty criteria) +/// - Valid enum values for connectors and operators /// /// Semantic validation (field name grammar, numeric operator constraints, query length) is /// handled separately by [SearchFilterValidationService]. diff --git a/src/main/java/com/decathlon/idp_core/domain/service/search/SearchFilterValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/search/SearchFilterValidationService.java index 86ba5782..497e3d28 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/search/SearchFilterValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/search/SearchFilterValidationService.java @@ -34,14 +34,14 @@ @AllArgsConstructor public class SearchFilterValidationService { - private static final Set NUMERIC_OPERATORS = Set.of(SearchOperator.GT, - SearchOperator.GTE, SearchOperator.LT, SearchOperator.LTE); - private static final Set SIMPLE_FIELDS = Set.of("template", "identifier", "name", - "relation", "relations_as_target"); private static final String PROPERTY_PREFIX = "property."; private static final String RELATION_PREFIX = "relation."; private static final String RELATIONS_AS_TARGET_PREFIX = "relations_as_target."; private static final String TEMPLATE_FIELD = "template"; + private static final Set NUMERIC_OPERATORS = Set.of(SearchOperator.GT, + SearchOperator.GTE, SearchOperator.LT, SearchOperator.LTE); + private static final Set SIMPLE_FIELDS = Set.of(TEMPLATE_FIELD, "identifier", "name", + "relation", "relations_as_target"); private final EntityTemplateRepositoryPort entityTemplateRepository; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntitySearchRequestDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntitySearchRequestDtoIn.java index f31d7d61..0734a9b1 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntitySearchRequestDtoIn.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntitySearchRequestDtoIn.java @@ -5,20 +5,17 @@ /// Request body for the `POST /api/v1/entities/search` endpoint. /// /// Supports two complementary search modes that can be combined: -///
    -///
  • {@code query} — a free-text string searched across identifier, name, -/// templateIdentifier, and all property values (case-insensitive CONTAINS).
  • -///
  • {@code filter} — a structured, nested filter tree for precise queries.
  • -///
-/// When both are provided the results must satisfy both (AND semantics). +/// - `query` — a free-text string searched across identifier, name, +/// templateIdentifier, and all property values (case-insensitive CONTAINS). +/// - `filter` — a structured, nested filter tree for precise queries. /// -///

Free-text search example

-///
{@code
+/// ### Free-text search example
+/// ```
 /// { "query": "checkout", "page": 0, "size": 20 }
-/// }
+/// ``` /// -///

Structured filter example

-///
{@code
+/// ### Structured filter example
+/// ```
 /// {
 ///   "filter": {
 ///     "connector": "AND",
@@ -31,7 +28,7 @@
 ///   "size": 20,
 ///   "sort": "identifier:asc"
 /// }
-/// }
+/// ``` @Schema(description = "Request body for the POST /api/v1/entities/search endpoint") public record EntitySearchRequestDtoIn( diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/JpaPredicateBuilder.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/JpaPredicateBuilder.java index ec66c04b..a77c5f9c 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/JpaPredicateBuilder.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/JpaPredicateBuilder.java @@ -87,9 +87,6 @@ static boolean isNumericOperator(SearchOperator operator) { static Predicate buildNumericPredicate(CriteriaBuilder cb, Expression field, SearchOperator operator, BigDecimal numericValue) { - // Explicit SQL CAST(field AS NUMERIC): the property value column is VARCHAR; - // without - // this cast PostgreSQL would reject the comparison with a numeric literal. Expression numericField = ((HibernateCriteriaBuilder) cb) .cast((org.hibernate.query.criteria.JpaExpression) field, BigDecimal.class); return switch (operator) { diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntityFilterSpecificationTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntityFilterSpecificationTest.java deleted file mode 100644 index 02987a27..00000000 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntityFilterSpecificationTest.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.decathlon.idp_core.infrastructure.adapters.persistence.specification; - -import com.decathlon.idp_core.infrastructure.adapters.api.controller.EntityControllerTest; - -/// Unit tests for [EntityFilterSpecification]. -/// -/// LIKE wildcard escaping logic is tested in [JpaPredicateBuilderTest]. -/// Integration-level specification behavior is verified through the -/// [EntityControllerTest] integration tests. -@SuppressWarnings("java:S2187") -class EntityFilterSpecificationTest { -} From 4b6b0ee60749b2141e87fc32b6357b4600b21814 Mon Sep 17 00:00:00 2001 From: evebrnd Date: Mon, 1 Jun 2026 16:35:44 +0200 Subject: [PATCH 6/7] refactor: use static string descriptors and fix tests --- .pre-commit-config.yaml | 2 +- docs/src/concepts/entity-filtering.md | 130 +++++++++++++----- .../domain/model/entity/SearchFilterNode.java | 2 +- .../search/SearchFilterValidationService.java | 28 ++++ .../api/configuration/SwaggerDescription.java | 13 +- .../api/dto/in/EntitySearchRequestDtoIn.java | 18 +-- .../adapters/api/dto/in/FilterNodeDtoIn.java | 14 +- .../search/SearchFilterParserTest.java | 34 ++--- .../SearchFilterValidationServiceTest.java | 40 +++--- 9 files changed, 189 insertions(+), 92 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4dea8f00..90382cff 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -54,4 +54,4 @@ repos: language: system pass_filenames: false files: \.java$ - stages: [commit] + stages: [pre-commit] diff --git a/docs/src/concepts/entity-filtering.md b/docs/src/concepts/entity-filtering.md index 7a253031..03c9c635 100644 --- a/docs/src/concepts/entity-filtering.md +++ b/docs/src/concepts/entity-filtering.md @@ -1,16 +1,21 @@ --- title: Filtering entities -description: Query and filter template-scoped entities using the q= filter DSL +description: Query and filter entities --- -Entity filtering extends the existing list-entities endpoint with an optional `q` query parameter. It lets you narrow down results by attributes, property values, and relations using a simple DSL with AND logic. +## Filtering options -> [!WARNING] -> This is a first version of entity filtering with known constraints: `<` and `>` operators are not supported for relation filters, each key can appear at most once per query, and OR logic between criteria is not supported. These limitations will be addressed in future versions. +| Feature | Option 1: GET Query DSL (`q=`) | Option 2: POST JSON Search | +| :--------------------- | :------------------------------------- | :-------------------------------------------------------- | +| **Best for** | Quick, simple, template-scoped filters | Complex, nested logic, or global cross-template discovery | +| **Endpoint scope** | Scoped to a single template | Cross-template (Global system search) | +| **Logical connectors** | AND only | AND / OR | +| **Operators** | `=`, `:`, `<`, `>` | `EQ`, `NEQ`, `CONTAINS`, `STARTS_WITH`, `GT`, `LTE`, etc. | +| **Global text search** | No, strictly filtering | Yes, via optional free-text `query` property | -## Endpoint +## Option 1: Simple GET Filter DSL (`q` parameter) -The `q` parameter is an optional addition to the existing list-entities endpoint: +Entity filtering extends the existing list-entities endpoint with an optional `q` query parameter. It lets you narrow down results by attributes, property values, and relations using a simple DSL with AND logic. ```http GET /api/v1/entities/{templateIdentifier}?q= @@ -28,7 +33,7 @@ curl "http://localhost:8084/api/v1/entities/service?q=name:api;property.status=p --- -## Syntax +### DSL Syntax Each filter is a semicolon-separated list of criteria: @@ -36,25 +41,25 @@ Each filter is a semicolon-separated list of criteria: [;...] ``` -All criteria are combined with **AND** logic, meaning every criterion must match for an entity to appear in the results. +All criteria are combined with **AND** logic, meaning every criterion must match for an entity to appear in the results. It also means users cannot duplicate criteria, each key can appear at most once per filter `q`expression. ### Operators -| Operator | Symbol | Behavior | Example | -| ------------- | ------ | ---------------------------------- | ----------------------- | -| Equals | `=` | Exact match (case-insensitive) | `name=payment-service` | -| Contains | `:` | Partial match (case-insensitive) | `name:payment` | -| Less than | `<` | Less than comparison | `property.port<9000` | -| Greater than | `>` | Greater than comparison | `property.port>1000` | +| Operator | Symbol | Behavior | Example | +| ------------ | ------ | -------------------------------- | ---------------------- | +| Equals | `=` | Exact match (case-insensitive) | `name=payment-service` | +| Contains | `:` | Partial match (case-insensitive) | `name:payment` | +| Less than | `<` | Less than comparison | `property.port<9000` | +| Greater than | `>` | Greater than comparison | `property.port>1000` | > [!WARNING] -> `<` and `>` are only supported for attribute and property filters. Using them on relation filters returns an HTTP `400 Bad Request`. +> `<` and `>` are only supported for property filters (of type NUMBER). Using them on attributes (identifier, name) or relation filters returns an HTTP `400 Bad Request`. --- -## Key Types +### Key Types -### Attribute Filters +#### Attribute Filters Filter by a direct entity field. Supported fields are `identifier` and `name`. @@ -63,7 +68,7 @@ identifier=checkout-service name:api ``` -### Property Filters +#### Property Filters Filter by a property value using `property.`, where `` is the property's name as defined in the template. @@ -96,9 +101,9 @@ relation.database.identifier=my-postgres-1 relation.database.name:prod ``` -### Reverse Relation Filters +#### Reverse Relation Filters -Use `relations_as_target..` to find entities that *appear as targets* in a relation of type ``. The `` must be `identifier` or `name` and refers to the **source** entity in that relation. +Use `relations_as_target..` to find entities that _appear as targets_ in a relation of type ``. The `` must be `identifier` or `name` and refers to the **source** entity in that relation. ```text relations_as_target.owned_by.name:platform-team @@ -107,7 +112,7 @@ relations_as_target.uses.identifier=service-1 --- -## Combining Criteria +### Combining Criteria Join multiple criteria with `;` to narrow results further: @@ -124,7 +129,7 @@ curl "http://localhost:8084/api/v1/entities/service?q=relation.database=my-postg --- -## Examples +### Examples ```bash # Find a service by exact identifier @@ -145,23 +150,78 @@ curl "http://localhost:8084/api/v1/entities/service?q=relations_as_target.api-li --- -## Known Constraints +## Option 2: POST /api/v1/entities/search (JSON advanced search) + +For more complex queries across all templates you can use the JSON search endpoint which accepts a nested filter tree, free-text `query`, pagination and sorting. + +Request shape (`EntitySearchRequestDtoIn`): + +```json +{ + "query": "checkout", + "filter": { + "connector": "AND", + "criteria": [ + { "field": "template", "operation": "EQ", "value": "microservice" }, + { + "connector": "OR", + "criteria": [ + { "field": "property.language", "operation": "EQ", "value": "JAVA" }, + { "field": "property.language", "operation": "EQ", "value": "KOTLIN" } + ] + } + ] + }, + "page": 0, + "size": 20, + "sort": "identifier:asc" +} +``` + +### Request body syntax -This first version of entity filtering has the following constraints: +- `filter` is a tree of group nodes and criterion nodes. A group node has `connector` (one of `AND`,`OR`) and a non-empty `criteria` array. A criterion node must include `field`, `operation` and `value`. +- Supported `operation` values: `EQ`, `NEQ`, `CONTAINS`, `NOT_CONTAINS`, `STARTS_WITH`, `ENDS_WITH`, `GT`, `GTE`, `LT`, `LTE`. +- Free-text `query` (optional) performs a case-insensitive contains search across identifier, name, template identifier and all property values. +- Pagination: `page` is zero-based, `size` defaults to 20. Maximum allowed `size` is 500. -| Constraint | Detail | -| ----------------------------- | -------------------------------------------------------------------------- | -| Operators on relation filters | `<` and `>` are not supported for any relation filter type | -| Logic | AND only—no OR/NOT between criteria | -| Duplicate criteria | Each key can appear at most once per query | -| Max criteria per query | 10 | -| Max key length | 255 characters | -| Max value length | 255 characters | -| Property value type-awareness | All comparisons are string-based regardless of property type | +### What it does -Exceeding the numeric limits returns an HTTP `400 Bad Request` with a descriptive error message. +- Execute cross-template searches with precise logical compositions and full-text assistance. +- Combine `query` and `filter` to first narrow by free-text and then apply structured criteria (or vice-versa). ---- +Quick curl example + +```bash +curl -sS -X POST "http://localhost:8084/api/v1/entities/search" \ + -H 'Content-Type: application/json' \ + -d '{"query":"checkout","filter":{"connector":"AND","criteria":[{"field":"template","operation":"EQ","value":"microservice"}]},"page":0,"size":20}' +``` + +### Field reference + +- `query` (optional): free-text string (max 255 chars) searched case-insensitively in `identifier`, `name`, `templateIdentifier`, and property values. +- `filter` (optional): nested `FilterNodeDtoIn` JSON structure. Use a `template` criterion to scope results to a specific template when needed. +- `page` / `size`: pagination (zero-based `page`). `size` defaults to 20 and the server enforces a maximum of 500. +- `sort`: `field:asc|desc`. Allowed sort fields: `identifier`, `name`, `templateIdentifier`. + +### Operator semantics + +| Operator | Meaning | +| --------------------------- | --------------------------------------------------------------- | +| `EQ` | Exact, case-insensitive equality | +| `NEQ` | Not equal (case-insensitive) | +| `CONTAINS` | Case-insensitive string match | +| `NOT_CONTAINS` | Negated string match | +| `STARTS_WITH` / `ENDS_WITH` | Prefix / suffix match | +| `GT` / `GTE` / `LT` / `LTE` | Ordering comparisons (string or numeric depending on the field) | + +### Enforced limits + +- Maximum filter nesting depth: 5 levels. Requests exceeding this fail with `400 Bad Request` and a descriptive error message. +- Maximum total criterion count across the tree: 50. Requests exceeding this fail with `400 Bad Request`. +- Maximum `query` length: 255 characters. +- Maximum `size` (page size): 500. ## Next Steps diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/SearchFilterNode.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/SearchFilterNode.java index 996f4ad5..6819ec69 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/SearchFilterNode.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/SearchFilterNode.java @@ -9,7 +9,7 @@ /// /// **Business semantics:** A filter tree is composed of two types of nodes: /// - [Group] — a logical group that combines child nodes with a [LogicalConnector] -/// (AND / OR / IN). Children may themselves be groups or leaf criteria, allowing +/// (AND / OR). Children may themselves be groups or leaf criteria, allowing /// arbitrarily deep nesting. /// - [Criterion] — a leaf predicate: field value. /// diff --git a/src/main/java/com/decathlon/idp_core/domain/service/search/SearchFilterValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/search/SearchFilterValidationService.java index 497e3d28..4281253c 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/search/SearchFilterValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/search/SearchFilterValidationService.java @@ -42,6 +42,7 @@ public class SearchFilterValidationService { SearchOperator.GTE, SearchOperator.LT, SearchOperator.LTE); private static final Set SIMPLE_FIELDS = Set.of(TEMPLATE_FIELD, "identifier", "name", "relation", "relations_as_target"); + private static final Set RELATION_PROPERTY_NAMES = Set.of("identifier", "name"); private final EntityTemplateRepositoryPort entityTemplateRepository; @@ -80,6 +81,7 @@ private void validateField(String field) { return; } if (field.startsWith(RELATION_PREFIX) && field.length() > RELATION_PREFIX.length()) { + validateRelationField(field); return; } throw new InvalidSearchQueryException(ValidationMessages.SEARCH_INVALID_FIELD.formatted(field)); @@ -92,6 +94,32 @@ private void validateRelationsAsTargetField(String field) { throw new InvalidSearchQueryException( ValidationMessages.SEARCH_INVALID_FIELD.formatted(field)); } + String property = rest.substring(dot + 1); + if (!RELATION_PROPERTY_NAMES.contains(property)) { + throw new InvalidSearchQueryException( + ValidationMessages.SEARCH_INVALID_FIELD.formatted(field)); + } + } + + private void validateRelationField(String field) { + String rest = field.substring(RELATION_PREFIX.length()); + int dot = rest.indexOf('.'); + if (dot < 0) { + if (rest.isBlank()) { + throw new InvalidSearchQueryException( + ValidationMessages.SEARCH_INVALID_FIELD.formatted(field)); + } + return; + } + if (dot == 0 || dot == rest.length() - 1) { + throw new InvalidSearchQueryException( + ValidationMessages.SEARCH_INVALID_FIELD.formatted(field)); + } + String property = rest.substring(dot + 1); + if (!RELATION_PROPERTY_NAMES.contains(property)) { + throw new InvalidSearchQueryException( + ValidationMessages.SEARCH_INVALID_FIELD.formatted(field)); + } } private void validateNumericConstraints(SearchOperator operator, String field, String value) { diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java index 15abd19e..f3668701 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java @@ -107,6 +107,8 @@ public class SwaggerDescription { public static final String SCHEMA_ENTITY_CREATE_IN = "Input DTO for creating an entity"; public static final String SCHEMA_ENTITY_UPDATE_IN = "Input DTO for updating an entity"; public static final String SCHEMA_ENTITY_RELATION_IN = "Input DTO for an entity relation instance"; + public static final String SCHEMA_ENTITY_SEARCH_REQUEST_IN = "Request body for the POST /api/v1/entities/search endpoint"; + public static final String SCHEMA_FILTER_NODE = "A node in the search filter tree. Either a logical group (connector + criteria) or a leaf criterion (field + operation + value)."; // --- Field descriptions (shared) --- public static final String FIELD_TEMPLATE_ID = "Unique generated identifier of the entity template"; @@ -147,6 +149,15 @@ public class SwaggerDescription { public static final String FIELD_RELATION_REQUIRED = "Whether this relation is required"; public static final String FIELD_RELATION_TO_MANY = "Whether this relation can have multiple targets"; + public static final String FIELD_SEARCH_QUERY = "Free-text search string. When present, returns entities whose identifier, name, templateIdentifier, or any property value contains this string (case-insensitive). Can be combined with filter."; + public static final String FIELD_SEARCH_FILTER = "Root node of the search filter tree. May be omitted or null to return all entities."; + + public static final String FIELD_FILTER_CONNECTOR = "Logical connector for a group node. One of: AND, OR. Required for group nodes."; + public static final String FIELD_FILTER_CRITERIA = "Child filter nodes for a group node. Required for group nodes (must be non-empty)."; + public static final String FIELD_FILTER_FIELD = "Field to filter on for a criterion node. Required for leaf nodes. Examples: template, identifier, name, relation, property.language, relation.api-link, relation.api-link.identifier, relations_as_target.api-link.name"; + public static final String FIELD_FILTER_OPERATION = "Filter operation for a criterion node. One of: EQ, NEQ, CONTAINS, NOT_CONTAINS, STARTS_WITH, ENDS_WITH, GT, GTE, LT, LTE. Required for leaf nodes."; + public static final String FIELD_FILTER_VALUE = "Value to compare against for a criterion node. Required for leaf nodes."; + // --- Pagination and sorting parameter descriptions --- public static final String PARAM_PAGE_DESCRIPTION = "Page number for pagination. Defaults to 0."; public static final String PARAM_SIZE_DESCRIPTION = "Number of items per page. Defaults to 20."; @@ -160,7 +171,7 @@ public class SwaggerDescription { public static final String ENDPOINT_POST_SEARCH_SUMMARY = "Search entities"; public static final String ENDPOINT_POST_SEARCH_DESCRIPTION = """ Search for entities across all templates using nested filter queries. \ - Supports complex logical compositions (AND / OR / IN) of filter criteria on \ + Supports complex logical compositions (AND / OR) of filter criteria on \ template, identifier, name, properties, relations, and reverse relations."""; public static final String RESPONSE_SEARCH_SUCCESS = "Entities retrieved successfully"; public static final String RESPONSE_INVALID_SEARCH_QUERY = "Invalid search filter"; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntitySearchRequestDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntitySearchRequestDtoIn.java index 0734a9b1..945e83a7 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntitySearchRequestDtoIn.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntitySearchRequestDtoIn.java @@ -1,5 +1,7 @@ package com.decathlon.idp_core.infrastructure.adapters.api.dto.in; +import com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription; + import io.swagger.v3.oas.annotations.media.Schema; /// Request body for the `POST /api/v1/entities/search` endpoint. @@ -29,23 +31,23 @@ /// "sort": "identifier:asc" /// } /// ``` -@Schema(description = "Request body for the POST /api/v1/entities/search endpoint") +@Schema(description = SwaggerDescription.SCHEMA_ENTITY_SEARCH_REQUEST_IN) public record EntitySearchRequestDtoIn( - @Schema(description = "Free-text search string. When present, returns entities whose identifier, name, templateIdentifier, or any property value contains this string (case-insensitive). Can be combined with filter.", example = "checkout") String query, + @Schema(description = SwaggerDescription.FIELD_SEARCH_QUERY, example = "checkout") String query, - @Schema(description = "Root node of the search filter tree. May be omitted or null to return all entities.") FilterNodeDtoIn filter, + @Schema(description = SwaggerDescription.FIELD_SEARCH_FILTER) FilterNodeDtoIn filter, - @Schema(description = "Zero-based page index. Defaults to 0.", defaultValue = "0", example = "0") Integer page, + @Schema(description = SwaggerDescription.PARAM_PAGE_DESCRIPTION, defaultValue = "0", example = "0") Integer page, - @Schema(description = "Number of entities per page. Defaults to 20.", defaultValue = "20", example = "20") Integer size, + @Schema(description = SwaggerDescription.PARAM_SIZE_DESCRIPTION, defaultValue = "20", example = "20") Integer size, - @Schema(description = "Sort expression in the form field:asc|desc, e.g. identifier:asc.", example = "identifier:asc") String sort) { + @Schema(description = SwaggerDescription.PARAM_SORT_DESCRIPTION, example = "identifier:asc") String sort) { public EntitySearchRequestDtoIn { - if (size == null || size <= 0) { + if (size == null) { size = 20; } - if (page == null || page < 0) { + if (page == null) { page = 0; } if (query != null) { diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/FilterNodeDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/FilterNodeDtoIn.java index 4cb11a03..a2842890 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/FilterNodeDtoIn.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/FilterNodeDtoIn.java @@ -2,6 +2,8 @@ import java.util.List; +import com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription; + import io.swagger.v3.oas.annotations.media.Schema; /// A node in the search filter tree, used in the request body of @@ -12,16 +14,16 @@ /// A **criterion** must have a `field`, an `operation`, and a `value`. /// /// Both types share the same JSON object shape; unused fields should be omitted or set to null. -@Schema(description = "A node in the search filter tree. Either a logical group (connector + criteria) or a leaf criterion (field + operation + value).") +@Schema(description = SwaggerDescription.SCHEMA_FILTER_NODE) public record FilterNodeDtoIn( - @Schema(description = "Logical connector for a group node. One of: AND, OR. Required for group nodes.", example = "AND") String connector, + @Schema(description = SwaggerDescription.FIELD_FILTER_CONNECTOR, example = "AND") String connector, - @Schema(description = "Child filter nodes for a group node. Required for group nodes (must be non-empty).") List criteria, + @Schema(description = SwaggerDescription.FIELD_FILTER_CRITERIA) List criteria, - @Schema(description = "Field to filter on for a criterion node. Required for leaf nodes. Examples: template, identifier, name, relation, property.language, relation.api-link, relation.api-link.identifier, relations_as_target.api-link.name", example = "template") String field, + @Schema(description = SwaggerDescription.FIELD_FILTER_FIELD, example = "template") String field, - @Schema(description = "Filter operation for a criterion node. One of: EQ, NEQ, CONTAINS, NOT_CONTAINS, STARTS_WITH, ENDS_WITH, GT, GTE, LT, LTE. Required for leaf nodes.", example = "EQ") String operation, + @Schema(description = SwaggerDescription.FIELD_FILTER_OPERATION, example = "EQ") String operation, - @Schema(description = "Value to compare against for a criterion node. Required for leaf nodes.", example = "microservice") String value) { + @Schema(description = SwaggerDescription.FIELD_FILTER_VALUE, example = "microservice") String value) { } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/search/SearchFilterParserTest.java b/src/test/java/com/decathlon/idp_core/domain/service/search/SearchFilterParserTest.java index 63cb6016..73e42955 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/search/SearchFilterParserTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/search/SearchFilterParserTest.java @@ -5,10 +5,14 @@ import java.util.ArrayList; import java.util.List; +import java.util.stream.Stream; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import com.decathlon.idp_core.domain.constant.SearchConstraints; import com.decathlon.idp_core.domain.exception.search.InvalidSearchQueryException; @@ -140,22 +144,14 @@ void connector_caseInsensitive() { assertThat(group.connector()).isEqualTo(LogicalConnector.OR); } - @Test - @DisplayName("'IN' connector is rejected") - void inConnector_rejected() { + @ParameterizedTest + @MethodSource("invalidConnectors") + @DisplayName("invalid connectors are rejected") + void invalidConnector_rejected(String connector, String expectedMessage) { var child = new RawSearchFilterNode.Criterion("template", "EQ", "microservice"); - var raw = new RawSearchFilterNode.Group("IN", List.of(child)); + var raw = new RawSearchFilterNode.Group(connector, List.of(child)); assertThatThrownBy(() -> parser.parse(raw)).isInstanceOf(InvalidSearchQueryException.class) - .hasMessageContaining("IN"); - } - - @Test - @DisplayName("throws for missing connector in group") - void missingConnector_throws() { - var child = new RawSearchFilterNode.Criterion("template", "EQ", "microservice"); - var raw = new RawSearchFilterNode.Group(null, List.of(child)); - assertThatThrownBy(() -> parser.parse(raw)).isInstanceOf(InvalidSearchQueryException.class) - .hasMessageContaining("connector"); + .hasMessageContaining(expectedMessage); } @Test @@ -166,13 +162,9 @@ void emptyCriteria_throws() { .hasMessageContaining("criteria"); } - @Test - @DisplayName("throws for invalid connector string") - void invalidConnector_throws() { - var child = new RawSearchFilterNode.Criterion("template", "EQ", "microservice"); - var raw = new RawSearchFilterNode.Group("NAND", List.of(child)); - assertThatThrownBy(() -> parser.parse(raw)).isInstanceOf(InvalidSearchQueryException.class) - .hasMessageContaining("NAND"); + static Stream invalidConnectors() { + return Stream.of(Arguments.of("IN", "IN"), Arguments.of(null, "connector"), + Arguments.of("NAND", "NAND")); } } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/search/SearchFilterValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/search/SearchFilterValidationServiceTest.java index ab0f8184..bddd3061 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/search/SearchFilterValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/search/SearchFilterValidationServiceTest.java @@ -160,17 +160,15 @@ void relationsAsTargetNameField_accepted() { @Test @DisplayName("unknown field throws") void unknownField_throws() { - assertThatThrownBy( - () -> service.validate(criterion("badField", SearchOperator.EQ, "val"), null)) - .isInstanceOf(InvalidSearchQueryException.class).hasMessageContaining("badField"); + var crit = criterion("badField", SearchOperator.EQ, "val"); + assertValidationThrows(crit, "badField"); } @Test @DisplayName("'relations_as_target' without subfield throws") void relationsAsTarget_missingSubfield_throws() { - assertThatThrownBy(() -> service - .validate(criterion("relations_as_target.api-link", SearchOperator.EQ, "val"), null)) - .isInstanceOf(InvalidSearchQueryException.class); + var crit = criterion("relations_as_target.api-link", SearchOperator.EQ, "val"); + assertValidationThrows(crit); } @Test @@ -211,34 +209,29 @@ void gte_onProperty_decimalValue_accepted() { @Test @DisplayName("GT on 'template' field throws — numeric ops only on property.{name}") void gt_onTemplateField_throws() { - assertThatThrownBy( - () -> service.validate(criterion("template", SearchOperator.GT, "5"), null)) - .isInstanceOf(InvalidSearchQueryException.class).hasMessageContaining("GT"); + var crit = criterion("template", SearchOperator.GT, "5"); + assertValidationThrows(crit, "GT"); } @Test @DisplayName("LT on 'identifier' field throws — numeric ops only on property.{name}") void lt_onIdentifierField_throws() { - assertThatThrownBy( - () -> service.validate(criterion("identifier", SearchOperator.LT, "5"), null)) - .isInstanceOf(InvalidSearchQueryException.class).hasMessageContaining("LT"); + var crit = criterion("identifier", SearchOperator.LT, "5"); + assertValidationThrows(crit, "LT"); } @Test @DisplayName("GT on property.{name} with a non-numeric value throws") void gt_nonNumericValue_throws() { - assertThatThrownBy( - () -> service.validate(criterion("property.port", SearchOperator.GT, "abc"), null)) - .isInstanceOf(InvalidSearchQueryException.class).hasMessageContaining("abc") - .hasMessageContaining("GT"); + var crit = criterion("property.port", SearchOperator.GT, "abc"); + assertValidationThrows(crit, "abc", "GT"); } @Test @DisplayName("LTE on property.{name} with alphanumeric non-numeric value throws") void lte_nonNumericValue_throws() { - assertThatThrownBy( - () -> service.validate(criterion("property.size", SearchOperator.LTE, "10MB"), null)) - .isInstanceOf(InvalidSearchQueryException.class).hasMessageContaining("10MB"); + var crit = criterion("property.size", SearchOperator.LTE, "10MB"); + assertValidationThrows(crit, "10MB"); } } @@ -397,4 +390,13 @@ private static SearchFilterNode.Criterion criterion(String field, SearchOperator String value) { return new SearchFilterNode.Criterion(field, op, value); } + + private void assertValidationThrows(SearchFilterNode.Criterion crit, + String... expectedFragments) { + var assertion = assertThatThrownBy(() -> service.validate(crit, null)) + .isInstanceOf(InvalidSearchQueryException.class); + for (String frag : expectedFragments) { + assertion.hasMessageContaining(frag); + } + } } From 8f64bf840a3f162d224129060b49e8acd169547d Mon Sep 17 00:00:00 2001 From: evebrnd Date: Fri, 5 Jun 2026 11:02:58 +0200 Subject: [PATCH 7/7] refactor: add search domain package --- .../domain/constant/SearchConstraints.java | 6 +- .../{enums => search}/LogicalConnector.java | 2 +- .../domain/model/search/PaginatedResult.java | 7 ++ .../model/search/PaginationCriteria.java | 4 ++ .../RawSearchFilterNode.java | 2 +- .../{entity => search}/SearchFilterNode.java | 5 +- .../{enums => search}/SearchOperator.java | 2 +- .../domain/port/EntityRepositoryPort.java | 7 +- .../domain/service/entity/EntityService.java | 68 +++++++++---------- .../service/search/SearchFilterParser.java | 8 +-- .../search/SearchFilterValidationService.java | 4 +- .../api/controller/EntityController.java | 25 +++++-- .../api/mapper/entity/EntityDtoOutMapper.java | 44 ++---------- .../api/mapper/entity/SearchFilterMapper.java | 2 +- .../persistence/PostgresEntityAdapter.java | 41 ++++++++++- .../EntityFilterSpecification.java | 2 +- .../EntitySearchSpecification.java | 47 +++++-------- .../specification/JpaPredicateBuilder.java | 2 +- .../service/entity/EntityServiceTest.java | 57 ++++++++-------- .../search/SearchFilterParserTest.java | 8 +-- .../SearchFilterValidationServiceTest.java | 6 +- .../EntitySearchSpecificationTest.java | 6 +- 22 files changed, 182 insertions(+), 173 deletions(-) rename src/main/java/com/decathlon/idp_core/domain/model/{enums => search}/LogicalConnector.java (82%) create mode 100644 src/main/java/com/decathlon/idp_core/domain/model/search/PaginatedResult.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/model/search/PaginationCriteria.java rename src/main/java/com/decathlon/idp_core/domain/model/{entity => search}/RawSearchFilterNode.java (97%) rename src/main/java/com/decathlon/idp_core/domain/model/{entity => search}/SearchFilterNode.java (92%) rename src/main/java/com/decathlon/idp_core/domain/model/{enums => search}/SearchOperator.java (94%) diff --git a/src/main/java/com/decathlon/idp_core/domain/constant/SearchConstraints.java b/src/main/java/com/decathlon/idp_core/domain/constant/SearchConstraints.java index 49cd82cf..08723413 100644 --- a/src/main/java/com/decathlon/idp_core/domain/constant/SearchConstraints.java +++ b/src/main/java/com/decathlon/idp_core/domain/constant/SearchConstraints.java @@ -2,6 +2,8 @@ import java.util.Set; +import com.decathlon.idp_core.domain.model.search.SearchFilterNode; + import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -16,11 +18,11 @@ public final class SearchConstraints { public static final int MAX_QUERY_LENGTH = 255; /// Maximum nesting depth of a - /// [com.decathlon.idp_core.domain.model.entity.SearchFilterNode] tree. + /// [SearchFilterNode] tree. public static final int MAX_NESTING_DEPTH = 5; /// Maximum total number of criterion nodes across a - /// [com.decathlon.idp_core.domain.model.entity.SearchFilterNode] tree. + /// [SearchFilterNode] tree. public static final int MAX_TOTAL_CRITERIA = 50; /// Fields on which search results may be sorted. diff --git a/src/main/java/com/decathlon/idp_core/domain/model/enums/LogicalConnector.java b/src/main/java/com/decathlon/idp_core/domain/model/search/LogicalConnector.java similarity index 82% rename from src/main/java/com/decathlon/idp_core/domain/model/enums/LogicalConnector.java rename to src/main/java/com/decathlon/idp_core/domain/model/search/LogicalConnector.java index aa05674f..a7ca462c 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/enums/LogicalConnector.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/search/LogicalConnector.java @@ -1,4 +1,4 @@ -package com.decathlon.idp_core.domain.model.enums; +package com.decathlon.idp_core.domain.model.search; /// Logical connectors for combining multiple filter nodes in a search query. /// diff --git a/src/main/java/com/decathlon/idp_core/domain/model/search/PaginatedResult.java b/src/main/java/com/decathlon/idp_core/domain/model/search/PaginatedResult.java new file mode 100644 index 00000000..5cf80381 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/search/PaginatedResult.java @@ -0,0 +1,7 @@ +package com.decathlon.idp_core.domain.model.search; + +import java.util.List; + +public record PaginatedResult (List content, long totalElements, int totalPages, + int currentPage) { +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/search/PaginationCriteria.java b/src/main/java/com/decathlon/idp_core/domain/model/search/PaginationCriteria.java new file mode 100644 index 00000000..9c425a5a --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/search/PaginationCriteria.java @@ -0,0 +1,4 @@ +package com.decathlon.idp_core.domain.model.search; + +public record PaginationCriteria(int page, int size, String sort) { +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/RawSearchFilterNode.java b/src/main/java/com/decathlon/idp_core/domain/model/search/RawSearchFilterNode.java similarity index 97% rename from src/main/java/com/decathlon/idp_core/domain/model/entity/RawSearchFilterNode.java rename to src/main/java/com/decathlon/idp_core/domain/model/search/RawSearchFilterNode.java index e5858526..0ba01abe 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/RawSearchFilterNode.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/search/RawSearchFilterNode.java @@ -1,4 +1,4 @@ -package com.decathlon.idp_core.domain.model.entity; +package com.decathlon.idp_core.domain.model.search; import java.util.List; diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/SearchFilterNode.java b/src/main/java/com/decathlon/idp_core/domain/model/search/SearchFilterNode.java similarity index 92% rename from src/main/java/com/decathlon/idp_core/domain/model/entity/SearchFilterNode.java rename to src/main/java/com/decathlon/idp_core/domain/model/search/SearchFilterNode.java index 6819ec69..66b9c2e8 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/SearchFilterNode.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/search/SearchFilterNode.java @@ -1,10 +1,7 @@ -package com.decathlon.idp_core.domain.model.entity; +package com.decathlon.idp_core.domain.model.search; import java.util.List; -import com.decathlon.idp_core.domain.model.enums.LogicalConnector; -import com.decathlon.idp_core.domain.model.enums.SearchOperator; - /// A node in the search filter tree for entity search queries. /// /// **Business semantics:** A filter tree is composed of two types of nodes: diff --git a/src/main/java/com/decathlon/idp_core/domain/model/enums/SearchOperator.java b/src/main/java/com/decathlon/idp_core/domain/model/search/SearchOperator.java similarity index 94% rename from src/main/java/com/decathlon/idp_core/domain/model/enums/SearchOperator.java rename to src/main/java/com/decathlon/idp_core/domain/model/search/SearchOperator.java index 501baaec..cf5585c2 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/enums/SearchOperator.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/search/SearchOperator.java @@ -1,4 +1,4 @@ -package com.decathlon.idp_core.domain.model.enums; +package com.decathlon.idp_core.domain.model.search; /// Operators supported by the entity search query DSL. /// diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java index c5b61afa..bd250155 100644 --- a/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java @@ -11,7 +11,9 @@ import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntityFilter; import com.decathlon.idp_core.domain.model.entity.EntitySummary; -import com.decathlon.idp_core.domain.model.entity.SearchFilterNode; +import com.decathlon.idp_core.domain.model.search.PaginatedResult; +import com.decathlon.idp_core.domain.model.search.PaginationCriteria; +import com.decathlon.idp_core.domain.model.search.SearchFilterNode; /// Driven port defining the contract for [Entity] persistence operations. /// @@ -55,5 +57,6 @@ void deletePropertiesByTemplateIdentifierAndPropertyName(String templateIdentifi void deleteRelationsByTemplateIdentifierAndRelationName(String templateIdentifier, Collection relationNames); - Page search(SearchFilterNode filter, String query, Pageable pageable); + PaginatedResult search(SearchFilterNode filter, String query, + PaginationCriteria paginationCriteria); } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java index 9bd33401..1451e06b 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java @@ -2,14 +2,12 @@ import java.util.List; -import jakarta.transaction.Transactional; import jakarta.validation.Valid; import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.annotation.Validated; import com.decathlon.idp_core.domain.constant.SearchConstraints; @@ -22,8 +20,10 @@ import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntityFilter; import com.decathlon.idp_core.domain.model.entity.EntitySummary; -import com.decathlon.idp_core.domain.model.entity.SearchFilterNode; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; +import com.decathlon.idp_core.domain.model.search.PaginatedResult; +import com.decathlon.idp_core.domain.model.search.PaginationCriteria; +import com.decathlon.idp_core.domain.model.search.SearchFilterNode; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; @@ -52,7 +52,7 @@ public class EntityService { private final EntityValidationService entityValidationService; private final EntityTemplateValidationService entityTemplateValidationService; private final EntityTemplateService entityTemplateService; - private final EntityFilterDslParser entityQueryParserService; + private final EntityFilterDslParser entityFilterDslParser; private final SearchFilterValidationService searchFilterValidationService; /// Retrieves entities filtered by template with optional query filter. @@ -75,7 +75,7 @@ public Page getEntitiesByTemplateIdentifier(Pageable pageable, String te EntityTemplate template = entityTemplateService .getEntityTemplateByIdentifier(templateIdentifier); EntityFilter filter = entityFilter != null ? entityFilter : EntityFilter.empty(); - entityQueryParserService.validateFilterPropertyTypes(filter, template); + entityFilterDslParser.validateFilterPropertyTypes(filter, template); return entityRepository.findByTemplateIdentifierWithFilter(templateIdentifier, filter, pageable); } @@ -173,48 +173,45 @@ public Entity updateEntity(String templateIdentifier, String entityIdentifier, /// **Contract:** Executes a global entity search using the provided filter tree /// and optional text query. /// Not scoped to a single template; include a template criterion in the filter - /// to scope the result to a specific template. Validates and builds pagination - /// internally. + /// to scope the result to a specific template. Validates pagination criteria + /// and delegates to the repository for execution. /// /// @param filter root node of the search filter tree; an empty group returns /// all entities /// @param query optional free-text string searched across identifier, name, /// templateIdentifier, /// and all property values; null means no text restriction - /// @param page zero-based page index; must be 0 or greater - /// @param size number of items per page; must be between 1 and - /// [SearchConstraints#MAX_PAGE_SIZE] - /// @param sort optional sort expression in the form `field` or `field:asc|desc; - /// null or blank means default ordering + /// @param paginationCriteria contains page (zero-based), size (1 to + /// MAX_PAGE_SIZE), + /// and optional sort expression (`field` or `field:asc|desc`) /// @return paginated entities matching the filter and query - /// @throws InvalidSearchQueryException when page, size, or sort parameters are - /// invalid - @Transactional - public Page searchEntities(SearchFilterNode filter, String query, int page, int size, - String sort) { + /// @throws InvalidSearchQueryException when pagination parameters are invalid + @Transactional(readOnly = true) + public PaginatedResult searchEntities(SearchFilterNode filter, String query, + PaginationCriteria paginationCriteria) { searchFilterValidationService.validate(filter, query); - Pageable pageable = buildPageable(page, size, sort); - return entityRepository.search(filter, query, pageable); + validatePaginationCriteria(paginationCriteria); + return entityRepository.search(filter, query, paginationCriteria); } - private Pageable buildPageable(int page, int size, String sort) { - if (page < 0) { + private void validatePaginationCriteria(PaginationCriteria criteria) { + if (criteria.page() < 0) { throw new InvalidSearchQueryException(ValidationMessages.SEARCH_PAGE_INVALID); } - if (size <= 0) { + if (criteria.size() <= 0) { throw new InvalidSearchQueryException(ValidationMessages.SEARCH_SIZE_INVALID); } - if (size > SearchConstraints.MAX_PAGE_SIZE) { + if (criteria.size() > SearchConstraints.MAX_PAGE_SIZE) { throw new InvalidSearchQueryException( ValidationMessages.SEARCH_PAGE_SIZE_TOO_LARGE.formatted(SearchConstraints.MAX_PAGE_SIZE)); } - if (sort == null || sort.isBlank()) { - return PageRequest.of(page, size); + String sort = criteria.sort(); + if (sort != null && !sort.isBlank()) { + validateSortExpression(sort); } - return PageRequest.of(page, size, parseSortExpression(sort)); } - private Sort parseSortExpression(String sortExpression) { + private void validateSortExpression(String sortExpression) { String[] parts = sortExpression.split(":"); if (parts.length > 2) { throw new InvalidSearchQueryException( @@ -225,16 +222,13 @@ private Sort parseSortExpression(String sortExpression) { throw new InvalidSearchQueryException( ValidationMessages.SEARCH_INVALID_SORT_FIELD.formatted(property)); } - if (parts.length == 1) { - return Sort.by(Sort.Direction.ASC, property); + if (parts.length == 2) { + String direction = parts[1].trim().toLowerCase(); + if (!direction.equals("asc") && !direction.equals("desc")) { + throw new InvalidSearchQueryException( + ValidationMessages.SEARCH_INVALID_SORT_FORMAT.formatted(sortExpression)); + } } - String direction = parts[1].trim().toLowerCase(); - return switch (direction) { - case "asc" -> Sort.by(Sort.Direction.ASC, property); - case "desc" -> Sort.by(Sort.Direction.DESC, property); - default -> throw new InvalidSearchQueryException( - ValidationMessages.SEARCH_INVALID_SORT_FORMAT.formatted(sortExpression)); - }; } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/search/SearchFilterParser.java b/src/main/java/com/decathlon/idp_core/domain/service/search/SearchFilterParser.java index d201bf1c..03709122 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/search/SearchFilterParser.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/search/SearchFilterParser.java @@ -7,10 +7,10 @@ import com.decathlon.idp_core.domain.constant.SearchConstraints; import com.decathlon.idp_core.domain.constant.ValidationMessages; import com.decathlon.idp_core.domain.exception.search.InvalidSearchQueryException; -import com.decathlon.idp_core.domain.model.entity.RawSearchFilterNode; -import com.decathlon.idp_core.domain.model.entity.SearchFilterNode; -import com.decathlon.idp_core.domain.model.enums.LogicalConnector; -import com.decathlon.idp_core.domain.model.enums.SearchOperator; +import com.decathlon.idp_core.domain.model.search.LogicalConnector; +import com.decathlon.idp_core.domain.model.search.RawSearchFilterNode; +import com.decathlon.idp_core.domain.model.search.SearchFilterNode; +import com.decathlon.idp_core.domain.model.search.SearchOperator; /// Domain service that converts a [RawSearchFilterNode] tree into a validated [SearchFilterNode] tree. /// diff --git a/src/main/java/com/decathlon/idp_core/domain/service/search/SearchFilterValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/search/SearchFilterValidationService.java index 4281253c..fab2324c 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/search/SearchFilterValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/search/SearchFilterValidationService.java @@ -10,10 +10,10 @@ import com.decathlon.idp_core.domain.constant.SearchConstraints; import com.decathlon.idp_core.domain.constant.ValidationMessages; import com.decathlon.idp_core.domain.exception.search.InvalidSearchQueryException; -import com.decathlon.idp_core.domain.model.entity.SearchFilterNode; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.model.enums.PropertyType; -import com.decathlon.idp_core.domain.model.enums.SearchOperator; +import com.decathlon.idp_core.domain.model.search.SearchFilterNode; +import com.decathlon.idp_core.domain.model.search.SearchOperator; import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; import lombok.AllArgsConstructor; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java index 7e096bc2..546cdb59 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java @@ -40,10 +40,13 @@ import static org.springframework.http.HttpStatus.CREATED; import static org.springframework.http.HttpStatus.OK; +import java.util.List; + import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.validation.annotation.Validated; @@ -59,8 +62,10 @@ import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntityFilter; -import com.decathlon.idp_core.domain.model.entity.RawSearchFilterNode; -import com.decathlon.idp_core.domain.model.entity.SearchFilterNode; +import com.decathlon.idp_core.domain.model.search.PaginatedResult; +import com.decathlon.idp_core.domain.model.search.PaginationCriteria; +import com.decathlon.idp_core.domain.model.search.RawSearchFilterNode; +import com.decathlon.idp_core.domain.model.search.SearchFilterNode; import com.decathlon.idp_core.domain.service.entity.EntityService; import com.decathlon.idp_core.domain.service.filter.EntityFilterDslParser; import com.decathlon.idp_core.domain.service.search.SearchFilterParser; @@ -262,8 +267,18 @@ public EntityDtoOut updateEntity(@NotBlank @PathVariable String templateIdentifi public Page searchEntities(@RequestBody EntitySearchRequestDtoIn searchRequest) { RawSearchFilterNode rawFilter = searchFilterMapper.toRaw(searchRequest.filter()); SearchFilterNode filter = searchFilterParser.parse(rawFilter); - Page entities = entityService.searchEntities(filter, searchRequest.query(), - searchRequest.page(), searchRequest.size(), searchRequest.sort()); - return entityDtoOutMapper.fromEntitiesSearchPageToDtoPage(entities); + PaginationCriteria paginationCriteria = new PaginationCriteria(searchRequest.page(), + searchRequest.size(), searchRequest.sort()); + + PaginatedResult result = entityService.searchEntities(filter, searchRequest.query(), + paginationCriteria); + List dtoOutList = entityDtoOutMapper.toDtoList(result.content()); + return toPageResponse(dtoOutList, paginationCriteria, result.totalElements()); + } + + private Page toPageResponse(List content, PaginationCriteria criteria, + long totalElements) { + Pageable pageable = PageRequest.of(criteria.page(), criteria.size()); + return new PageImpl<>(content, pageable, totalElements); } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java index fa5f5ef4..53cff3c1 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java @@ -68,46 +68,12 @@ public EntityDtoOut fromEntity(Entity entity) { return fromEntityUsingEntityTemplate(entity, entityTemplate); } - /// Maps paginated search results to API DTOs with optimized bulk operations. + /// Maps a list of domain entities to API DTOs. /// - /// **Performance optimization:** Batches template resolution across all - /// templates - /// referenced in the page — unlike [#fromEntitiesPageToDtoPage] which is scoped - /// to a single template, this method handles multi-template result sets. - /// - /// @param entities paginated domain entities, possibly spanning several - /// templates - /// @return paginated API DTOs with complete relationship data - public Page fromEntitiesSearchPageToDtoPage(Page entities) { - if (entities.isEmpty()) { - return entities.map(entity -> entityDtoOutMapper(entity, Map.of(), Map.of())); - } - - Map pageEntitiesSummaries = buildRelatedEntitiesSummaryMapByPage( - entities); - Map> relationTargetOwnershipsMap = buildRelationsAsTargetSummaryMapByPage( - entities); - - Map templatesByIdentifier = entities.stream() - .map(Entity::templateIdentifier).filter(Objects::nonNull).distinct().collect(Collectors - .toMap(Function.identity(), entityTemplateService::getEntityTemplateByIdentifier)); - - return entities.map(entity -> { - EntityTemplate template = templatesByIdentifier.get(entity.templateIdentifier()); - if (template == null) { - return entityDtoOutMapper(entity, pageEntitiesSummaries, relationTargetOwnershipsMap); - } - return fromEntityUsingEntityTemplateAndSummaryMap(entity, template, pageEntitiesSummaries, - relationTargetOwnershipsMap); - }); - } - - private EntityDtoOut entityDtoOutMapper(Entity entity, Map summaries, - Map> relationsAsTargetMap) { - return EntityDtoOut.builder().templateIdentifier(entity.templateIdentifier()) - .name(entity.name()).identifier(entity.identifier()).properties(Collections.emptyMap()) - .relations(mapRelationsDto(entity, summaries)) - .relationsAsTarget(mapRelationsAsTargetDto(entity, relationsAsTargetMap)).build(); + /// @param entities domain entities to convert for API response + /// @return mapped DTO list + public List toDtoList(List entities) { + return entities.stream().map(this::fromEntity).toList(); } /// Maps paginated domain entities to API DTOs with optimized bulk operations. diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/SearchFilterMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/SearchFilterMapper.java index 4bec6da7..5c933106 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/SearchFilterMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/SearchFilterMapper.java @@ -4,7 +4,7 @@ import org.springframework.stereotype.Component; -import com.decathlon.idp_core.domain.model.entity.RawSearchFilterNode; +import com.decathlon.idp_core.domain.model.search.RawSearchFilterNode; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.FilterNodeDtoIn; /// Converts a [FilterNodeDtoIn] tree into a [RawSearchFilterNode] tree. diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java index e20758f8..90a6b242 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java @@ -6,14 +6,19 @@ import java.util.UUID; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Sort.Direction; import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Component; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntityFilter; import com.decathlon.idp_core.domain.model.entity.EntitySummary; -import com.decathlon.idp_core.domain.model.entity.SearchFilterNode; +import com.decathlon.idp_core.domain.model.search.PaginatedResult; +import com.decathlon.idp_core.domain.model.search.PaginationCriteria; +import com.decathlon.idp_core.domain.model.search.SearchFilterNode; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.EntityPersistenceMapper; import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.EntityJpaEntity; @@ -92,11 +97,41 @@ public void deleteRelationsByTemplateIdentifierAndRelationName(String templateId } @Override - public Page search(SearchFilterNode filter, String query, Pageable pageable) { + public PaginatedResult search(SearchFilterNode filter, String query, + PaginationCriteria paginationCriteria) { Specification spec = EntitySearchSpecification.of(filter); if (query != null && !query.isBlank()) { spec = spec.and(EntitySearchSpecification.globalTextSearch(query)); } - return jpaEntityRepository.findAll(spec, pageable).map(mapper::toDomain); + Pageable pageable = buildPageable(paginationCriteria); + Page page = jpaEntityRepository.findAll(spec, pageable); + + return new PaginatedResult<>(page.getContent().stream().map(mapper::toDomain).toList(), + page.getTotalElements(), page.getTotalPages(), page.getNumber()); + } + + private Pageable buildPageable(PaginationCriteria criteria) { + if (criteria.sort() == null || criteria.sort().isBlank()) { + return PageRequest.of(criteria.page(), criteria.size()); + } + + Sort sort = parseSortExpression(criteria.sort()); + return PageRequest.of(criteria.page(), criteria.size(), sort); + } + + private Sort parseSortExpression(String sortExpression) { + String[] parts = sortExpression.split(":"); + String property = parts[0].trim(); + + if (parts.length == 1) { + return Sort.by(Direction.ASC, property); + } + + String direction = parts[1].trim().toLowerCase(); + return switch (direction) { + case "asc" -> Sort.by(Direction.ASC, property); + case "desc" -> Sort.by(Direction.DESC, property); + default -> Sort.by(Direction.ASC, property); + }; } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntityFilterSpecification.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntityFilterSpecification.java index 411ce5ac..24b00341 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntityFilterSpecification.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntityFilterSpecification.java @@ -14,7 +14,7 @@ import com.decathlon.idp_core.domain.model.entity.EntityFilter; import com.decathlon.idp_core.domain.model.entity.FilterCriterion; import com.decathlon.idp_core.domain.model.enums.FilterOperator; -import com.decathlon.idp_core.domain.model.enums.SearchOperator; +import com.decathlon.idp_core.domain.model.search.SearchOperator; import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.EntityJpaEntity; import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.PropertyJpaEntity; import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.RelationJpaEntity; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecification.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecification.java index 44cd275b..f4d927f2 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecification.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecification.java @@ -12,8 +12,8 @@ import org.hibernate.query.criteria.HibernateCriteriaBuilder; import org.springframework.data.jpa.domain.Specification; -import com.decathlon.idp_core.domain.model.entity.SearchFilterNode; -import com.decathlon.idp_core.domain.model.enums.SearchOperator; +import com.decathlon.idp_core.domain.model.search.SearchFilterNode; +import com.decathlon.idp_core.domain.model.search.SearchOperator; import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.EntityJpaEntity; import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.RelationJpaEntity; @@ -41,14 +41,15 @@ /// /// **Security:** LIKE-based operators ([SearchOperator#CONTAINS], [SearchOperator#NOT_CONTAINS], /// [SearchOperator#STARTS_WITH], [SearchOperator#ENDS_WITH]) use PostgreSQL `ILIKE` for -/// case-insensitive matching, allowing GIN trigram indexes (V3_5) to be leveraged. +/// case-insensitive matching, allowing GIN trigram indexes to be leveraged. /// SQL wildcards (`%` and `_`) in user-supplied values are escaped to prevent unintended -/// pattern matching. EQ and NEQ use `LOWER()` with functional btree indexes (V3_4). +/// pattern matching. EQ and NEQ use `LOWER()` with functional btree indexes. @NoArgsConstructor(access = AccessLevel.PRIVATE) public final class EntitySearchSpecification { private static final String TEMPLATE_IDENTIFIER = "templateIdentifier"; private static final String IDENTIFIER = "identifier"; + private static final String TEMPLATE = "template"; private static final String NAME = "name"; private static final String RELATION = "relation"; private static final String RELATIONS = "relations"; @@ -134,33 +135,17 @@ private static Specification buildGroup(SearchFilterNode.Group private static Specification buildCriterion(SearchFilterNode.Criterion c) { var field = c.field(); - if ("template".equals(field)) { - return (root, query, cb) -> buildPredicate(cb, root.get(TEMPLATE_IDENTIFIER), c.operation(), - c.value()); - } - if (IDENTIFIER.equals(field)) { - return (root, query, cb) -> buildPredicate(cb, root.get(IDENTIFIER), c.operation(), - c.value()); - } - if (NAME.equals(field)) { - return (root, query, cb) -> buildPredicate(cb, root.get(NAME), c.operation(), c.value()); - } - if (field.startsWith(PROPERTY_PREFIX)) { - return propertySpec(c, field.substring(PROPERTY_PREFIX.length())); - } - if (field.startsWith(RELATIONS_AS_TARGET_PREFIX)) { - return relationsAsTargetSpec(c, field.substring(RELATIONS_AS_TARGET_PREFIX.length())); - } - if (RELATIONS_AS_TARGET.equals(field)) { - return relationsAsTargetNameSpec(c); - } - if (RELATION.equals(field)) { - return relationNameSpec(c); - } - if (field.startsWith(RELATION_PREFIX)) { - return relationSpec(c, field.substring(RELATION_PREFIX.length())); - } - throw new IllegalArgumentException("Unknown search field: " + field); + return switch (field) { + case TEMPLATE -> (root, query, cb) -> buildPredicate(cb, root.get(TEMPLATE_IDENTIFIER), c.operation(), c.value()); + case String f when f.equals(IDENTIFIER) -> (root, query, cb) -> buildPredicate(cb, root.get(IDENTIFIER), c.operation(), c.value()); + case String f when f.equals(NAME) -> (root, query, cb) -> buildPredicate(cb, root.get(NAME), c.operation(), c.value()); + case String f when f.startsWith(PROPERTY_PREFIX) -> propertySpec(c, f.substring(PROPERTY_PREFIX.length())); + case String f when f.startsWith(RELATIONS_AS_TARGET_PREFIX) -> relationsAsTargetSpec(c, f.substring(RELATIONS_AS_TARGET_PREFIX.length())); + case String f when f.equals(RELATIONS_AS_TARGET) -> relationsAsTargetNameSpec(c); + case String f when f.equals(RELATION) -> relationNameSpec(c); + case String f when f.startsWith(RELATION_PREFIX) -> relationSpec(c, f.substring(RELATION_PREFIX.length())); + default -> throw new IllegalArgumentException("Unknown search field: " + field); + }; } // --- Property spec --- diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/JpaPredicateBuilder.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/JpaPredicateBuilder.java index a77c5f9c..e47a737d 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/JpaPredicateBuilder.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/JpaPredicateBuilder.java @@ -8,7 +8,7 @@ import org.hibernate.query.criteria.HibernateCriteriaBuilder; -import com.decathlon.idp_core.domain.model.enums.SearchOperator; +import com.decathlon.idp_core.domain.model.search.SearchOperator; import lombok.AccessLevel; import lombok.NoArgsConstructor; diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java index 1a3b3d94..c90ea8e5 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java @@ -34,9 +34,11 @@ import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntityFilter; import com.decathlon.idp_core.domain.model.entity.EntitySummary; -import com.decathlon.idp_core.domain.model.entity.SearchFilterNode; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; -import com.decathlon.idp_core.domain.model.enums.LogicalConnector; +import com.decathlon.idp_core.domain.model.search.LogicalConnector; +import com.decathlon.idp_core.domain.model.search.PaginatedResult; +import com.decathlon.idp_core.domain.model.search.PaginationCriteria; +import com.decathlon.idp_core.domain.model.search.SearchFilterNode; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; @@ -269,16 +271,24 @@ private SearchFilterNode emptyFilter() { return new SearchFilterNode.Group(LogicalConnector.AND, List.of()); } + private void assertSearchThrows(PaginationCriteria paginationCriteria) { + var filter = emptyFilter(); + assertThrows(InvalidSearchQueryException.class, + () -> entityService.searchEntities(filter, null, paginationCriteria)); + } + @Test @DisplayName("Should search entities with valid parameters") void shouldSearchEntitiesWithValidParameters() { var filter = emptyFilter(); - var page = new PageImpl<>(List.of(entity("tmpl", "ent-a", "Entity A"))); - when(entityRepository.search(filter, "api", Pageable.ofSize(20))).thenReturn(page); + var entity = entity("tmpl", "ent-a", "Entity A"); + var paginatedResult = new PaginatedResult<>(List.of(entity), 1L, 1, 0); + when(entityRepository.search(filter, "api", new PaginationCriteria(0, 20, null))) + .thenReturn(paginatedResult); - var result = entityService.searchEntities(filter, "api", 0, 20, null); + var result = entityService.searchEntities(filter, "api", new PaginationCriteria(0, 20, null)); - assertSame(page, result); + assertEquals(paginatedResult, result); verify(searchFilterValidationService).validate(filter, "api"); } @@ -286,60 +296,51 @@ void shouldSearchEntitiesWithValidParameters() { @DisplayName("Should search entities with valid sort") void shouldSearchEntitiesWithValidSort() { var filter = emptyFilter(); - var page = new PageImpl<>(List.of(entity("tmpl", "ent-a", "Entity A"))); + var entity = entity("tmpl", "ent-a", "Entity A"); + var paginatedResult = new PaginatedResult<>(List.of(entity), 1L, 1, 0); when(entityRepository.search(org.mockito.ArgumentMatchers.any(), - org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any())).thenReturn(page); + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any())) + .thenReturn(paginatedResult); - var result = entityService.searchEntities(filter, null, 0, 10, "identifier:asc"); + var result = entityService.searchEntities(filter, null, + new PaginationCriteria(0, 10, "identifier:asc")); - assertSame(page, result); + assertEquals(paginatedResult, result); } @Test @DisplayName("Should reject page size exceeding maximum") void shouldRejectPageSizeExceedingMaximum() { - var filter = emptyFilter(); - assertThrows(InvalidSearchQueryException.class, () -> entityService.searchEntities(filter, null, - 0, SearchConstraints.MAX_PAGE_SIZE + 1, null)); + assertSearchThrows(new PaginationCriteria(0, SearchConstraints.MAX_PAGE_SIZE + 1, null)); } @Test @DisplayName("Should reject negative page index") void shouldRejectNegativePageIndex() { - var filter = emptyFilter(); - assertThrows(InvalidSearchQueryException.class, - () -> entityService.searchEntities(filter, null, -1, 20, null)); + assertSearchThrows(new PaginationCriteria(-1, 20, null)); } @Test @DisplayName("Should reject non-positive page size") void shouldRejectNonPositivePageSize() { - var filter = emptyFilter(); - assertThrows(InvalidSearchQueryException.class, - () -> entityService.searchEntities(filter, null, 0, 0, null)); + assertSearchThrows(new PaginationCriteria(0, 0, null)); } @Test @DisplayName("Should reject invalid sort field") void shouldRejectInvalidSortField() { - var filter = emptyFilter(); - assertThrows(InvalidSearchQueryException.class, - () -> entityService.searchEntities(filter, null, 0, 20, "badField:asc")); + assertSearchThrows(new PaginationCriteria(0, 20, "badField:asc")); } @Test @DisplayName("Should reject invalid sort direction") void shouldRejectInvalidSortDirection() { - var filter = emptyFilter(); - assertThrows(InvalidSearchQueryException.class, - () -> entityService.searchEntities(filter, null, 0, 20, "identifier:zzz")); + assertSearchThrows(new PaginationCriteria(0, 20, "identifier:zzz")); } @Test @DisplayName("Should reject extra sort expression segments") void shouldRejectExtraSortSegments() { - var filter = emptyFilter(); - assertThrows(InvalidSearchQueryException.class, - () -> entityService.searchEntities(filter, null, 0, 20, "identifier:asc:extra")); + assertSearchThrows(new PaginationCriteria(0, 20, "identifier:asc:extra")); } } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/search/SearchFilterParserTest.java b/src/test/java/com/decathlon/idp_core/domain/service/search/SearchFilterParserTest.java index 73e42955..00480390 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/search/SearchFilterParserTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/search/SearchFilterParserTest.java @@ -16,10 +16,10 @@ import com.decathlon.idp_core.domain.constant.SearchConstraints; import com.decathlon.idp_core.domain.exception.search.InvalidSearchQueryException; -import com.decathlon.idp_core.domain.model.entity.RawSearchFilterNode; -import com.decathlon.idp_core.domain.model.entity.SearchFilterNode; -import com.decathlon.idp_core.domain.model.enums.LogicalConnector; -import com.decathlon.idp_core.domain.model.enums.SearchOperator; +import com.decathlon.idp_core.domain.model.search.LogicalConnector; +import com.decathlon.idp_core.domain.model.search.RawSearchFilterNode; +import com.decathlon.idp_core.domain.model.search.SearchFilterNode; +import com.decathlon.idp_core.domain.model.search.SearchOperator; /// Unit tests for [SearchFilterParser]. /// diff --git a/src/test/java/com/decathlon/idp_core/domain/service/search/SearchFilterValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/search/SearchFilterValidationServiceTest.java index bddd3061..3cd689b6 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/search/SearchFilterValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/search/SearchFilterValidationServiceTest.java @@ -15,12 +15,12 @@ import com.decathlon.idp_core.domain.constant.SearchConstraints; import com.decathlon.idp_core.domain.exception.search.InvalidSearchQueryException; -import com.decathlon.idp_core.domain.model.entity.SearchFilterNode; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; -import com.decathlon.idp_core.domain.model.enums.LogicalConnector; import com.decathlon.idp_core.domain.model.enums.PropertyType; -import com.decathlon.idp_core.domain.model.enums.SearchOperator; +import com.decathlon.idp_core.domain.model.search.LogicalConnector; +import com.decathlon.idp_core.domain.model.search.SearchFilterNode; +import com.decathlon.idp_core.domain.model.search.SearchOperator; import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; /// Unit tests for [SearchFilterValidationService]. diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecificationTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecificationTest.java index e15af22a..104caeec 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecificationTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecificationTest.java @@ -10,9 +10,9 @@ import org.junit.jupiter.api.Test; import org.springframework.data.jpa.domain.Specification; -import com.decathlon.idp_core.domain.model.entity.SearchFilterNode; -import com.decathlon.idp_core.domain.model.enums.LogicalConnector; -import com.decathlon.idp_core.domain.model.enums.SearchOperator; +import com.decathlon.idp_core.domain.model.search.LogicalConnector; +import com.decathlon.idp_core.domain.model.search.SearchFilterNode; +import com.decathlon.idp_core.domain.model.search.SearchOperator; import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.EntityJpaEntity; /// Unit tests for [EntitySearchSpecification].