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/.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/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/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/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..42fd1559 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/constant/FilterConstraints.java @@ -0,0 +1,16 @@ +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..08723413 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/constant/SearchConstraints.java @@ -0,0 +1,31 @@ +package com.decathlon.idp_core.domain.constant; + +import java.util.Set; + +import com.decathlon.idp_core.domain.model.search.SearchFilterNode; + +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 + /// [SearchFilterNode] tree. + public static final int MAX_NESTING_DEPTH = 5; + + /// Maximum total number of criterion nodes across a + /// [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 83d4a497..e32977de 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 @@ -92,4 +92,26 @@ public static String minMaxConstraintViolated(String constraint) { 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..5cffcc16 --- /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..a0723bfd --- /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/search/LogicalConnector.java b/src/main/java/com/decathlon/idp_core/domain/model/search/LogicalConnector.java new file mode 100644 index 00000000..a7ca462c --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/search/LogicalConnector.java @@ -0,0 +1,10 @@ +package com.decathlon.idp_core.domain.model.search; + +/// 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/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/search/RawSearchFilterNode.java b/src/main/java/com/decathlon/idp_core/domain/model/search/RawSearchFilterNode.java new file mode 100644 index 00000000..0ba01abe --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/search/RawSearchFilterNode.java @@ -0,0 +1,39 @@ +package com.decathlon.idp_core.domain.model.search; + +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/search/SearchFilterNode.java b/src/main/java/com/decathlon/idp_core/domain/model/search/SearchFilterNode.java new file mode 100644 index 00000000..66b9c2e8 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/search/SearchFilterNode.java @@ -0,0 +1,50 @@ +package com.decathlon.idp_core.domain.model.search; + +import java.util.List; + +/// 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). 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/search/SearchOperator.java b/src/main/java/com/decathlon/idp_core/domain/model/search/SearchOperator.java new file mode 100644 index 00000000..cf5585c2 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/search/SearchOperator.java @@ -0,0 +1,18 @@ +package com.decathlon.idp_core.domain.model.search; + +/// 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 49fd2b52..ac4fc633 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,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.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. /// @@ -24,6 +27,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. @@ -53,6 +57,9 @@ void deletePropertiesByTemplateIdentifierAndPropertyName(String templateIdentifi void deleteRelationsByTemplateIdentifierAndRelationName(String templateIdentifier, Collection relationNames); + PaginatedResult search(SearchFilterNode filter, String query, + PaginationCriteria paginationCriteria); + List findEntitiesRelated(String targetIdentifier); void deleteByTemplateIdentifierAndIdentifier(String templateIdentifier, String entityIdentifier); 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 c494dd8d..5647fc66 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 @@ -7,29 +7,36 @@ import java.util.Map; import java.util.Objects; -import jakarta.transaction.Transactional; import jakarta.validation.Valid; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; 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; +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.EntityDeletionBlockedException; 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.Relation; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.model.entity_template.RelationDefinition; +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.EntityQueryParserService; 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; @@ -53,7 +60,8 @@ public class EntityService { private final EntityValidationService entityValidationService; private final EntityTemplateValidationService entityTemplateValidationService; private final EntityTemplateService entityTemplateService; - private final EntityQueryParserService entityQueryParserService; + private final EntityFilterDslParser entityFilterDslParser; + private final SearchFilterValidationService searchFilterValidationService; /// Retrieves entities filtered by template with optional query filter. /// @@ -75,7 +83,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); } @@ -384,4 +392,69 @@ private Entity retrieveEntity(final String templateIdentifier, final String enti .findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); } + + /// 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 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 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 pagination parameters are invalid + @Transactional(readOnly = true) + public PaginatedResult searchEntities(SearchFilterNode filter, String query, + PaginationCriteria paginationCriteria) { + searchFilterValidationService.validate(filter, query); + validatePaginationCriteria(paginationCriteria); + return entityRepository.search(filter, query, paginationCriteria); + } + + private void validatePaginationCriteria(PaginationCriteria criteria) { + if (criteria.page() < 0) { + throw new InvalidSearchQueryException(ValidationMessages.SEARCH_PAGE_INVALID); + } + if (criteria.size() <= 0) { + throw new InvalidSearchQueryException(ValidationMessages.SEARCH_SIZE_INVALID); + } + if (criteria.size() > SearchConstraints.MAX_PAGE_SIZE) { + throw new InvalidSearchQueryException( + ValidationMessages.SEARCH_PAGE_SIZE_TOO_LARGE.formatted(SearchConstraints.MAX_PAGE_SIZE)); + } + String sort = criteria.sort(); + if (sort != null && !sort.isBlank()) { + validateSortExpression(sort); + } + } + + private void validateSortExpression(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 == 2) { + String direction = parts[1].trim().toLowerCase(); + if (!direction.equals("asc") && !direction.equals("desc")) { + throw new InvalidSearchQueryException( + ValidationMessages.SEARCH_INVALID_SORT_FORMAT.formatted(sortExpression)); + } + } + } + } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/EntityQueryParserService.java b/src/main/java/com/decathlon/idp_core/domain/service/filter/EntityFilterDslParser.java similarity index 84% rename from src/main/java/com/decathlon/idp_core/domain/service/EntityQueryParserService.java rename to src/main/java/com/decathlon/idp_core/domain/service/filter/EntityFilterDslParser.java index 59ec1717..8d1bddfd 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/EntityQueryParserService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/filter/EntityFilterDslParser.java @@ -1,4 +1,4 @@ -package com.decathlon.idp_core.domain.service; +package com.decathlon.idp_core.domain.service.filter; import java.util.HashSet; import java.util.List; @@ -8,8 +8,9 @@ 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.InvalidQueryDslException; +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; @@ -35,12 +36,12 @@ /// (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 +/// - 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 EntityQueryParserService { +public class EntityFilterDslParser { private static final String RELATION = "relation"; private static final String RELATIONS_AS_TARGET = "relations_as_target"; @@ -54,15 +55,12 @@ public class EntityQueryParserService { 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 + /// @throws InvalidFilterDslException when the query string is malformed or /// exceeds safety limits public EntityFilter parse(String query) { if (query == null || query.isBlank()) { @@ -72,9 +70,9 @@ public EntityFilter parse(String query) { 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)); + if (criteria.size() > FilterConstraints.MAX_CRITERIA_COUNT) { + throw new InvalidFilterDslException(ValidationMessages.FILTER_TOO_MANY_CRITERIA + .formatted(FilterConstraints.MAX_CRITERIA_COUNT)); } validateNoDuplicates(criteria); @@ -84,7 +82,7 @@ public EntityFilter parse(String query) { private FilterCriterion parseCriterion(String token) { int operatorIndex = findOperatorIndex(token) - .orElseThrow(() -> new InvalidQueryDslException(ValidationMessages.FILTER_INVALID_FORMAT)); + .orElseThrow(() -> new InvalidFilterDslException(ValidationMessages.FILTER_INVALID_FORMAT)); var rawKey = token.substring(0, operatorIndex); var operatorChar = token.charAt(operatorIndex); @@ -116,7 +114,7 @@ private FilterOperator toOperator(char c) { case ':' -> FilterOperator.CONTAINS; case '<' -> FilterOperator.LESS_THAN; case '>' -> FilterOperator.GREATER_THAN; - default -> throw new InvalidQueryDslException("Unknown operator character: " + c); + default -> throw new InvalidFilterDslException("Unknown operator character: " + c); }; } @@ -152,7 +150,7 @@ private FilterCriterion buildCriterion(String rawKey, FilterOperator operator, S } if (!VALID_ATTRIBUTE_NAMES.contains(rawKey)) { - throw new InvalidQueryDslException( + throw new InvalidFilterDslException( "Unknown attribute '%s' in filter criterion '%s'. Valid attributes: %s".formatted(rawKey, token, VALID_ATTRIBUTE_NAMES)); } @@ -163,7 +161,7 @@ private FilterCriterion buildRelationsAsTargetCriterion(String relationPart, FilterOperator operator, String value, String token) { int dotIndex = relationPart.indexOf('.'); if (dotIndex <= 0) { - throw new InvalidQueryDslException( + throw new InvalidFilterDslException( "Invalid filter criterion '%s': relations_as_target requires the form 'relations_as_target..'" .formatted(token)); } @@ -199,7 +197,7 @@ private void validateNoDuplicates(List criteria) { for (FilterCriterion criterion : criteria) { String dedupeKey = criterion.keyType().name() + ":" + criterion.key(); if (!seen.add(dedupeKey)) { - throw new InvalidQueryDslException(ValidationMessages.FILTER_DUPLICATE_CRITERION); + throw new InvalidFilterDslException(ValidationMessages.FILTER_DUPLICATE_CRITERION); } } } @@ -209,7 +207,7 @@ private void validateOperatorCompatibility(FilterKeyType keyType, FilterOperator if (COMPARISON_INCOMPATIBLE_TYPES.contains(keyType) && (operator == FilterOperator.LESS_THAN || operator == FilterOperator.GREATER_THAN)) { var opSymbol = operator == FilterOperator.LESS_THAN ? "<" : ">"; - throw new InvalidQueryDslException( + throw new InvalidFilterDslException( ValidationMessages.FILTER_TYPE_MISMATCH.formatted(opSymbol, rawKey)); } } @@ -223,7 +221,7 @@ private void validateOperatorCompatibility(FilterKeyType keyType, FilterOperator /// /// @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 + /// @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) @@ -234,7 +232,7 @@ public void validateFilterPropertyTypes(EntityFilter filter, EntityTemplate temp .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( + throw new InvalidFilterDslException( ValidationMessages.FILTER_PROPERTY_TYPE_NOT_NUMERIC.formatted(opSymbol, c.key())); } }); @@ -242,41 +240,41 @@ public void validateFilterPropertyTypes(EntityFilter filter, EntityTemplate temp private void validateKey(String key, String token) { if (key.isBlank()) { - throw new InvalidQueryDslException( + 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 InvalidQueryDslException( + 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 InvalidQueryDslException( + 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 InvalidQueryDslException( + 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() > MAX_KEY_VALUE_LENGTH) { - throw new InvalidQueryDslException( - ValidationMessages.FILTER_KEY_TOO_LONG.formatted(MAX_KEY_VALUE_LENGTH, 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() > MAX_KEY_VALUE_LENGTH) { - throw new InvalidQueryDslException( - ValidationMessages.FILTER_VALUE_TOO_LONG.formatted(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..03709122 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/search/SearchFilterParser.java @@ -0,0 +1,113 @@ +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.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. +/// +/// **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..fab2324c --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/search/SearchFilterValidationService.java @@ -0,0 +1,189 @@ +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_template.EntityTemplate; +import com.decathlon.idp_core.domain.model.enums.PropertyType; +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; + +/// 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 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 static final Set RELATION_PROPERTY_NAMES = Set.of("identifier", "name"); + + 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()) { + validateRelationField(field); + 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)); + } + 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) { + 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 71978080..117d88a8 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 @@ -111,6 +111,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"; @@ -151,6 +153,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."; @@ -159,4 +170,14 @@ public class SwaggerDescription { 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) 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 d9910e92..0eb19c6a 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_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_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; @@ -34,6 +36,8 @@ 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 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; @@ -42,10 +46,13 @@ import static org.springframework.http.HttpStatus.NO_CONTENT; 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; @@ -62,16 +69,23 @@ 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.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; 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.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; 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; @@ -100,7 +114,9 @@ public class EntityController { private final EntityService entityService; private final EntityDtoOutMapper entityDtoOutMapper; private final EntityDtoInMapper entityDtoInMapper; - private final EntityQueryParserService entityQueryParserService; + private final EntityFilterDslParser entityFilterDslParser; + private final SearchFilterMapper searchFilterMapper; + private final SearchFilterParser searchFilterParser; /// Returns paginated entities filtered by template with HTTP pagination /// support. @@ -133,7 +149,7 @@ 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); + EntityFilter filter = entityFilterDslParser.parse(q); Page entities = entityService.getEntitiesByTemplateIdentifier(pageable, templateIdentifier, filter); return entityDtoOutMapper.fromEntitiesPageToDtoPage(entities, templateIdentifier); @@ -265,4 +281,41 @@ public void deleteEntity(@NotBlank @PathVariable String templateIdentifier, @NotBlank @PathVariable String entityIdentifier) { entityService.deleteEntity(templateIdentifier, entityIdentifier); } + + /// 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); + PaginationCriteria paginationCriteria = new PaginationCriteria(searchRequest.page(), + searchRequest.size(), searchRequest.sort()); + + PaginatedResult result = entityService.searchEntities(filter, searchRequest.query(), + paginationCriteria); + Page page = toPageResponse(result.content(), paginationCriteria, + result.totalElements()); + return entityDtoOutMapper.fromEntitiesSearchPageToDtoPage(page); + } + + 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/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..945e83a7 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntitySearchRequestDtoIn.java @@ -0,0 +1,60 @@ +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. +/// +/// Supports two complementary search modes that can be combined: +/// - `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 +/// ``` +/// { "query": "checkout", "page": 0, "size": 20 } +/// ``` +/// +/// ### Structured filter example +/// ``` +/// { +/// "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 = SwaggerDescription.SCHEMA_ENTITY_SEARCH_REQUEST_IN) +public record EntitySearchRequestDtoIn( + + @Schema(description = SwaggerDescription.FIELD_SEARCH_QUERY, example = "checkout") String query, + + @Schema(description = SwaggerDescription.FIELD_SEARCH_FILTER) FilterNodeDtoIn filter, + + @Schema(description = SwaggerDescription.PARAM_PAGE_DESCRIPTION, defaultValue = "0", example = "0") Integer page, + + @Schema(description = SwaggerDescription.PARAM_SIZE_DESCRIPTION, defaultValue = "20", example = "20") Integer size, + + @Schema(description = SwaggerDescription.PARAM_SORT_DESCRIPTION, example = "identifier:asc") String sort) { + public EntitySearchRequestDtoIn { + if (size == null) { + size = 20; + } + if (page == null) { + 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..a2842890 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/FilterNodeDtoIn.java @@ -0,0 +1,29 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.in; + +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 +/// `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 = SwaggerDescription.SCHEMA_FILTER_NODE) +public record FilterNodeDtoIn( + + @Schema(description = SwaggerDescription.FIELD_FILTER_CONNECTOR, example = "AND") String connector, + + @Schema(description = SwaggerDescription.FIELD_FILTER_CRITERIA) List criteria, + + @Schema(description = SwaggerDescription.FIELD_FILTER_FIELD, example = "template") String field, + + @Schema(description = SwaggerDescription.FIELD_FILTER_OPERATION, example = "EQ") String operation, + + @Schema(description = SwaggerDescription.FIELD_FILTER_VALUE, 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 8f11d981..e02c4858 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 @@ -20,7 +20,6 @@ import org.springframework.web.method.annotation.HandlerMethodValidationException; import org.springframework.web.servlet.NoHandlerFoundException; -import com.decathlon.idp_core.domain.exception.InvalidQueryDslException; import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; import com.decathlon.idp_core.domain.exception.entity.EntityDeletionBlockedException; import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; @@ -36,6 +35,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 +77,31 @@ 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 + /// **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/EntityDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java index 80957ce7..2928d7a8 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 @@ -27,6 +27,7 @@ import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntitySummaryDto; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; /// Adapter mapper for converting domain [Entity] objects to API DTOs. /// @@ -46,6 +47,7 @@ /// - Integrates with Jackson for JSON serialization patterns /// - Stateless design ensures thread safety in web containers @Component +@Slf4j @RequiredArgsConstructor public class EntityDtoOutMapper { @@ -304,4 +306,46 @@ private Map buildEntitiesSummariesMap(List tar es -> new EntitySummaryDto(es.identifier(), es.name()))); } + /// 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(); + } + } 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..5c933106 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/SearchFilterMapper.java @@ -0,0 +1,41 @@ +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.search.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 5828c7f3..262a9060 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,18 +6,25 @@ 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.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; 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 +68,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); } @@ -90,6 +97,44 @@ public void deleteRelationsByTemplateIdentifierAndRelationName(String templateId } @Override + 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)); + } + 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); + }; + } + public List findEntitiesRelated(String targetIdentifier) { return jpaEntityRepository.findEntitiesRelated(targetIdentifier).stream().map(mapper::toDomain) .toList(); 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/EntityFilterSpecification.java similarity index 90% rename from src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySpecification.java rename to src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntityFilterSpecification.java index 8423e9e2..24b00341 100644 --- 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/EntityFilterSpecification.java @@ -14,6 +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.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; @@ -39,9 +40,8 @@ /// **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 { +public final class EntityFilterSpecification { - 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"; @@ -56,7 +56,7 @@ public final class EntitySpecification { /// @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); + var criteriaSpecs = filter.criteria().stream().map(EntityFilterSpecification::fromCriterion); return Stream.concat(Stream.of(hasTemplateIdentifier(templateIdentifier)), criteriaSpecs) .reduce(Specification::and).orElse(hasTemplateIdentifier(templateIdentifier)); @@ -139,15 +139,14 @@ private static Specification relationPropertySpec(FilterCriteri 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); + 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); }; } @@ -206,12 +205,4 @@ private static Specification relationsAsTargetPropertySpec( }; } - /// 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/EntitySearchSpecification.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecification.java new file mode 100644 index 00000000..f4d927f2 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecification.java @@ -0,0 +1,294 @@ +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; + +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; + +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 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. +@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"; + 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(); + 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 --- + + 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 new file mode 100644 index 00000000..e47a737d --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/JpaPredicateBuilder.java @@ -0,0 +1,114 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.specification; + +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.search.SearchOperator; + +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) { + 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_6__add_search_performance_indexes.sql b/src/main/resources/db/migration/V3_6__add_search_performance_indexes.sql new file mode 100644 index 00000000..21aa28ef --- /dev/null +++ b/src/main/resources/db/migration/V3_6__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..00356b45 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) { 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 6d49670a..6a3c9ea1 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 @@ -27,22 +27,29 @@ 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.EntityDeletionBlockedException; 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.Relation; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.model.entity_template.RelationDefinition; +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.EntityQueryParserService; 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") @@ -61,7 +68,10 @@ class EntityServiceTest { private EntityTemplateService entityTemplateService; @Mock - private EntityQueryParserService entityQueryParserService; + private EntityFilterDslParser entityFilterDslParser; + + @Mock + private SearchFilterValidationService searchFilterValidationService; @InjectMocks private EntityService entityService; @@ -83,7 +93,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); } @@ -418,4 +428,81 @@ 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()); + } + + 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 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", new PaginationCriteria(0, 20, null)); + + assertEquals(paginatedResult, result); + verify(searchFilterValidationService).validate(filter, "api"); + } + + @Test + @DisplayName("Should search entities with valid sort") + void shouldSearchEntitiesWithValidSort() { + var filter = emptyFilter(); + 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(paginatedResult); + + var result = entityService.searchEntities(filter, null, + new PaginationCriteria(0, 10, "identifier:asc")); + + assertEquals(paginatedResult, result); + } + + @Test + @DisplayName("Should reject page size exceeding maximum") + void shouldRejectPageSizeExceedingMaximum() { + assertSearchThrows(new PaginationCriteria(0, SearchConstraints.MAX_PAGE_SIZE + 1, null)); + } + + @Test + @DisplayName("Should reject negative page index") + void shouldRejectNegativePageIndex() { + assertSearchThrows(new PaginationCriteria(-1, 20, null)); + } + + @Test + @DisplayName("Should reject non-positive page size") + void shouldRejectNonPositivePageSize() { + assertSearchThrows(new PaginationCriteria(0, 0, null)); + } + + @Test + @DisplayName("Should reject invalid sort field") + void shouldRejectInvalidSortField() { + assertSearchThrows(new PaginationCriteria(0, 20, "badField:asc")); + } + + @Test + @DisplayName("Should reject invalid sort direction") + void shouldRejectInvalidSortDirection() { + assertSearchThrows(new PaginationCriteria(0, 20, "identifier:zzz")); + } + + @Test + @DisplayName("Should reject extra sort expression segments") + void shouldRejectExtraSortSegments() { + assertSearchThrows(new PaginationCriteria(0, 20, "identifier:asc:extra")); + } } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/EntityQueryParserServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/filter/EntityFilterDslParserTest.java similarity index 84% rename from src/test/java/com/decathlon/idp_core/domain/service/EntityQueryParserServiceTest.java rename to src/test/java/com/decathlon/idp_core/domain/service/filter/EntityFilterDslParserTest.java index 24477373..d738173f 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/EntityQueryParserServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/filter/EntityFilterDslParserTest.java @@ -1,4 +1,4 @@ -package com.decathlon.idp_core.domain.service; +package com.decathlon.idp_core.domain.service.filter; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -13,18 +13,19 @@ import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; +import com.decathlon.idp_core.domain.constant.FilterConstraints; import com.decathlon.idp_core.domain.constant.ValidationMessages; -import com.decathlon.idp_core.domain.exception.InvalidQueryDslException; +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; import com.decathlon.idp_core.domain.model.enums.FilterOperator; -@DisplayName("EntityQueryParserService") +@DisplayName("EntityFilterDslParser") @SuppressWarnings("java:S2187") -class EntityQueryParserServiceTest { +class EntityFilterDslParserTest { - private final EntityQueryParserService parser = new EntityQueryParserService(); + private final EntityFilterDslParser parser = new EntityFilterDslParser(); private void assertSingleCriterion(EntityFilter result, FilterKeyType expectedKeyType, String expectedKeyName, FilterOperator expectedOperator, String expectedValue) { @@ -162,10 +163,10 @@ void parse_relationPropertyContains() { } @Test - @DisplayName("throws InvalidQueryDslException for unsupported property in relation (custom-prop is not identifier or name)") + @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(InvalidQueryDslException.class).hasMessageContaining("custom-prop") + .isInstanceOf(InvalidFilterDslException.class).hasMessageContaining("custom-prop") .hasMessageContaining("identifier").hasMessageContaining("name"); } } @@ -210,7 +211,7 @@ void parse_relationsAsTargetPropertyNameContains() { @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) + .isInstanceOf(InvalidFilterDslException.class) .hasMessageContaining("only 'identifier' and 'name' are supported"); } @@ -218,7 +219,7 @@ void parse_relationsAsTargetInvalidProperty_throwsException() { @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) + .isInstanceOf(InvalidFilterDslException.class) .hasMessageContaining("relations_as_target requires the form"); } } @@ -279,38 +280,38 @@ class InvalidQueryTests { @ParameterizedTest(name = "missing operator in: ''{0}''") @ValueSource(strings = {"noOperatorHere", "property.lang", "relation.db"}) - @DisplayName("throws InvalidQueryDslException when operator is missing") + @DisplayName("throws InvalidFilterDslException when operator is missing") void parse_missingOperator_throwsException(String query) { - assertThatThrownBy(() -> parser.parse(query)).isInstanceOf(InvalidQueryDslException.class) + assertThatThrownBy(() -> parser.parse(query)).isInstanceOf(InvalidFilterDslException.class) .hasMessage(ValidationMessages.FILTER_INVALID_FORMAT); } @Test - @DisplayName("throws InvalidQueryDslException for unknown attribute") + @DisplayName("throws InvalidFilterDslException for unknown attribute") void parse_unknownAttribute_throwsException() { assertThatThrownBy(() -> parser.parse("unknownField=value")) - .isInstanceOf(InvalidQueryDslException.class).hasMessageContaining("Unknown attribute"); + .isInstanceOf(InvalidFilterDslException.class).hasMessageContaining("Unknown attribute"); } @Test - @DisplayName("throws InvalidQueryDslException for blank value") + @DisplayName("throws InvalidFilterDslException for blank value") void parse_blankValue_throwsException() { - assertThatThrownBy(() -> parser.parse("name=")).isInstanceOf(InvalidQueryDslException.class) + assertThatThrownBy(() -> parser.parse("name=")).isInstanceOf(InvalidFilterDslException.class) .hasMessageContaining("value must not be blank"); } @Test - @DisplayName("throws InvalidQueryDslException for blank key") + @DisplayName("throws InvalidFilterDslException for blank key") void parse_blankKey_throwsException() { - assertThatThrownBy(() -> parser.parse("=value")).isInstanceOf(InvalidQueryDslException.class) + assertThatThrownBy(() -> parser.parse("=value")).isInstanceOf(InvalidFilterDslException.class) .hasMessageContaining("key must not be blank"); } @Test - @DisplayName("throws InvalidQueryDslException for blank property name after prefix") + @DisplayName("throws InvalidFilterDslException for blank property name after prefix") void parse_blankPropertyName_throwsException() { assertThatThrownBy(() -> parser.parse("property.=JAVA")) - .isInstanceOf(InvalidQueryDslException.class) + .isInstanceOf(InvalidFilterDslException.class) .hasMessageContaining("key name must not be blank"); } } @@ -320,13 +321,12 @@ void parse_blankPropertyName_throwsException() { class SecurityConstraintTests { @Test - @DisplayName("throws InvalidQueryDslException when criteria count exceeds limit") + @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(InvalidQueryDslException.class) - .hasMessageContaining( - "maximum of %d".formatted(EntityQueryParserService.MAX_CRITERIA_COUNT)); + assertThatThrownBy(() -> parser.parse(query)).isInstanceOf(InvalidFilterDslException.class) + .hasMessageContaining("maximum of %d".formatted(FilterConstraints.MAX_CRITERIA_COUNT)); } @Test @@ -335,25 +335,25 @@ 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); + assertThat(result.criteria()).hasSize(FilterConstraints.MAX_CRITERIA_COUNT); } @Test - @DisplayName("throws InvalidQueryDslException when value exceeds max length") + @DisplayName("throws InvalidFilterDslException when value exceeds max length") void parse_valueTooLong_throwsException() { - var longValue = "a".repeat(EntityQueryParserService.MAX_KEY_VALUE_LENGTH + 1); + var longValue = "a".repeat(FilterConstraints.MAX_KEY_VALUE_LENGTH + 1); assertThatThrownBy(() -> parser.parse("name=" + longValue)) - .isInstanceOf(InvalidQueryDslException.class).hasMessageContaining( - "must not exceed %d".formatted(EntityQueryParserService.MAX_KEY_VALUE_LENGTH)); + .isInstanceOf(InvalidFilterDslException.class).hasMessageContaining( + "must not exceed %d".formatted(FilterConstraints.MAX_KEY_VALUE_LENGTH)); } @Test - @DisplayName("throws InvalidQueryDslException when key exceeds max length") + @DisplayName("throws InvalidFilterDslException when key exceeds max length") void parse_keyTooLong_throwsException() { - var longKey = "property." + "a".repeat(EntityQueryParserService.MAX_KEY_VALUE_LENGTH); + var longKey = "property." + "a".repeat(FilterConstraints.MAX_KEY_VALUE_LENGTH); assertThatThrownBy(() -> parser.parse(longKey + "=value")) - .isInstanceOf(InvalidQueryDslException.class).hasMessageContaining( - "must not exceed %d".formatted(EntityQueryParserService.MAX_KEY_VALUE_LENGTH)); + .isInstanceOf(InvalidFilterDslException.class).hasMessageContaining( + "must not exceed %d".formatted(FilterConstraints.MAX_KEY_VALUE_LENGTH)); } @ParameterizedTest(name = "valid key name: ''{0}''") @@ -375,9 +375,9 @@ 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") + @DisplayName("throws InvalidFilterDslException for duplicate criteria") void parse_duplicateCriterion_throwsException(String query) { - assertThatThrownBy(() -> parser.parse(query)).isInstanceOf(InvalidQueryDslException.class) + assertThatThrownBy(() -> parser.parse(query)).isInstanceOf(InvalidFilterDslException.class) .hasMessage(ValidationMessages.FILTER_DUPLICATE_CRITERION); } @@ -402,60 +402,60 @@ class TypeMismatchTests { @ParameterizedTest(name = "comparison operator on: ''{0}''") @ValueSource(strings = {"relationapi-link"}) - @DisplayName("throws InvalidQueryDslException for less/greater than on relation name") + @DisplayName("throws InvalidFilterDslException for less/greater than on relation name") void parse_comparisonOnRelationName_throwsException(String query) { - assertThatThrownBy(() -> parser.parse(query)).isInstanceOf(InvalidQueryDslException.class) + 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 InvalidQueryDslException for less/greater than on relation entity") + @DisplayName("throws InvalidFilterDslException for less/greater than on relation entity") void parse_comparisonOnRelationEntity_throwsException(String query) { - assertThatThrownBy(() -> parser.parse(query)).isInstanceOf(InvalidQueryDslException.class) + 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 InvalidQueryDslException for unsupported property on relation (template is not a valid relation property)") + @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(InvalidQueryDslException.class) + assertThatThrownBy(() -> parser.parse(query)).isInstanceOf(InvalidFilterDslException.class) .hasMessageContaining("template"); } @Test - @DisplayName("throws InvalidQueryDslException for unsupported property on relation with equals operator") + @DisplayName("throws InvalidFilterDslException for unsupported property on relation with equals operator") void parse_equalsOnRelationTemplate_throwsException() { assertThatThrownBy(() -> parser.parse("relation.database.template=postgresql")) - .isInstanceOf(InvalidQueryDslException.class).hasMessageContaining("template") + .isInstanceOf(InvalidFilterDslException.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") + @DisplayName("throws InvalidFilterDslException for less/greater than on relation property") void parse_comparisonOnRelationProperty_throwsException(String query) { - assertThatThrownBy(() -> parser.parse(query)).isInstanceOf(InvalidQueryDslException.class) + 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 InvalidQueryDslException for less/greater than on relations_as_target property") + @DisplayName("throws InvalidFilterDslException for less/greater than on relations_as_target property") void parse_comparisonOnRelationsAsTargetProperty_throwsException(String query) { - assertThatThrownBy(() -> parser.parse(query)).isInstanceOf(InvalidQueryDslException.class) + 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(InvalidQueryDslException.class) + assertThatThrownBy(() -> parser.parse(query)).isInstanceOf(InvalidFilterDslException.class) .hasMessageContaining("is not applicable for field"); } 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..00480390 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/search/SearchFilterParserTest.java @@ -0,0 +1,199 @@ +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 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; +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]. +/// +/// 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); + } + + @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(connector, List.of(child)); + assertThatThrownBy(() -> parser.parse(raw)).isInstanceOf(InvalidSearchQueryException.class) + .hasMessageContaining(expectedMessage); + } + + @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"); + } + + static Stream invalidConnectors() { + return Stream.of(Arguments.of("IN", "IN"), Arguments.of(null, "connector"), + Arguments.of("NAND", "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..3cd689b6 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/search/SearchFilterValidationServiceTest.java @@ -0,0 +1,402 @@ +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_template.EntityTemplate; +import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; +import com.decathlon.idp_core.domain.model.enums.PropertyType; +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]. +@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() { + var crit = criterion("badField", SearchOperator.EQ, "val"); + assertValidationThrows(crit, "badField"); + } + + @Test + @DisplayName("'relations_as_target' without subfield throws") + void relationsAsTarget_missingSubfield_throws() { + var crit = criterion("relations_as_target.api-link", SearchOperator.EQ, "val"); + assertValidationThrows(crit); + } + + @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() { + 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() { + 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() { + 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() { + var crit = criterion("property.size", SearchOperator.LTE, "10MB"); + assertValidationThrows(crit, "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 void assertValidationThrows(SearchFilterNode.Criterion crit, + String... expectedFragments) { + var assertion = assertThatThrownBy(() -> service.validate(crit, null)) + .isInstanceOf(InvalidSearchQueryException.class); + for (String frag : expectedFragments) { + assertion.hasMessageContaining(frag); + } + } +} 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 4e9eb68f..0708dd04 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.delete; @@ -24,6 +25,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, @@ -758,6 +760,693 @@ 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(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"))); + } + + @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(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"))); + } + + @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": "8079" } + ] + }, + "page": 0, "size": 20 + } + """)) + .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 + @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); + } } @Nested 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..104caeec --- /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.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]. +/// +/// 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/JpaPredicateBuilderTest.java similarity index 72% rename from src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySpecificationTest.java rename to src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/JpaPredicateBuilderTest.java index 595738f5..bdc8de6c 100644 --- 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/JpaPredicateBuilderTest.java @@ -8,14 +8,12 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; -/// Unit tests for [EntitySpecification]. +/// Unit tests for [JpaPredicateBuilder]. /// -/// 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 { +/// Focuses on the LIKE wildcard escaping logic which is security-critical and shared +/// between [EntitySpecification] and [EntitySearchSpecification]. +@DisplayName("JpaPredicateBuilder") +class JpaPredicateBuilderTest { @Nested @DisplayName("escapeLikeWildcards") @@ -24,40 +22,40 @@ class EscapeLikeWildcardsTests { @Test @DisplayName("escapes percent sign") void escapes_percent() { - assertThat(EntitySpecification.escapeLikeWildcards("100%")).isEqualTo("100\\%"); + assertThat(JpaPredicateBuilder.escapeLikeWildcards("100%")).isEqualTo("100\\%"); } @Test @DisplayName("escapes underscore") void escapes_underscore() { - assertThat(EntitySpecification.escapeLikeWildcards("my_value")).isEqualTo("my\\_value"); + assertThat(JpaPredicateBuilder.escapeLikeWildcards("my_value")).isEqualTo("my\\_value"); } @Test @DisplayName("escapes backslash before other wildcards") void escapes_backslash() { - assertThat(EntitySpecification.escapeLikeWildcards("path\\to%file")) + assertThat(JpaPredicateBuilder.escapeLikeWildcards("path\\to%file")) .isEqualTo("path\\\\to\\%file"); } @Test @DisplayName("escapes multiple wildcards") void escapes_multipleWildcards() { - assertThat(EntitySpecification.escapeLikeWildcards("100%_success")) + assertThat(JpaPredicateBuilder.escapeLikeWildcards("100%_success")) .isEqualTo("100\\%\\_success"); } @Test @DisplayName("returns plain string unchanged") void leaves_plainString_unchanged() { - assertThat(EntitySpecification.escapeLikeWildcards("hello")).isEqualTo("hello"); + 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 = EntitySpecification.escapeLikeWildcards(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("_"); 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..a5a0b8f8 --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byRelationNameContains.json @@ -0,0 +1,24 @@ +{ + "content": [ + { + "identifier": "web-api-1", + "name": "Web API 1", + "properties": { + "environment": "PROD", + "port": 8080.0, + "programmingLanguage": "JAVA" + }, + "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_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..2d9de923 --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byTemplateAndProperty.json @@ -0,0 +1,60 @@ +{ + "content": [ + { + "identifier": "web-api-1", + "name": "Web API 1", + "properties": { + "environment": "PROD", + "port": 8080.0, + "programmingLanguage": "JAVA" + }, + "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 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", + "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": 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 new file mode 100644 index 00000000..642d3c75 --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/searchEntities_200_neq.json @@ -0,0 +1,23 @@ +{ + "content": [ + { + "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" + } + ], + "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 } +}