From d807c697d72b8e25806481443238f01f242185bf Mon Sep 17 00:00:00 2001 From: renny vandomber Date: Fri, 24 Apr 2026 14:05:10 +0200 Subject: [PATCH 01/53] feat(core): add post entity --- docker-compose.yml | 3 +- docs/src/concepts/entities.md | 264 +++++++++++++---- .../code/domain-infrastructure.md | 7 +- docs/src/static/swagger.yaml | 272 +++++++++++++++++- .../domain/constant/ValidationMessages.java | 35 +++ .../EntityAlreadyExistsException.java | 17 ++ .../exception/EntityNotFoundException.java | 6 +- .../exception/EntityValidationException.java | 30 ++ .../idp_core/domain/model/entity/Entity.java | 6 +- .../domain/port/EntityRepositoryPort.java | 4 +- .../service/{ => entity}/EntityService.java | 66 +++-- .../entity/EntityValidationService.java | 152 ++++++++++ .../domain/service/entity/Violations.java | 35 +++ .../property/PropertyValidationService.java | 116 ++++++++ .../configuration/SecurityConfiguration.java | 1 + .../api/configuration/SwaggerDescription.java | 16 ++ .../api/controller/EntityController.java | 47 ++- .../adapters/api/dto/in/EntityDtoIn.java | 51 +++- .../api/handler/ApiExceptionHandler.java | 44 ++- .../api/mapper/entity/EntityDtoInMapper.java | 53 ++-- .../api/mapper/entity/EntityDtoOutMapper.java | 2 +- .../persistence/PostgresEntityAdapter.java | 14 +- .../repository/JpaEntityRepository.java | 2 + .../service/entity/EntityServiceTest.java | 158 ++++++++++ .../entity/EntityValidationServiceTest.java | 235 +++++++++++++++ .../PropertyValidationServiceTest.java | 80 ++++++ .../api/controller/EntityControllerTest.java | 142 +++++++-- .../EntityTemplateControllerTest.java | 4 - .../api/handler/ApiExceptionHandlerTest.java | 90 ++++-- ...stEntityTemplate_400_properties_empty.json | 6 + ...late_400_withoutPropertiesDefinitions.json | 6 + .../json/entity/v1/postEntity_201.json | 16 +- .../entity/v1/postEntity_201_minimal.json | 4 + .../v1/postEntity_201_with_relations.json | 14 + .../v1/postEntity_400_identifier_missing.json | 7 + .../v1/postEntity_400_name_missing.json | 7 + .../postEntity_400_property_value_blank.json | 8 + .../postEntity_400_relation_name_blank.json | 11 + .../entity/v1/postEntity_409_duplicate.json | 6 + 39 files changed, 1816 insertions(+), 221 deletions(-) create mode 100644 src/main/java/com/decathlon/idp_core/domain/exception/EntityAlreadyExistsException.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/exception/EntityValidationException.java rename src/main/java/com/decathlon/idp_core/domain/service/{ => entity}/EntityService.java (59%) create mode 100644 src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/service/entity/Violations.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java create mode 100644 src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java create mode 100644 src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java create mode 100644 src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java create mode 100644 src/test/resources/integration_test/json/entity-template/v1/postEntityTemplate_400_properties_empty.json create mode 100644 src/test/resources/integration_test/json/entity-template/v1/putEntityTemplate_400_withoutPropertiesDefinitions.json create mode 100644 src/test/resources/integration_test/json/entity/v1/postEntity_201_minimal.json create mode 100644 src/test/resources/integration_test/json/entity/v1/postEntity_201_with_relations.json create mode 100644 src/test/resources/integration_test/json/entity/v1/postEntity_400_identifier_missing.json create mode 100644 src/test/resources/integration_test/json/entity/v1/postEntity_400_name_missing.json create mode 100644 src/test/resources/integration_test/json/entity/v1/postEntity_400_property_value_blank.json create mode 100644 src/test/resources/integration_test/json/entity/v1/postEntity_400_relation_name_blank.json create mode 100644 src/test/resources/integration_test/json/entity/v1/postEntity_409_duplicate.json diff --git a/docker-compose.yml b/docker-compose.yml index be4859d8..139089d2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,9 +1,8 @@ --- -version: "3.8" services: postgres: - image: postgres:14 + image: postgres:18 environment: POSTGRES_USER: idpcore POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-idpcore_password} diff --git a/docs/src/concepts/entities.md b/docs/src/concepts/entities.md index 4a789453..92598c02 100644 --- a/docs/src/concepts/entities.md +++ b/docs/src/concepts/entities.md @@ -3,17 +3,17 @@ title: Entities description: Understand Entities - instances of Entity Templates with actual data --- -Entities are **instances** of Entity Templates containing actual data. If an Entity Template is the blueprint, an Entity is the house built from that blueprint. +Entities are **instances** of Entity Templates containing actual data. If an Entity Template is the blueprint, an Entity +is the house built from that blueprint. ## Overview An Entity contains: -- **Identity** - Unique identifier and title +- **Identity** - Unique identifier and name - **Template Reference** - Which template it instantiates - **Properties** - Actual values for the template's property definitions - **Relations** - Links to other entities -- **Audit Fields** - Creation/modification timestamps and actors ```mermaid flowchart LR @@ -36,26 +36,26 @@ flowchart LR ### Complete Example -Here's an entity instantiated from the `sonar_project` template: +Here's an entity instantiated from the `web-service` template: ```json { - "identifier": "decathlon_my-backend-project", - "title": "My Backend Project", - "template": "sonar_project", + "identifier": "my-web-service", + "name": "my-web-service", + "template_identifier": "web-service", "properties": { - "project_name": "My Backend Project", - "last_analysis_date": "2025-11-28T12:20:38+0000", - "issues_number": 137, - "loc": 20000 + "port": "8080", + "environment": "dev" }, "relations": { - "github_repository": "my-backend-repo" + "depends-on": [ + { + "identifier": "web-api-1", + "name": "Web API 1" + } + ] }, - "created_at": "2024-10-25T09:44:02.742Z", - "created_by": "1EOn3KYVK6L8Bh6Sm0dZ1AdG1AtAZmWt", - "updated_at": "2025-11-29T09:44:03.448Z", - "updated_by": "1EOn3KYVK6L8Bh6Sm0dZ1AdG1AtAZmWt" + "relations_as_target": {} } ``` @@ -63,17 +63,104 @@ Here's an entity instantiated from the `sonar_project` template: ## Core Fields -| Field | Type | Description | -| ------------ | -------- | -------------------------------------------- | -| `identifier` | String | Unique identifier for this entity | -| `title` | String | Human-readable name | -| `template` | String | The Entity Template this entity instantiates | -| `properties` | Object | Key-value pairs of property data | -| `relations` | Object | Links to other entities | -| `created_at` | DateTime | When the entity was created | -| `created_by` | String | Who created the entity | -| `updated_at` | DateTime | Last modification time | -| `updated_by` | String | Who last modified the entity | +| Field | Type | Description | +|-----------------------|----------|----------------------------------------------| +| `identifier` | String | Unique identifier within the template scope | +| `name` | String | Human-readable name | +| `template_identifier` | String | The Entity Template this entity instantiates | +| `properties` | Object | Key-value pairs of property data | +| `relations` | Object | Links to other entities (grouped by name) | + +--- + +## Creating an Entity + +You create an entity by sending a `POST` request to the entities endpoint, specifying the template identifier in the URL +path. + +### Endpoint + +```text +POST /api/v1/entities/{templateIdentifier} +``` + +### Request Body + +```json +{ + "name": "my-web-service", + "identifier": "my-web-service", + "properties": { + "port": "8080", + "environment": "dev" + }, + "relations": [ + { + "name": "depends-on", + "target_entity_identifiers": [ + "web-api-1", + "web-api-2" + ] + } + ] +} +``` + +### Validation + +IDP-Core validates entities at two levels: **syntactic validation** at the API boundary and **semantic validation** +against the template definition. + +#### Syntactic Validation (API Layer) + +The API enforces basic structural rules on the request body before any business logic runs: + +| Field | Rule | Error Message | +|-----------------------------------------|---------------------|--------------------------------------| +| `name` | Required, not blank | Entity name is mandatory | +| `identifier` | Required, not blank | Entity identifier is mandatory | +| `relations[].name` | Required, not blank | Relation name is mandatory | +| `relations[].target_entity_identifiers` | Required, not null | Relation target identifiers required | + +If any rule fails, the API returns `400 Bad Request` with a description of the violation. + +#### Semantic Validation (Domain Layer) + +After syntactic checks pass, the domain service validates the entity against its template definition: + +- **Template existence** - The template identifier must match an existing template. Returns `404 Not Found` if the + template does not exist. +- **Property value types** - Values must conform to the property definition type (STRING, NUMBER, BOOLEAN). +- **Property rules** - Values must satisfy the template's property rules (min/max length, format, regex, enum). +- **Required properties** - All properties marked as required in the template must be present. +- **Duplicate check** - An entity with the same identifier must not already exist for the template. Returns + `409 Conflict` if it does. + +### Response Codes + +| Code | Description | +|-------|----------------------------------------------------------------| +| `201` | Entity created successfully | +| `400` | Invalid request body or validation failure | +| `401` | Missing or invalid authentication token | +| `403` | Insufficient permissions | +| `404` | Template not found for the given identifier | +| `409` | An entity with this identifier already exists for the template | +| `500` | Unexpected server error | + +### Minimal Example + +You can create an entity with only the required fields: + +```json +{ + "name": "microservice-minimal", + "identifier": "microservice-minimal" +} +``` + +Properties and relations are optional in the request body. The domain layer validates that all *required* properties (as +defined in the template) are present. --- @@ -84,17 +171,17 @@ Properties contain the actual data values. The structure follows the template's ```json { "properties": { - "project_name": "My Backend Project", // STRING - "issues_number": 137, // NUMBER - "loc": 20000, // NUMBER - "last_analysis_date": "2025-11-28..." // STRING (date-time) + "project_name": "My Backend Project", + "issues_number": 137, + "loc": 20000, + "last_analysis_date": "2025-11-28..." } } ``` -### Validation +### Validation of properties -System validates values against the template's property rules: +The system validates values against the template's property rules: - Required properties must be present - Types must match: STRING, NUMBER, or BOOLEAN @@ -104,51 +191,119 @@ System validates values against the template's property rules: ## Relations -Relations link entities together, forming a graph. It references the entity identifiers of related entities. +Relations link entities together, forming a graph. Each relation references the entity identifiers of related entities. -### One-to-One Relations (`to_many: false`) +### Creating Relations -For consistency, even single relations are represented as arrays: +When creating an entity, you specify relations as an array of objects, each with a `name` and a list of +`target_entity_identifiers`: ```json { - "relations": { - "owned_by": ["platform-team"] - } + "relations": [ + { + "name": "depends-on", + "target_entity_identifiers": [ + "web-api-1", + "web-api-2" + ] + }, + { + "name": "owned-by", + "target_entity_identifiers": [ + "platform-team" + ] + } + ] } ``` -### One-to-Many Relations (`to_many: true`) +### Relations in Responses -When multiple related entities are allowed, you can list several identifiers in the relation array: +In API responses, relations are grouped by name and include summary information about each target entity: ```json { "relations": { - "components": ["frontend", "backend", "database"] + "depends-on": [ + { + "identifier": "web-api-1", + "name": "Web API 1" + }, + { + "identifier": "web-api-2", + "name": "Web API 2" + } + ] + }, + "relations_as_target": { + "depends-on": [ + { + "identifier": "frontend-app", + "name": "Frontend App" + } + ] } } ``` ---- +The `relations_as_target` field shows reverse relationships—other entities that reference this entity. -## Audit Fields +### One-to-One Relations (`to_many: false`) -Every entity tracks who created/modified it and when: +For consistency, even single relations are represented as arrays: ```json { - "created_at": "2024-10-25T09:44:02.742Z", - "created_by": "auth0|65c1d23377c9bea7d7adc415", - "updated_at": "2025-11-29T09:44:03.448Z", - "updated_by": "webhook_integration_sonar" + "relations": [ + { + "name": "owned_by", + "target_entity_identifiers": [ + "platform-team" + ] + } + ] } ``` -The `created_by` and `updated_by` fields contain: +### One-to-Many Relations (`to_many: true`) -- User IDs for manual operations -- Integration IDs for automated data ingestion +When multiple related entities are allowed, list several identifiers: + +```json +{ + "relations": [ + { + "name": "components", + "target_entity_identifiers": [ + "frontend", + "backend", + "database" + ] + } + ] +} +``` + +--- + +## Retrieving Entities + +### List Entities by Template + +Retrieve a paginated list of entities for a given template: + +```text +GET /api/v1/entities/{templateIdentifier}?page=0&size=20&sort=identifier,asc +``` + +### Get Entity by Identifier + +Retrieve a specific entity using its template and entity identifiers: + +```text +GET /api/v1/entities/{templateIdentifier}/identifier/{entityIdentifier} +``` --- @@ -157,7 +312,8 @@ The `created_by` and `updated_by` fields contain: Because templates are configured at runtime, the entity structure is **dynamic**: > [!WARNING] -> The second-level JSON paths (`properties`, `relations`) are **not guaranteed by the API contract**. Their structure depends on the template configuration. +> The second-level JSON paths (`properties`, `relations`) are **not guaranteed by the API contract**. Their structure +> depends on the template configuration. > > This means: > @@ -168,4 +324,4 @@ Because templates are configured at runtime, the entity structure is **dynamic** - **[Properties](properties.md)** - Property types and validation rules - **[Relations](relations.md)** - How entities connect -- **[Calculated Properties](calculated-properties.md)** - Automatic computations +- **[API Reference](../api/index.md)** - Interactive Swagger UI documentation diff --git a/docs/src/contributing/code/domain-infrastructure.md b/docs/src/contributing/code/domain-infrastructure.md index 8b6a25ca..ad1dbf92 100644 --- a/docs/src/contributing/code/domain-infrastructure.md +++ b/docs/src/contributing/code/domain-infrastructure.md @@ -33,7 +33,12 @@ domain/ │ ├── EntityTemplateRepositoryPort │ └── RelationRepositoryPort └── service/ # Domain services logic orchestration - ├── EntityService + ├── entity/ + │ ├── EntityService # Orchestrates entity CRUD with validation + │ ├── EntityValidationService # Entity validation pipeline (template, uniqueness, structure, rules) + │ └── Violations # Mutable accumulator of validation violation messages + ├── property/ + │ └── PropertyValidationService # Validates property values against type and rules (STRING, NUMBER, BOOLEAN) ├── EntityTemplateService └── RelationService ``` diff --git a/docs/src/static/swagger.yaml b/docs/src/static/swagger.yaml index 37c9d488..accb23c2 100644 --- a/docs/src/static/swagger.yaml +++ b/docs/src/static/swagger.yaml @@ -9,6 +9,8 @@ security: - clientId: [] - bearer: [] tags: + - name: Entities Management + description: Operations related to entity management - name: Entities Templates Management description: Operations related to entity template management paths: @@ -160,6 +162,143 @@ paths: '*/*': schema: $ref: '#/components/schemas/ErrorResponse' + /api/v1/entities/{templateIdentifier}: + get: + tags: + - Entities Management + summary: Get entities by template identifier + description: Retrieve a paginated list of entities with optional sorting + operationId: getEntities + parameters: + - name: page + in: query + description: Page number for pagination. Defaults to 0. + required: false + content: + '*/*': + schema: + type: integer + default: '0' + - name: size + in: query + description: Number of items per page. Defaults to 20. + required: false + content: + '*/*': + schema: + type: integer + default: '20' + - name: templateIdentifier + in: path + required: true + schema: + type: string + - name: sort + in: query + description: 'Sorting criteria in the format: property(,asc|desc). Defaults to identifier,asc.' + content: + '*/*': + schema: + type: string + default: identifier,asc + responses: + '200': + description: Paginated entities retrieved successfully + content: + '*/*': + schema: + $ref: '#/components/schemas/EntityPageResponse' + '400': + description: Invalid pagination parameters + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + post: + tags: + - Entities Management + summary: Create a new entity + description: Create a new entity in the system with the provided information + operationId: createEntity + parameters: + - name: templateIdentifier + in: path + required: true + schema: + type: string + minLength: 1 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/EntityDtoIn' + required: true + responses: + '201': + description: Entity created successfully + content: + '*/*': + schema: + $ref: '#/components/schemas/EntityDtoOut' + '400': + description: Invalid entity data provided + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - Missing or invalid token + '403': + description: Insufficient rights + '409': + description: Entity already exists in this template + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Template not found with the provided identifier + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Unexpected server-side failure + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + /api/v1/entities/{templateIdentifier}/identifier/{entityIdentifier}: + get: + tags: + - Entities Management + summary: Get entity by entity template and identifier + description: Retrieve a specific entity using its string identifier and its template identifier + operationId: getEntity + parameters: + - name: templateIdentifier + in: path + required: true + schema: + type: string + - name: entityIdentifier + in: path + required: true + schema: + type: string + responses: + '200': + description: Entity found + content: + '*/*': + schema: + $ref: '#/components/schemas/EntityDtoOut' + '404': + description: Entity not found with the provided identifier + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' components: schemas: EntityTemplateCreateDtoIn: @@ -173,7 +312,7 @@ components: minLength: 1 name: type: string - description: Unique Entity Template name + description: Entity Template name example: Service maxLength: 255 minLength: 1 @@ -201,10 +340,10 @@ components: properties: name: type: string - description: Entity Template name + description: Unique Entity Template name example: Service maxLength: 255 - minLength: 1 + minLength: 0 pattern: "^[a-zA-Z0-9 _-]+$" description: type: string @@ -356,11 +495,6 @@ components: type: object description: Output DTO for property definition properties: - id: - type: string - format: uuid - description: Unique identifier of the property definition - example: 123e4567-e89b-12d3-a456-426614174000 name: type: string description: Property name @@ -437,11 +571,6 @@ components: type: object description: Output DTO for relation definition properties: - id: - type: string - format: uuid - description: Unique identifier of the relation definition - example: 123e4567-e89b-12d3-a456-426614174000 name: type: string description: Name of the relation @@ -535,6 +664,86 @@ components: - 511 NETWORK_AUTHENTICATION_REQUIRED errorDescription: type: string + EntityDtoIn: + type: object + description: Input DTO for creating or updating an entity + properties: + name: + type: string + description: Name of the entity + example: my-web-service + minLength: 1 + identifier: + type: string + description: Unique identifier of the entity within the template scope + example: my-web-service + minLength: 1 + properties: + type: object + additionalProperties: {} + description: Map of property name to value for this entity + example: + port: '8080' + environment: dev + relations: + type: array + description: List of relations for this entity + items: + $ref: '#/components/schemas/RelationDtoIn' + required: + - identifier + - name + RelationDtoIn: + type: object + description: Input DTO for an entity relation instance + properties: + name: + type: string + description: Name of the relation (must match a template relation definition) + example: depends-on + minLength: 1 + target_entity_identifiers: + type: array + description: List of target entity identifiers for this relation + example: + - web-api-1 + - web-api-2 + items: + type: string + required: + - name + - target_entity_identifiers + EntityDtoOut: + type: object + properties: + template_identifier: + type: string + name: + type: string + identifier: + type: string + properties: + type: object + additionalProperties: {} + relations: + type: object + additionalProperties: + type: array + items: + $ref: '#/components/schemas/EntitySummaryDto' + relations_as_target: + type: object + additionalProperties: + type: array + items: + $ref: '#/components/schemas/EntitySummaryDto' + EntitySummaryDto: + type: object + properties: + identifier: + type: string + name: + type: string PageableObject: type: object properties: @@ -572,15 +781,46 @@ components: $ref: '#/components/schemas/EntityTemplateDtoOut' pageable: $ref: '#/components/schemas/PageableObject' + totalElements: + type: integer + format: int64 + totalPages: + type: integer + format: int32 last: type: boolean - totalPages: + size: + type: integer + format: int32 + number: type: integer format: int32 + sort: + $ref: '#/components/schemas/SortObject' + first: + type: boolean + numberOfElements: + type: integer + format: int32 + empty: + type: boolean + EntityPageResponse: + type: object + description: Paginated response containing Entity objects + properties: + content: + type: array + items: + $ref: '#/components/schemas/EntityDtoOut' + pageable: + $ref: '#/components/schemas/PageableObject' totalElements: type: integer format: int64 - first: + totalPages: + type: integer + format: int32 + last: type: boolean size: type: integer @@ -590,6 +830,8 @@ components: format: int32 sort: $ref: '#/components/schemas/SortObject' + first: + type: boolean numberOfElements: type: integer format: int32 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 8d30dda9..369f4348 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 @@ -20,6 +20,24 @@ public class ValidationMessages { public static final String PROPERTY_DESCRIPTION_MANDATORY = "Property description is mandatory and cannot be blank"; public static final String PROPERTY_TYPE_MANDATORY = "Property type is mandatory"; public static final String PROPERTY_VALUE_MANDATORY = "Property value is mandatory and cannot be blank"; + public static final String PROPERTY_REQUIRED_MISSING = "Property '%s' is required by template '%s'"; + public static final String PROPERTY_TYPE_MISMATCH = "Property '%s' must be of type %s"; + public static final String PROPERTY_MIN_LENGTH_VIOLATION = "Property '%s' length must be greater than or equal to %d"; + public static final String PROPERTY_MAX_LENGTH_VIOLATION = "Property '%s' length must be lower than or equal to %d"; + public static final String PROPERTY_MIN_VALUE_VIOLATION = "Property '%s' value must be greater than or equal to %d"; + public static final String PROPERTY_MAX_VALUE_VIOLATION = "Property '%s' value must be lower than or equal to %d"; + public static final String PROPERTY_REGEX_VIOLATION = "Property '%s' does not match expected format"; + public static final String PROPERTY_ENUM_VIOLATION = "Property '%s' must be one of %s"; + public static final String PROPERTY_FORMAT_VIOLATION = "Property '%s' does not match required format %s"; + public static final String PROPERTY_RULES_NUMERIC_RULE_NOT_ALLOWED = + "Numeric rule '{rule}' is not allowed for STRING properties"; + public static final String PROPERTY_RULES_MIN_LENGTH_NON_NEGATIVE = + "Rule 'min_length' must be greater than or equal to 0"; + public static final String PROPERTY_RULES_MAX_LENGTH_POSITIVE = + "Rule 'max_length' must be greater than 0"; + public static final String PROPERTY_RULES_BOOLEAN_NOT_ALLOWED = + "BOOLEAN properties do not allow validation rules"; + public static final String PROPERTY_RULES_REGEX_INVALID = "Invalid regex pattern: %s"; // Relation Definition validation messages public static final String RELATION_NAME_MANDATORY = "Relation name is mandatory and cannot be blank"; @@ -27,4 +45,21 @@ public class ValidationMessages { public static final String RELATION_NAME_MANDATORY_SIMPLE = "Relation name is mandatory"; public static final String RELATION_TARGET_IDENTIFIER_MANDATORY_SIMPLE = "Relation target identifier is mandatory"; public static final String RELATION_TARGET_IDENTIFIERS_NOT_NULL = "Target entity identifiers cannot be null"; + + // Entity input validation messages + public static final String ENTITY_NAME_MANDATORY = "Entity name is mandatory and cannot be blank"; + public static final String ENTITY_IDENTIFIER_MANDATORY = "Entity identifier is mandatory and cannot be blank"; + + // Entity creation validation messages + public static final String ENTITY_NOT_FOUND = "Entity not found with template identifier %s and entity identifier '%s'"; + public static final String ENTITY_ALREADY_EXISTS = "Entity with name '%s' already exists for template '%s'"; + public static final String ENTITY_VALIDATION_FAILED = "Entity validation failed: "; + + public static String minMaxConstraintViolated(String ruleName) { + return "Rule 'min_" + ruleName + "' must be lower than or equal to 'max_" + ruleName + "'"; + } + + public static String ruleNotAllowed(String ruleName, String propertyType) { + return "Rule '" + ruleName + "' is not allowed for " + propertyType + " properties"; + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/EntityAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/EntityAlreadyExistsException.java new file mode 100644 index 00000000..bd76169b --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/EntityAlreadyExistsException.java @@ -0,0 +1,17 @@ +package com.decathlon.idp_core.domain.exception; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_ALREADY_EXISTS; + +import com.decathlon.idp_core.domain.model.entity.Entity; + +/// Domain exception for duplicate [Entity] business entities within a template scope. +public class EntityAlreadyExistsException extends RuntimeException { + + /// Constructs a new exception with template and entity identifiers. + /// + /// @param templateIdentifier the identifier of the template + /// @param entityName the duplicate entity name + public EntityAlreadyExistsException(String templateIdentifier, String entityName) { + super(String.format(ENTITY_ALREADY_EXISTS, entityName, templateIdentifier)); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/EntityNotFoundException.java b/src/main/java/com/decathlon/idp_core/domain/exception/EntityNotFoundException.java index 2942d910..cc7d4a83 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/EntityNotFoundException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/EntityNotFoundException.java @@ -1,5 +1,9 @@ package com.decathlon.idp_core.domain.exception; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_NOT_FOUND; + +import com.decathlon.idp_core.domain.model.entity.Entity; + /// Domain exception for missing [Entity] business entities. /// /// **Business purpose:** Represents the business rule violation when attempting @@ -20,7 +24,7 @@ public class EntityNotFoundException extends RuntimeException { /// @param templateIdentifier the identifier of the template /// @param entityIdentifier the identifier of the entity public EntityNotFoundException(String templateIdentifier, String entityIdentifier) { - super(String.format("Entity not found with template identifier %s and entity identifier '%s'", templateIdentifier, entityIdentifier)); + super(String.format(ENTITY_NOT_FOUND, templateIdentifier, entityIdentifier)); } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/EntityValidationException.java b/src/main/java/com/decathlon/idp_core/domain/exception/EntityValidationException.java new file mode 100644 index 00000000..ca9da644 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/EntityValidationException.java @@ -0,0 +1,30 @@ +package com.decathlon.idp_core.domain.exception; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_VALIDATION_FAILED; + +import java.util.List; + +import lombok.Getter; + +/// Domain exception for entity schema validation failures +@Getter +public class EntityValidationException extends RuntimeException { + + /** + * -- GETTER -- + * Returns the list of individual validation violation messages. + * /// + * /// + * @return immutable list of violation messages + */ + private final List violations; + + /// Constructs a new exception with a list of validation violation messages. + /// + /// @param violations the list of validation error messages + public EntityValidationException(List violations) { + super(ENTITY_VALIDATION_FAILED + String.join("; ", violations)); + this.violations = List.copyOf(violations); + } + +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java index 2ec901e0..6250a5ac 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java @@ -1,5 +1,7 @@ package com.decathlon.idp_core.domain.model.entity; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_IDENTIFIER_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_NAME_MANDATORY; import static com.decathlon.idp_core.domain.constant.ValidationMessages.TEMPLATE_IDENTIFIER_MANDATORY; import java.util.List; @@ -24,9 +26,9 @@ public record Entity( @NotBlank(message = TEMPLATE_IDENTIFIER_MANDATORY) String templateIdentifier, - + @NotBlank(message = ENTITY_NAME_MANDATORY) String name, - + @NotBlank(message = ENTITY_IDENTIFIER_MANDATORY) String identifier, List properties, 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 1c80cb8e..0b2c4b83 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 @@ -30,7 +30,9 @@ public interface EntityRepositoryPort { Optional findByTemplateIdentifierAndIdentifier(String templateIdentifier, String identifier); - Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable); + Optional findByTemplateIdentifierAndName(String templateIdentifier, String entityName); + + Optional> findByTemplateIdentifier(String templateIdentifier, Pageable pageable); List findByIdentifierIn(List identifiers); diff --git a/src/main/java/com/decathlon/idp_core/domain/service/EntityService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java similarity index 59% rename from src/main/java/com/decathlon/idp_core/domain/service/EntityService.java rename to src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java index b09cff99..3f5de082 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/EntityService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java @@ -1,21 +1,21 @@ -package com.decathlon.idp_core.domain.service; +package com.decathlon.idp_core.domain.service.entity; import java.util.List; +import lombok.AllArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; import com.decathlon.idp_core.domain.exception.EntityNotFoundException; import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.EntityValidationException; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntitySummary; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; -import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; - import jakarta.transaction.Transactional; import jakarta.validation.Valid; -import lombok.AllArgsConstructor; /// Domain service orchestrating [Entity] business operations and validations. /// @@ -26,39 +26,37 @@ /// **Key responsibilities:** /// - Entity retrieval with template validation /// - Entity creation with business rule enforcement +/// - Entity data integrity validation (entity, properties, relations) /// - Entity summary generation for efficient queries -/// - Relationship integrity validation @Service @AllArgsConstructor public class EntityService { private final EntityRepositoryPort entityRepository; - private final EntityTemplateRepositoryPort entityTemplateRepository; + private final EntityValidationService entityValidationService; /// Retrieves entities filtered by template with existence validation. /// /// **Contract:** Returns paginated entities that conform to the specified template. /// Template existence is validated first to ensure meaningful results. /// - /// @param pageable pagination configuration for large entity sets + /// @param pageable pagination configuration for large entity sets /// @param templateIdentifier business identifier of the entity template /// @return paginated entities matching the template /// @throws EntityTemplateNotFoundException when template doesn't exist @Transactional public Page getEntitiesByTemplateIdentifier(Pageable pageable, String templateIdentifier) { - if (!entityTemplateRepository.existsByIdentifier(templateIdentifier)) { - throw new EntityTemplateNotFoundException("identifier", templateIdentifier); - } - return entityRepository.findByTemplateIdentifier(templateIdentifier, pageable); + return entityRepository.findByTemplateIdentifier(templateIdentifier, pageable) + .orElseThrow(() -> new EntityTemplateNotFoundException(templateIdentifier)); } - /// Provides lightweight entity summaries for efficient bulk operations. - /// - /// **Contract:** Returns summary projections without full entity data, - /// optimized for UI lists and relationship resolution scenarios. - /// - /// @param identifiers business identifiers of entities to summarize - /// @return lightweight entity summaries for the specified identifiers + /// Provides lightweight entity summaries for efficient bulk operations. + /// + /// **Contract:** Returns summary projections without full entity data, + /// optimized for UI lists and relationship resolution scenarios. + /// + /// @param identifiers business identifiers of entities to summarize + /// @return lightweight entity summaries for the specified identifiers public List getEntitiesSummariesByIndentifiers(List identifiers) { return entityRepository.findByIdentifierIn(identifiers); } @@ -69,29 +67,35 @@ public List getEntitiesSummariesByIndentifiers(List ident /// Validates template existence first, then entity existence, ensuring referential integrity. /// /// @param templateIdentifier business identifier of the entity template - /// @param entityIdentifier unique business identifier of the entity within template + /// @param entityIdentifier unique business identifier of the entity within template /// @return the entity matching both identifiers /// @throws EntityTemplateNotFoundException when template doesn't exist - /// @throws EntityNotFoundException when entity doesn't exist + /// @throws EntityNotFoundException when entity doesn't exist @Transactional public Entity getEntityByTemplateIdentifierAnIdentifier(String templateIdentifier, String entityIdentifier) { - if (!entityTemplateRepository.existsByIdentifier(templateIdentifier)) { - throw new EntityTemplateNotFoundException("identifier", templateIdentifier); - } + entityValidationService.checkTemplateExist(templateIdentifier); return entityRepository.findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); } - /// Creates and persists a new entity with business validation. - /// - /// **Contract:** Validates entity structure against template rules and persists - /// the entity. Future enhancement will include comprehensive business rule validation. - /// - /// @param entity validated entity to create and persist - /// @return the persisted entity with generated identifiers + /// Creates and persists a new entity with business validation. + /// + /// **Contract:** Validates template existence, entity identifier uniqueness within + /// the template scope, and entity/property/relation data integrity before persisting. + /// + /// @param entity validated entity to create and persist + /// @return the persisted entity with generated identifiers + /// @throws EntityTemplateNotFoundException when the referenced template doesn't exist + /// @throws EntityAlreadyExistsException when an entity with the same identifier already exists for this template + /// @throws EntityValidationException when entity, property, or relation data is invalid + @Transactional public Entity createEntity(@Valid Entity entity) { - // Add validations + entityValidationService.checkTemplateExist(entity.templateIdentifier()); + entityValidationService.checkEntityAlreadyExist(entity); + entityValidationService.validateEntity(entity); return entityRepository.save(entity); } + + } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java new file mode 100644 index 00000000..f535e7d2 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java @@ -0,0 +1,152 @@ +package com.decathlon.idp_core.domain.service.entity; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_IDENTIFIER_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_NAME_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_NAME_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_REQUIRED_MISSING; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_VALUE_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_NAME_MANDATORY_SIMPLE; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TARGET_IDENTIFIERS_NOT_NULL; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Service; + +import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.EntityValidationException; +import com.decathlon.idp_core.domain.model.entity.Entity; +import com.decathlon.idp_core.domain.model.entity.Property; +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.PropertyDefinition; +import com.decathlon.idp_core.domain.port.EntityRepositoryPort; +import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; +import com.decathlon.idp_core.domain.service.property.PropertyValidationService; + +/// Domain validator for [Entity] aggregates. +/// +/// Validation pipeline: +/// 1. Existence checks (template found, entity not duplicated). +/// 2. Syntactic checks on the entity itself (name/identifier, nested properties, relations). +/// 3. Template-driven semantic checks (required, type, rules). +@Service +@AllArgsConstructor +public class EntityValidationService { + + private final EntityRepositoryPort entityRepository; + private final EntityTemplateRepositoryPort entityTemplateRepository; + private final PropertyValidationService propertyValidationService; + + /// Check entity template existence to ensure valid template reference before deeper validations. + /// @param entity the entity whose template existence is to be checked + /// @throws EntityTemplateNotFoundException if the template referenced by the entity does not exist + void checkTemplateExist(final String entity) { + if (!entityTemplateRepository.existsByIdentifier(entity)) { + throw new EntityTemplateNotFoundException("identifier", entity); + } + } + + /// Validates intrinsic entity data integrity and template-driven rules. + /// + /// @param entity the entity to validate + /// @throws EntityValidationException when one or more validation rules are violated + /// @throws EntityAlreadyExistsException if an entity with the same identifier exists for the template + /// @throws EntityTemplateNotFoundException if the referenced template does not exist + void validateEntity(Entity entity) { + checkEntityAlreadyExist(entity); + EntityTemplate template = entityTemplateRepository.findByIdentifier(entity.templateIdentifier()) + .orElseThrow(() -> new EntityTemplateNotFoundException("identifier", entity.templateIdentifier())); + + Violations violations = new Violations(); + + validateEntityHeader(entity, violations); + validatePropertiesShape(entity.properties(), violations); + validateRelationsShape(entity.relations(), violations); + validateAgainstTemplate(template, entity.properties(), violations); + + if (!violations.isEmpty()) { + throw new EntityValidationException(violations.asList()); + } + } + + private void validateEntityHeader(Entity entity, Violations violations) { + violations.addIfBlank(entity.name(), ENTITY_NAME_MANDATORY); + violations.addIfBlank(entity.identifier(), ENTITY_IDENTIFIER_MANDATORY); + } + + private void validatePropertiesShape(List properties, Violations violations) { + if (properties == null) { + return; + } + for (int i = 0; i < properties.size(); i++) { + Property prop = properties.get(i); + if (prop.name() == null || prop.name().isBlank()) { + violations.addIndexed("Property", i, PROPERTY_NAME_MANDATORY); + } + if (prop.value() == null || prop.value().isBlank()) { + violations.addIndexed("Property", i, PROPERTY_VALUE_MANDATORY); + } + } + } + + private void validateRelationsShape(List relations, Violations violations) { + if (relations == null) { + return; + } + for (int i = 0; i < relations.size(); i++) { + Relation rel = relations.get(i); + if (rel.name() == null || rel.name().isBlank()) { + violations.addIndexed("Relation", i, RELATION_NAME_MANDATORY_SIMPLE); + } + if (rel.targetEntityIdentifiers() == null) { + violations.addIndexed("Relation", i, RELATION_TARGET_IDENTIFIERS_NOT_NULL); + } + } + } + + /// Validates entity properties against the template's property definitions, enforcing required fields and value rules. + /// @param template the entity template whose property definitions are used for validation + /// @param properties the list of properties from the entity to validate + /// @param violations the accumulator for validation violation messages + private void validateAgainstTemplate(EntityTemplate template, + List properties, + Violations violations) { + List definitions = Optional.ofNullable(template.propertiesDefinitions()).orElse(List.of()); + Map propertiesByName = Optional.ofNullable(properties).orElse(List.of()).stream() + .filter(p -> p.name() != null) + .collect(Collectors.toMap(Property::name, p -> p, (left, _) -> left)); + + for (PropertyDefinition definition : definitions) { + Property property = propertiesByName.get(definition.name()); + boolean missing = property == null || property.value() == null || property.value().isBlank(); + + if (missing) { + if (definition.required()) { + violations.add(PROPERTY_REQUIRED_MISSING, definition.name(), template.identifier()); + } + continue; + } + + propertyValidationService + .validatePropertyValue(definition, property.value()) + .forEach(violations::add); + } + } + + /// Checks for existing entity with same template and identifier to prevent duplicates. + /// @param entity the entity to check for existence + /// @throws EntityAlreadyExistsException if an entity with the same template and identifier already exists + void checkEntityAlreadyExist(final Entity entity) { + if (entity.identifier() != null + && entityRepository + .findByTemplateIdentifierAndIdentifier(entity.templateIdentifier(), entity.identifier()) + .isPresent()) { + throw new EntityAlreadyExistsException(entity.templateIdentifier(), entity.identifier()); + } + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/Violations.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/Violations.java new file mode 100644 index 00000000..92a3dd62 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/Violations.java @@ -0,0 +1,35 @@ +package com.decathlon.idp_core.domain.service.entity; +import java.util.ArrayList; +import java.util.List; + +/// Mutable accumulator of validation violation messages. +/// +/// Centralises message formatting and indexed-prefix handling so domain +/// validators stay focused on the rule they enforce rather than on string +/// concatenation. Not thread-safe; intended for short-lived per-request use. +final class Violations { + private final List messages = new ArrayList<>(); + void add(String message) { + messages.add(message); + } + void add(String template, Object... args) { + messages.add(template.formatted(args)); + } + void addIfBlank(String value, String message) { + if (value == null || value.isBlank()) { + messages.add(message); + } + } + + /// Adds a violation prefixed with the indexed collection name, e.g. + /// `Property[2]: Property name is mandatory`. + void addIndexed(String collection, int index, String message) { + messages.add("%s[%d]: %s".formatted(collection, index, message)); + } + boolean isEmpty() { + return messages.isEmpty(); + } + List asList() { + return List.copyOf(messages); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java new file mode 100644 index 00000000..983e1e36 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java @@ -0,0 +1,116 @@ +package com.decathlon.idp_core.domain.service.property; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_ENUM_VIOLATION; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_FORMAT_VIOLATION; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_MAX_LENGTH_VIOLATION; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_MAX_VALUE_VIOLATION; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_MIN_LENGTH_VIOLATION; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_MIN_VALUE_VIOLATION; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_REGEX_VIOLATION; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_TYPE_MISMATCH; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +import org.springframework.stereotype.Service; + +import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; +import com.decathlon.idp_core.domain.model.entity_template.PropertyRules; +import com.decathlon.idp_core.domain.model.enums.PropertyFormat; +import com.decathlon.idp_core.domain.model.enums.PropertyType; + +/** + * Domain service validating entity property values against template definitions. + */ +@Service +public class PropertyValidationService { + + private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9+_.-]+@(.+)$"); + private static final Pattern URL_PATTERN = Pattern.compile("^https?://.*$"); + + /** + * Validates a concrete property value against its property definition. + * + * @param propertyDefinition property definition with expected type and optional rules + * @param rawValue raw property value + * @return list of violations for this value; empty when valid + */ + public List validatePropertyValue(PropertyDefinition propertyDefinition, String rawValue) { + return switch (propertyDefinition.type()) { + case STRING -> validateStringPropertyValue(propertyDefinition.name(), rawValue, propertyDefinition.rules()); + case NUMBER -> validateNumberPropertyValue(propertyDefinition.name(), rawValue, propertyDefinition.rules()); + case BOOLEAN -> validateBooleanPropertyValue(propertyDefinition.name(), rawValue); + }; + } + + private List validateStringPropertyValue(String propertyName, String rawValue, PropertyRules rules) { + if (rawValue == null) { + return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.STRING)); + } + + if (rules == null) { + return List.of(); + } + + var violations = new ArrayList(); + + if (rules.minLength() != null && rawValue.length() < rules.minLength()) { + violations.add(PROPERTY_MIN_LENGTH_VIOLATION.formatted(propertyName, rules.minLength())); + } + if (rules.maxLength() != null && rawValue.length() > rules.maxLength()) { + violations.add(PROPERTY_MAX_LENGTH_VIOLATION.formatted(propertyName, rules.maxLength())); + } + if (rules.regex() != null && !Pattern.matches(rules.regex(), rawValue)) { + violations.add(PROPERTY_REGEX_VIOLATION.formatted(propertyName)); + } + if (rules.enumValues() != null && !rules.enumValues().isEmpty() + && rules.enumValues().stream().noneMatch(enumValue -> enumValue.equalsIgnoreCase(rawValue))) { + violations.add(PROPERTY_ENUM_VIOLATION.formatted(propertyName, rules.enumValues())); + } + if (rules.format() != null && !matchesFormat(rules.format(), rawValue)) { + violations.add(PROPERTY_FORMAT_VIOLATION.formatted(propertyName, rules.format())); + } + + return List.copyOf(violations); + } + + private List validateNumberPropertyValue(String propertyName, String rawValue, PropertyRules rules) { + final BigDecimal parsedValue; + try { + parsedValue = new BigDecimal(rawValue); + } catch (RuntimeException exception) { + return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.NUMBER)); + } + + if (rules == null) { + return List.of(); + } + + var violations = new ArrayList(); + + if (rules.minValue() != null && parsedValue.compareTo(BigDecimal.valueOf(rules.minValue())) < 0) { + violations.add(PROPERTY_MIN_VALUE_VIOLATION.formatted(propertyName, rules.minValue())); + } + if (rules.maxValue() != null && parsedValue.compareTo(BigDecimal.valueOf(rules.maxValue())) > 0) { + violations.add(PROPERTY_MAX_VALUE_VIOLATION.formatted(propertyName, rules.maxValue())); + } + + return List.copyOf(violations); + } + + private List validateBooleanPropertyValue(String propertyName, String rawValue) { + if ("true".equalsIgnoreCase(rawValue) || "false".equalsIgnoreCase(rawValue)) { + return List.of(); + } + return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.BOOLEAN)); + } + + private boolean matchesFormat(PropertyFormat format, String value) { + return switch (format) { + case EMAIL -> EMAIL_PATTERN.matcher(value).matches(); + case URL -> URL_PATTERN.matcher(value).matches(); + }; + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java index 8105a5d0..b882f5b4 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java @@ -5,6 +5,7 @@ import java.util.List; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.Customizer; 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 53aacc8a..d6457250 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 @@ -26,9 +26,12 @@ public class SwaggerDescription { public static final String NO_CONTENT_CODE = "204"; public static final String PARTIAL_CONTENT_CODE = "206"; public static final String BAD_REQUEST_CODE = "400"; + public static final String UNAUTHORIZED_CODE = "401"; + public static final String FORBIDDEN_CODE = "403"; public static final String NOT_FOUND_CODE = "404"; public static final String CONFLICT_CODE = "409"; public static final String SERVICE_UNAVAILABLE_CODE = "503"; + public static final String INTERNAL_SERVER_ERROR_CODE = "500"; /// Entity Template API endpoint constants public static final String ENDPOINT_GET_TEMPLATES_SUMMARY = "Get all templates"; @@ -78,11 +81,15 @@ public class SwaggerDescription { public static final String RESPONSE_INVALID_TEMPLATE_DATA = "Invalid template data provided"; public static final String RESPONSE_INVALID_PAGINATION = "Invalid pagination parameters"; public static final String RESPONSE_TEMPLATE_CONFLICT = "Template with this identifier already exists"; + public static final String RESPONSE_ENTITY_CONFLICT = "Entity already exists in this template"; public static final String RESPONSE_ENTITIES_PAGINATED_SUCCESS = "Paginated entities retrieved successfully"; public static final String RESPONSE_ENTITY_FOUND = "Entity found"; public static final String RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER = "Entity not found with the provided identifier"; public static final String RESPONSE_ENTITY_CREATED = "Entity created successfully"; public static final String RESPONSE_INVALID_ENTITY_DATA = "Invalid entity data provided"; + public static final String RESPONSE_UNEXPECTED_SERVER_ERROR = "Unexpected server-side failure"; + public static final String RESPONSE_INSUFFICIENT_RIGHTS = "Insufficient rights"; + public static final String RESPONSE_UNAUTHORIZED = "Unauthorized - Missing or invalid token"; // --- Schema (class) descriptions --- @@ -95,6 +102,8 @@ public class SwaggerDescription { public static final String SCHEMA_PROPERTY_DEFINITION_OUT = "Output DTO for property definition"; public static final String SCHEMA_RELATION_DEFINITION_OUT = "Output DTO for relation definition"; public static final String SCHEMA_PROPERTY_RULES_OUT = "Output DTO for property validation rules"; + public static final String SCHEMA_ENTITY_IN = "Input DTO for creating or updating an entity"; + public static final String SCHEMA_ENTITY_RELATION_IN = "Input DTO for an entity relation instance"; // --- Field descriptions (shared) --- public static final String FIELD_TEMPLATE_ID = "Unique generated identifier of the entity template"; @@ -104,6 +113,13 @@ public class SwaggerDescription { public static final String FIELD_TEMPLATE_PROPERTIES = "List of property definitions for this template"; public static final String FIELD_TEMPLATE_RELATIONS = "List of relation definitions for this template"; + public static final String FIELD_ENTITY_NAME = "Name of the entity"; + public static final String FIELD_ENTITY_IDENTIFIER = "Unique identifier of the entity within the template scope"; + public static final String FIELD_ENTITY_PROPERTIES = "Map of property name to value for this entity"; + public static final String FIELD_ENTITY_RELATIONS = "List of relations for this entity"; + public static final String FIELD_ENTITY_RELATION_NAME = "Name of the relation (must match a template relation definition)"; + public static final String FIELD_ENTITY_RELATION_TARGETS = "List of target entity identifiers for this relation"; + public static final String FIELD_PROPERTY_ID = "Unique identifier of the property definition"; public static final String FIELD_PROPERTY_NAME = "Property name"; public static final String FIELD_PROPERTY_DESCRIPTION = "Property description"; 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 3f94e446..ad37b94b 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 @@ -1,6 +1,7 @@ package com.decathlon.idp_core.infrastructure.adapters.api.controller; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.BAD_REQUEST_CODE; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.CONFLICT_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.CREATED_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITIES_PAGINATED_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITIES_SUMMARY; @@ -8,20 +9,29 @@ 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.FORBIDDEN_CODE; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.INTERNAL_SERVER_ERROR_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.NOT_FOUND_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.OK_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_PAGE_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_SIZE_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_SORT_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITIES_PAGINATED_SUCCESS; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_CONFLICT; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_CREATED; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_FOUND; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_INSUFFICIENT_RIGHTS; 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_TEMPLATE_NOT_FOUND_IDENTIFIER; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_UNAUTHORIZED; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_UNEXPECTED_SERVER_ERROR; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.UNAUTHORIZED_CODE; import static org.springframework.http.HttpStatus.CREATED; import static org.springframework.http.HttpStatus.OK; +import lombok.AllArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -35,7 +45,7 @@ import org.springframework.web.bind.annotation.RestController; import com.decathlon.idp_core.domain.model.entity.Entity; -import com.decathlon.idp_core.domain.service.EntityService; +import com.decathlon.idp_core.domain.service.entity.EntityService; import com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerConfiguration.EntityPageResponse; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityDtoIn; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityDtoOut; @@ -43,7 +53,6 @@ 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 io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.enums.ParameterIn; @@ -51,7 +60,9 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.AllArgsConstructor; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; /// REST API adapter providing entity management endpoints. /// @@ -77,14 +88,14 @@ public class EntityController { /// Supports standard REST pagination parameters and returns appropriate HTTP status codes. /// Template validation is handled by the domain service layer. /// - /// @param page zero-based page index for pagination navigation - /// @param size number of entities per page for response size control + /// @param page zero-based page index for pagination navigation + /// @param size number of entities per page for response size control /// @param templateIdentifier template filter for entity scope limitation /// @return paginated entity DTOs optimized for API consumers @Operation(summary = ENDPOINT_GET_ENTITIES_SUMMARY, description = ENDPOINT_GET_ENTITIES_PAGINATED_DESCRIPTION) @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITIES_PAGINATED_SUCCESS, content = @Content(schema = @Schema(implementation = EntityPageResponse.class))) @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_PAGINATION, content = { - @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class)) }) + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) @Parameter(name = "page", description = PARAM_PAGE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "0"))) @Parameter(name = "size", description = PARAM_SIZE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "20"))) @Parameter(name = "sort", description = PARAM_SORT_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string", defaultValue = "identifier,asc"))) @@ -105,13 +116,13 @@ public Page getEntities( /// Returns HTTP 404 if either template or entity doesn't exist, maintaining REST semantics. /// /// @param templateIdentifier business template identifier for entity scope - /// @param entityIdentifier unique business identifier within template context + /// @param entityIdentifier unique business identifier within template context /// @return entity DTO with full property and relationship data @Operation(summary = ENDPOINT_GET_ENTITY_BY_IDENTIFIER_SUMMARY, description = ENDPOINT_GET_ENTITY_BY_IDENTIFIER_DESCRIPTION) @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_FOUND, content = { - @Content(schema = @Schema(implementation = EntityDtoOut.class)) }) + @Content(schema = @Schema(implementation = EntityDtoOut.class))}) @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = { - @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class)) }) + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) @GetMapping("/{templateIdentifier}/identifier/{entityIdentifier}") @ResponseStatus(OK) public EntityDtoOut getEntity( @@ -128,16 +139,22 @@ public EntityDtoOut getEntity( /// and returns HTTP 201 on success, HTTP 400 for validation errors. /// /// @param templateIdentifier target template identifier for entity creation context - /// @param entityDtoIn entity creation payload with properties and relationships + /// @param entityDtoIn entity creation payload with properties and relationships /// @return created entity DTO with server-generated identifiers @Operation(summary = ENDPOINT_POST_ENTITY_SUMMARY, description = ENDPOINT_POST_ENTITY_DESCRIPTION) - @ApiResponse(responseCode = CREATED_CODE, description = RESPONSE_ENTITY_CREATED, content = { - @Content(schema = @Schema(implementation = EntityDtoOut.class)) }) - @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_ENTITY_DATA, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class)) }) + @ApiResponse(responseCode = CREATED_CODE, description = RESPONSE_ENTITY_CREATED, content = {@Content(schema = @Schema(implementation = EntityDtoOut.class))}) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_ENTITY_DATA, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = UNAUTHORIZED_CODE, description = RESPONSE_UNAUTHORIZED, content = @Content) + @ApiResponse(responseCode = FORBIDDEN_CODE, description = RESPONSE_INSUFFICIENT_RIGHTS, content = @Content) + @ApiResponse(responseCode = CONFLICT_CODE, description = RESPONSE_ENTITY_CONFLICT, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = INTERNAL_SERVER_ERROR_CODE, description = RESPONSE_UNEXPECTED_SERVER_ERROR, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) @PostMapping("/{templateIdentifier}") @ResponseStatus(CREATED) - public EntityDtoOut createEntity(@PathVariable String templateIdentifier, @RequestBody EntityDtoIn entityDtoIn) { + public EntityDtoOut createEntity( + @NotBlank @PathVariable String templateIdentifier, + @Valid @RequestBody EntityDtoIn entityDtoIn) { + Entity entity = entityDtoInMapper.fromEntityDtoInToEntity(entityDtoIn, templateIdentifier); Entity savedEntity = entityService.createEntity(entity); return entityDtoOutMapper.fromEntity(savedEntity); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoIn.java index 33bd3566..0531655a 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoIn.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoIn.java @@ -1,34 +1,79 @@ package com.decathlon.idp_core.infrastructure.adapters.api.dto.in; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_ENTITY_IDENTIFIER; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_ENTITY_NAME; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_ENTITY_PROPERTIES; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_ENTITY_RELATIONS; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_ENTITY_RELATION_NAME; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_ENTITY_RELATION_TARGETS; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.SCHEMA_ENTITY_IN; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.SCHEMA_ENTITY_RELATION_IN; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_NAME_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_IDENTIFIER_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_NAME_MANDATORY_SIMPLE; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TARGET_IDENTIFIERS_NOT_NULL; + import java.util.List; import java.util.Map; import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +/// Input DTO for creating a new entity within a template scope. +/// +/// **Infrastructure validation:** Performs syntactic validation at the API boundary +/// using Jakarta Validation annotations. Semantic validation (schema conformance +/// against template definitions) is handled by the domain service layer. @Data +@Builder @NoArgsConstructor @AllArgsConstructor -@Builder +@JsonNaming(SnakeCaseStrategy.class) +@Schema(description = SCHEMA_ENTITY_IN) public class EntityDtoIn { + + @NotBlank(message = ENTITY_NAME_MANDATORY) + @Schema(description = FIELD_ENTITY_NAME, example = "my-web-service") private String name; + + @NotBlank(message = ENTITY_IDENTIFIER_MANDATORY) + @Schema(description = FIELD_ENTITY_IDENTIFIER, example = "my-web-service") private String identifier; + + @Schema(description = FIELD_ENTITY_PROPERTIES, example = "{\"port\": \"8080\", \"environment\": \"dev\"}") private Map properties; + + @Valid + @Schema(description = FIELD_ENTITY_RELATIONS) private List relations; + /// Input DTO for an entity relation instance. + /// + /// **Infrastructure validation:** Validates relation name presence and target + /// identifiers at the API boundary before domain-level schema checks. @Data + @Builder @NoArgsConstructor @AllArgsConstructor - - @Builder @JsonNaming(SnakeCaseStrategy.class) + @Schema(description = SCHEMA_ENTITY_RELATION_IN) public static class RelationDtoIn { + + @NotBlank(message = RELATION_NAME_MANDATORY_SIMPLE) + @Schema(description = FIELD_ENTITY_RELATION_NAME, example = "depends-on") private String name; + + @NotNull(message = RELATION_TARGET_IDENTIFIERS_NOT_NULL) + @Schema(description = FIELD_ENTITY_RELATION_TARGETS, example = "[\"web-api-1\", \"web-api-2\"]") private List targetEntityIdentifiers; } } 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 a9d6f590..1cfbf69a 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 @@ -11,11 +11,14 @@ import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.method.annotation.HandlerMethodValidationException; import com.decathlon.idp_core.domain.exception.EntityNotFoundException; +import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; import com.decathlon.idp_core.domain.exception.EntityTemplateAlreadyExistsException; import com.decathlon.idp_core.domain.exception.EntityTemplateNameAlreadyExistsException; import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.EntityValidationException; import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; @@ -23,7 +26,6 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.web.servlet.NoHandlerFoundException; import static org.springframework.http.HttpStatus.NOT_FOUND; @@ -83,6 +85,40 @@ public ResponseEntity handleEntityTemplateNameAlreadyExistsExcept return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); } + /// Handles validation exceptions from Spring MVC handler method parameters. + /// + /// **Error aggregation:** Combines multiple validation error messages into a single + /// user-friendly response with HTTP 400 status for client correction. + @ExceptionHandler(HandlerMethodValidationException.class) + public ResponseEntity handleHandlerMethodValidationException(HandlerMethodValidationException ex) { + log.warn("Handler method validation error: {}", ex.getMessage()); + String errorMessage = ex.getAllErrors().stream() + .map(org.springframework.context.MessageSourceResolvable::getDefaultMessage) + .collect(Collectors.joining(", ")); + return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage); + } + + /// Handles domain exception when entities already exist. + /// + /// **HTTP mapping:** Maps domain EntityAlreadyExistsException to HTTP 409 + /// status indicating business rule conflict for duplicate entities. + @ExceptionHandler(EntityAlreadyExistsException.class) + public ResponseEntity handleEntityAlreadyExistsException(EntityAlreadyExistsException ex) { + log.warn("Entity already exists: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + } + + /// Handles domain exception when entity validation fails. + /// + /// **HTTP mapping:** Maps domain EntityValidationException to HTTP 400 status with aggregated + /// validation error messages for client correction. + @ExceptionHandler(EntityValidationException.class) + public ResponseEntity handleEntityValidationException(EntityValidationException ex) { + log.warn("Entity validation failed: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + /// Handles Bean Validation constraint violations from domain model validation. /// /// **Error aggregation:** Combines multiple constraint violation messages into @@ -134,12 +170,6 @@ public ResponseEntity handleEntityNotFoundException(EntityNotFoun ErrorResponse errorResponse = new ErrorResponse(NOT_FOUND.name(), ex.getMessage()); return ResponseEntity.status(NOT_FOUND).body(errorResponse); } - - @ExceptionHandler(NoHandlerFoundException.class) - public ResponseEntity handleNotFound(NoHandlerFoundException e) { - return createErrorResponse(NOT_FOUND, "Resource not found: " + e.getRequestURL()); - } - private String parseHttpMessageNotReadableError(String originalMessage) { if (originalMessage == null) { return "Invalid request body format"; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java index a5e6b8f8..1f6ad3a0 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java @@ -4,6 +4,7 @@ import java.util.List; import java.util.Map; +import lombok.AllArgsConstructor; import org.springframework.stereotype.Component; import com.decathlon.idp_core.domain.model.entity.Entity; @@ -11,8 +12,6 @@ import com.decathlon.idp_core.domain.model.entity.Relation; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityDtoIn; -import lombok.AllArgsConstructor; - /// Adapter mapper for converting API request DTOs to domain [Entity] objects. /// /// **Infrastructure mapping responsibilities:** @@ -28,38 +27,43 @@ /// /// **API contract support:** Enables clean separation between API request format /// and internal domain model structure for maintainable API evolution. - @Component @AllArgsConstructor public class EntityDtoInMapper { + + /// Converts an entity creation request DTO to a domain entity. + /// + /// @param entityDtoIn the entity creation request payload + /// @param entityTemplateIdentifier the target template identifier + /// @return the mapped domain entity with audit fields populated public Entity fromEntityDtoInToEntity(EntityDtoIn entityDtoIn, String entityTemplateIdentifier) { List properties = entityDtoIn.getProperties() == null ? Collections.emptyList() : entityDtoIn.getProperties().entrySet().stream() - .map((Map.Entry entry) -> { - String value; - if (entry.getValue() != null) { - value = String.valueOf(entry.getValue()); - } else { - value = null; - } - return new Property( - null, - entry.getKey(), - value - ); - }) - .toList(); + .map((Map.Entry entry) -> { + String value; + if (entry.getValue() != null) { + value = String.valueOf(entry.getValue()); + } else { + value = null; + } + return new Property( + null, + entry.getKey(), + value + ); + }) + .toList(); List relations = entityDtoIn.getRelations() == null ? Collections.emptyList() : entityDtoIn.getRelations().stream() - .map(relDto -> new Relation( - null, - relDto.getName(), - null, // targetTemplateIdentifier not available in DTO - relDto.getTargetEntityIdentifiers() - )) - .toList(); + .map(relDto -> new Relation( + null, + relDto.getName(), + null, + relDto.getTargetEntityIdentifiers() + )) + .toList(); return new Entity( null, @@ -70,5 +74,4 @@ public Entity fromEntityDtoInToEntity(EntityDtoIn entityDtoIn, String entityTemp relations ); } - } 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 2c295a3e..00721348 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 @@ -20,7 +20,7 @@ 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.service.EntityService; +import com.decathlon.idp_core.domain.service.entity.EntityService; import com.decathlon.idp_core.domain.service.EntityTemplateService; import com.decathlon.idp_core.domain.service.RelationService; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityDtoOut; 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 27ed5ed1..0319c667 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 @@ -4,6 +4,7 @@ import java.util.Optional; import java.util.UUID; +import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; @@ -14,8 +15,6 @@ import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.EntityPersistenceMapper; import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.JpaEntityRepository; -import lombok.RequiredArgsConstructor; - @Component @RequiredArgsConstructor public class PostgresEntityAdapter implements EntityRepositoryPort { @@ -40,8 +39,15 @@ public Optional findByTemplateIdentifierAndIdentifier(String templateIde } @Override - public Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable) { - return jpaEntityRepository.findByTemplateIdentifier(templateIdentifier, pageable).map(mapper::toDomain); + public Optional findByTemplateIdentifierAndName(String templateIdentifier, String entityName) { + return jpaEntityRepository.findByTemplateIdentifierAndName(templateIdentifier, entityName) + .map(mapper::toDomain); + } + + @Override + public Optional> findByTemplateIdentifier(String templateIdentifier, Pageable pageable) { + var pageableEntity = jpaEntityRepository.findByTemplateIdentifier(templateIdentifier, pageable); + return Optional.of(pageableEntity.map(mapper::toDomain)); } @Override diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java index 1debeca2..fcabfcb2 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java @@ -24,5 +24,7 @@ public interface JpaEntityRepository extends JpaRepository findByTemplateIdentifierAndIdentifier(String templateIdentifier, String identifier); + Optional findByTemplateIdentifierAndName(String templateIdentifier, String name); + Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable); } 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 new file mode 100644 index 00000000..a0a2d156 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java @@ -0,0 +1,158 @@ +package com.decathlon.idp_core.domain.service.entity; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; +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.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.EntityNotFoundException; +import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.model.entity.Entity; +import com.decathlon.idp_core.domain.model.entity.EntitySummary; +import com.decathlon.idp_core.domain.port.EntityRepositoryPort; + +@ExtendWith(MockitoExtension.class) +@DisplayName("EntityService Tests") +class EntityServiceTest { + + @Mock + private EntityRepositoryPort entityRepository; + + @Mock + private EntityValidationService entityValidationService; + + @InjectMocks + private EntityService entityService; + + @Test + @DisplayName("Should return entities page by template identifier") + void shouldReturnEntitiesByTemplateIdentifier() { + var pageable = Pageable.ofSize(10); + var entity = entity("template-a", "entity-a", "Entity A"); + var page = new PageImpl<>(List.of(entity)); + + when(entityRepository.findByTemplateIdentifier("template-a", pageable)).thenReturn(Optional.of(page)); + + var result = entityService.getEntitiesByTemplateIdentifier(pageable, "template-a"); + + assertSame(page, result); + verify(entityRepository).findByTemplateIdentifier("template-a", pageable); + } + + @Test + @DisplayName("Should throw when template has no entities page") + void shouldThrowWhenTemplatePageNotFound() { + var pageable = Pageable.ofSize(10); + when(entityRepository.findByTemplateIdentifier("missing-template", pageable)).thenReturn(Optional.empty()); + + assertThrows(EntityTemplateNotFoundException.class, + () -> entityService.getEntitiesByTemplateIdentifier(pageable, "missing-template")); + } + + @Test + @DisplayName("Should return entity summaries by identifiers") + void shouldReturnEntitySummariesByIdentifiers() { + var summaries = List.of(new EntitySummary("service-a", "Service A", "web-service")); + when(entityRepository.findByIdentifierIn(List.of("service-a"))).thenReturn(summaries); + + var result = entityService.getEntitiesSummariesByIndentifiers(List.of("service-a")); + + assertEquals(summaries, result); + verify(entityRepository).findByIdentifierIn(List.of("service-a")); + } + + @Test + @DisplayName("Should return entity by template and identifier") + void shouldReturnEntityByTemplateAndIdentifier() { + var entity = entity("web-service", "catalog-api", "Catalog API"); + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) + .thenReturn(Optional.of(entity)); + + var result = entityService.getEntityByTemplateIdentifierAnIdentifier("web-service", "catalog-api"); + + assertSame(entity, result); + verify(entityValidationService).checkTemplateExist("web-service"); + verify(entityRepository).findByTemplateIdentifierAndIdentifier("web-service", "catalog-api"); + } + + @Test + @DisplayName("Should throw when entity is not found for template") + void shouldThrowWhenEntityNotFoundByTemplateAndIdentifier() { + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "missing-entity")) + .thenReturn(Optional.empty()); + + assertThrows(EntityNotFoundException.class, + () -> entityService.getEntityByTemplateIdentifierAnIdentifier("web-service", "missing-entity")); + } + + @Test + @DisplayName("Should create entity when validations pass") + void shouldCreateEntityWhenValidationsPass() { + var entity = entity("web-service", "catalog-api", "Catalog API"); + when(entityRepository.save(entity)).thenReturn(entity); + + var result = entityService.createEntity(entity); + + assertSame(entity, result); + + InOrder inOrder = inOrder(entityValidationService, entityRepository); + inOrder.verify(entityValidationService).checkTemplateExist("web-service"); + inOrder.verify(entityValidationService).checkEntityAlreadyExist(entity); + inOrder.verify(entityValidationService).validateEntity(entity); + inOrder.verify(entityRepository).save(entity); + } + + @Test + @DisplayName("Should not save when entity already exists") + void shouldNotSaveWhenEntityAlreadyExists() { + var entity = entity("web-service", "catalog-api", "Catalog API"); + var alreadyExists = new EntityAlreadyExistsException("web-service", "catalog-api"); + + org.mockito.Mockito.doThrow(alreadyExists).when(entityValidationService).checkEntityAlreadyExist(entity); + + assertThrows(EntityAlreadyExistsException.class, () -> entityService.createEntity(entity)); + + verify(entityValidationService).checkTemplateExist("web-service"); + verify(entityValidationService).checkEntityAlreadyExist(entity); + verifyNoMoreInteractions(entityRepository); + } + + @Test + @DisplayName("Should stop immediately when template does not exist") + void shouldStopWhenTemplateDoesNotExistOnCreate() { + var entity = entity("missing-template", "catalog-api", "Catalog API"); + var templateNotFound = new EntityTemplateNotFoundException("identifier", "missing-template"); + + org.mockito.Mockito.doThrow(templateNotFound) + .when(entityValidationService) + .checkTemplateExist("missing-template"); + + assertThrows(EntityTemplateNotFoundException.class, () -> entityService.createEntity(entity)); + + verify(entityValidationService).checkTemplateExist("missing-template"); + verifyNoInteractions(entityRepository); + } + + private Entity entity(String templateIdentifier, String identifier, String name) { + return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, List.of(), List.of()); + } +} diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java new file mode 100644 index 00000000..4cdd394d --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java @@ -0,0 +1,235 @@ +package com.decathlon.idp_core.domain.service.entity; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_IDENTIFIER_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_NAME_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_NAME_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_REQUIRED_MISSING; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_VALUE_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_NAME_MANDATORY_SIMPLE; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TARGET_IDENTIFIERS_NOT_NULL; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +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.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.EntityValidationException; +import com.decathlon.idp_core.domain.model.entity.Entity; +import com.decathlon.idp_core.domain.model.entity.Property; +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.PropertyDefinition; +import com.decathlon.idp_core.domain.model.entity_template.PropertyRules; +import com.decathlon.idp_core.domain.model.enums.PropertyType; +import com.decathlon.idp_core.domain.port.EntityRepositoryPort; +import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; +import com.decathlon.idp_core.domain.service.property.PropertyValidationService; + +@ExtendWith(MockitoExtension.class) +@DisplayName("EntityValidationService Tests") +class EntityValidationServiceTest { + + @Mock + private EntityRepositoryPort entityRepository; + + @Mock + private EntityTemplateRepositoryPort entityTemplateRepository; + + @Mock + private PropertyValidationService propertyValidationService; + + @InjectMocks + private EntityValidationService entityValidationService; + + @Test + @DisplayName("Should pass checkTemplateExist when template exists") + void shouldPassCheckTemplateExistWhenTemplateExists() { + when(entityTemplateRepository.existsByIdentifier("web-service")).thenReturn(true); + + assertDoesNotThrow(() -> entityValidationService.checkTemplateExist("web-service")); + } + + @Test + @DisplayName("Should throw checkTemplateExist when template does not exist") + void shouldThrowCheckTemplateExistWhenTemplateDoesNotExist() { + when(entityTemplateRepository.existsByIdentifier("missing-template")).thenReturn(false); + + assertThrows(EntityTemplateNotFoundException.class, + () -> entityValidationService.checkTemplateExist("missing-template")); + } + + @Test + @DisplayName("Should throw when entity with same identifier already exists") + void shouldThrowWhenEntityAlreadyExists() { + var entity = entity("web-service", "catalog-api", "Catalog API", List.of(), List.of()); + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) + .thenReturn(Optional.of(entity)); + + assertThrows(EntityAlreadyExistsException.class, () -> entityValidationService.checkEntityAlreadyExist(entity)); + } + + @Test + @DisplayName("Should not query repository when identifier is null") + void shouldNotQueryRepositoryWhenIdentifierIsNull() { + var entity = entity("web-service", null, "Catalog API", List.of(), List.of()); + + assertDoesNotThrow(() -> entityValidationService.checkEntityAlreadyExist(entity)); + + verify(entityRepository, never()).findByTemplateIdentifierAndIdentifier(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); + } + + @Test + @DisplayName("Should throw when template is missing during validateEntity") + void shouldThrowWhenTemplateMissingDuringValidateEntity() { + var entity = entity("missing-template", "catalog-api", "Catalog API", List.of(), List.of()); + when(entityTemplateRepository.findByIdentifier("missing-template")).thenReturn(Optional.empty()); + + assertThrows(EntityTemplateNotFoundException.class, () -> entityValidationService.validateEntity(entity)); + } + + @Test + @DisplayName("Should aggregate entity, property, relation, required and rule violations") + void shouldAggregateAllViolationsDuringValidateEntity() { + var portDefinition = new PropertyDefinition( + UUID.randomUUID(), + "port", + "Port", + PropertyType.NUMBER, + true, + new PropertyRules(null, null, null, null, null, null, 65535, 1024)); + + var requiredDefinition = new PropertyDefinition( + UUID.randomUUID(), + "ownerEmail", + "Owner email", + PropertyType.STRING, + true, + null); + + var template = new EntityTemplate( + UUID.randomUUID(), + "web-service", + "Web Service", + "desc", + List.of(requiredDefinition, portDefinition), + List.of()); + + var mockedRelation = org.mockito.Mockito.mock(Relation.class); + when(mockedRelation.name()).thenReturn(" "); + when(mockedRelation.targetEntityIdentifiers()).thenReturn(null); + + var entity = entity( + "web-service", + " ", + " ", + List.of(new Property(UUID.randomUUID(), " ", " "), new Property(UUID.randomUUID(), "port", "80")), + List.of(mockedRelation)); + + when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", " ")).thenReturn(Optional.empty()); + when(propertyValidationService.validatePropertyValue(portDefinition, "80")) + .thenReturn(List.of("Property 'port' value must be greater than or equal to 1024")); + + var exception = assertThrows(EntityValidationException.class, () -> entityValidationService.validateEntity(entity)); + + assertEquals(8, exception.getViolations().size()); + assertEquals(ENTITY_NAME_MANDATORY, exception.getViolations().get(0)); + assertEquals(ENTITY_IDENTIFIER_MANDATORY, exception.getViolations().get(1)); + assertEquals("Property[0]: " + PROPERTY_NAME_MANDATORY, exception.getViolations().get(2)); + assertEquals("Property[0]: " + PROPERTY_VALUE_MANDATORY, exception.getViolations().get(3)); + assertEquals("Relation[0]: " + RELATION_NAME_MANDATORY_SIMPLE, exception.getViolations().get(4)); + assertEquals("Relation[0]: " + RELATION_TARGET_IDENTIFIERS_NOT_NULL, exception.getViolations().get(5)); + assertEquals(PROPERTY_REQUIRED_MISSING.formatted("ownerEmail", "web-service"), exception.getViolations().get(6)); + assertEquals("Property 'port' value must be greater than or equal to 1024", exception.getViolations().get(7)); + + verify(propertyValidationService).validatePropertyValue(portDefinition, "80"); + } + + @Test + @DisplayName("Should validate entity successfully when no violations") + void shouldValidateEntitySuccessfullyWhenNoViolations() { + var versionDefinition = new PropertyDefinition( + UUID.randomUUID(), + "version", + "Version", + PropertyType.STRING, + false, + null); + + var template = new EntityTemplate( + UUID.randomUUID(), + "web-service", + "Web Service", + "desc", + List.of(versionDefinition), + List.of()); + + var entity = entity( + "web-service", + "catalog-api", + "Catalog API", + List.of(new Property(UUID.randomUUID(), "version", "1.0.0")), + null); + + when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) + .thenReturn(Optional.empty()); + when(propertyValidationService.validatePropertyValue(versionDefinition, "1.0.0")).thenReturn(List.of()); + + assertDoesNotThrow(() -> entityValidationService.validateEntity(entity)); + verify(propertyValidationService).validatePropertyValue(versionDefinition, "1.0.0"); + } + + @Test + @DisplayName("Should skip property rule validation for missing optional property") + void shouldSkipPropertyRuleValidationWhenOptionalPropertyMissing() { + var optionalDefinition = new PropertyDefinition( + UUID.randomUUID(), + "version", + "Version", + PropertyType.STRING, + false, + null); + + var template = new EntityTemplate( + UUID.randomUUID(), + "web-service", + "Web Service", + "desc", + List.of(optionalDefinition), + List.of()); + + var entity = entity("web-service", "catalog-api", "Catalog API", List.of(), List.of()); + + when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) + .thenReturn(Optional.empty()); + + assertDoesNotThrow(() -> entityValidationService.validateEntity(entity)); + verifyNoInteractions(propertyValidationService); + } + + private Entity entity( + String templateIdentifier, + String identifier, + String name, + List properties, + List relations) { + return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, properties, relations); + } +} diff --git a/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java new file mode 100644 index 00000000..f8416e69 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java @@ -0,0 +1,80 @@ +package com.decathlon.idp_core.domain.service.property; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.decathlon.idp_core.domain.constant.ValidationMessages; +import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; +import com.decathlon.idp_core.domain.model.entity_template.PropertyRules; +import com.decathlon.idp_core.domain.model.enums.PropertyFormat; +import com.decathlon.idp_core.domain.model.enums.PropertyType; + +@DisplayName("PropertyValidationService Tests") +class PropertyValidationServiceTest { + + private final PropertyValidationService service = new PropertyValidationService(); + + @Test + @DisplayName("Should report type mismatch for non numeric NUMBER value") + void shouldReportTypeMismatchWhenNumberValueIsInvalid() { + var definition = propertyDefinition("score", PropertyType.NUMBER, null); + + var violations = service.validatePropertyValue(definition, "not-a-number"); + + assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("score", PropertyType.NUMBER)), violations); + } + + @Test + @DisplayName("Should report string constraint violations") + void shouldReportStringRuleViolations() { + var definition = propertyDefinition("name", PropertyType.STRING, new PropertyRules( + null, + PropertyFormat.EMAIL, + List.of("prod", "dev"), + "^[a-z]+$", + 5, + 3, + null, + null)); + + var violations = service.validatePropertyValue(definition, "AA"); + + assertEquals(4, violations.size()); + } + + @Test + @DisplayName("Should report number bound violations") + void shouldReportNumberBoundViolations() { + var definition = propertyDefinition("size", PropertyType.NUMBER, new PropertyRules( + null, + null, + null, + null, + null, + null, + 10, + 5)); + + var violations = service.validatePropertyValue(definition, "3"); + + assertEquals(List.of(ValidationMessages.PROPERTY_MIN_VALUE_VIOLATION.formatted("size", 5)), violations); + } + + @Test + @DisplayName("Should accept valid boolean value") + void shouldAcceptBooleanValues() { + var definition = propertyDefinition("enabled", PropertyType.BOOLEAN, null); + + var violations = service.validatePropertyValue(definition, "true"); + + assertEquals(List.of(), violations); + } + + private PropertyDefinition propertyDefinition(String name, PropertyType type, PropertyRules rules) { + return new PropertyDefinition(null, name, "description", type, true, rules); + } +} 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 9675a337..ccee22dc 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 @@ -17,21 +17,19 @@ import com.decathlon.idp_core.AbstractIntegrationTest; - /// Integration tests for the EntityController REST API endpoints. - /// These tests verify the behavior of entity retrieval endpoints, including - /// pagination, authentication, and lookup by template identifier and entity - /// identifier. +/// Integration tests for the EntityController REST API endpoints. +/// These tests verify the behavior of entity retrieval endpoints, including +/// pagination, authentication, and lookup by template identifier and entity +/// identifier. public class EntityControllerTest extends AbstractIntegrationTest { - @Autowired - private MockMvc mockMvc; - private static final String TEMPLATE_IDENTIFIER = "web-service"; private static final String ENTITY_IDENTIFIER = "web-api-2"; private static final String ENTITIES_BY_IDENTIFIER_PATH = "/api/v1/entities/{template-identifier}/identifier/{identifier}"; private static final String ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH = "/api/v1/entities/{template-identifier}"; private static final String ENTITY_JSON_FILES_TEST_PATH = "integration_test/json/entity/v1/"; - + @Autowired + private MockMvc mockMvc; /// Tests for GET /api/v1/entities/{template-identifier} endpoint (paginated /// retrieval). @@ -44,9 +42,9 @@ class GetEntitiesByTemplateIdentifierTests { @WithMockUser void getEntities_paginated_200() throws Exception { mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .param("page", "0") - .param("size", "15") - .accept(APPLICATION_JSON)) + .param("page", "0") + .param("size", "15") + .accept(APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(content().contentType(APPLICATION_JSON)) .andExpect(jsonPath("$.content").isArray()) @@ -63,7 +61,7 @@ void getEntities_paginated_200() throws Exception { @WithMockUser void getEntities_paginated_404_when_non_existent_template() throws Exception { mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "non-existent-template-identifier") - .accept(APPLICATION_JSON)) + .accept(APPLICATION_JSON)) .andExpect(status().isNotFound()); } @@ -71,7 +69,7 @@ void getEntities_paginated_404_when_non_existent_template() throws Exception { @DisplayName("Should return 401 without authentication") void getTemplates_paginated_401_without_user_token() throws Exception { mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .accept(APPLICATION_JSON)) + .accept(APPLICATION_JSON)) .andExpect(status().isUnauthorized()); } @@ -81,10 +79,10 @@ void getTemplates_paginated_401_without_user_token() throws Exception { void getEntities_paginated_200_custom() throws Exception { mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "monitoring-service") - .param("page", "1") - .param("size", "5") - .param("sort", "template_identifier,asc") - .accept(APPLICATION_JSON)) + .param("page", "1") + .param("size", "5") + .param("sort", "template_identifier,asc") + .accept(APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(content().contentType(APPLICATION_JSON)) .andExpect(jsonPath("$.content.length()").value(1)) @@ -100,7 +98,7 @@ void getEntities_paginated_200_custom() throws Exception { @WithMockUser void getEntities_invalid_pagination_200() throws Exception { mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .accept(APPLICATION_JSON)) + .accept(APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(content().contentType(APPLICATION_JSON)) .andExpect(jsonPath("$.content").isArray()) @@ -124,7 +122,7 @@ class GetEntitiesByTemplateAndEntityIdentifierTests { @WithMockUser void getEntityByTemplateAndIdentifier_200() throws Exception { mockMvc.perform(get(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, ENTITY_IDENTIFIER) - .accept(APPLICATION_JSON)) + .accept(APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(content().contentType(APPLICATION_JSON)) .andExpect(jsonPath("$.identifier").value(ENTITY_IDENTIFIER)) @@ -136,7 +134,7 @@ void getEntityByTemplateAndIdentifier_200() throws Exception { @WithMockUser void getEntityByTemplateAndIdentifier_404_non_existent_entity() throws Exception { mockMvc.perform(get(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, "non-existent-identifier") - .accept(APPLICATION_JSON)) + .accept(APPLICATION_JSON)) .andExpect(status().isNotFound()); } @@ -145,7 +143,7 @@ void getEntityByTemplateAndIdentifier_404_non_existent_entity() throws Exception @WithMockUser void getEntityByTemplateAndIdentifier_404_non_existent_template() throws Exception { mockMvc.perform(get(ENTITIES_BY_IDENTIFIER_PATH, "non-existent-template", "non-existent-identifier") - .accept(APPLICATION_JSON)) + .accept(APPLICATION_JSON)) .andExpect(status().isNotFound()); } } @@ -159,14 +157,108 @@ class PostEntitiesTests { @DisplayName("Should create entity and return 201") void postEntity_201() throws Exception { mockMvc.perform(MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent(ENTITY_JSON_FILES_TEST_PATH + "postEntity_201.json"))) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(getJsonTestFileContent(ENTITY_JSON_FILES_TEST_PATH + "postEntity_201.json"))) .andExpect(status().isCreated()) .andReturn(); } + @Test + @WithMockUser() + @DisplayName("Should return 400 when required template properties are missing") + void postEntity_400_when_required_properties_missing() throws Exception { + var payload = """ + { + "name": "web-service-missing-required", + "identifier": "web-service-missing-required", + "properties": { + "port": "8080" + } + } + """; + + mockMvc.perform(MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(payload)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(org.hamcrest.Matchers.containsString("Property 'applicationName' is required"))); + } + + @Test + @WithMockUser() + @DisplayName("Should return 400 when property type does not match template") + void postEntity_400_when_property_type_mismatch() throws Exception { + var payload = """ + { + "name": "web-service-invalid-type", + "identifier": "web-service-invalid-type", + "properties": { + "applicationName": "catalog-api", + "ownerEmail": "owner@example.com", + "port": "not-a-number", + "environment": "DEV", + "version": "1.2.3", + "teamName": "platform-team", + "baseUrl": "https://catalog.example.com", + "protocol": "HTTP", + "programmingLanguage": "JAVA" + } + } + """; + + mockMvc.perform(MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(payload)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(org.hamcrest.Matchers.containsString("Property 'port' must be of type NUMBER"))); + } + + @Test + @WithMockUser() + @DisplayName("Should return 400 when property rules are not respected") + void postEntity_400_when_property_rules_not_respected() throws Exception { + var payload = """ + { + "name": "web-service-invalid-rules", + "identifier": "web-service-invalid-rules", + "properties": { + "applicationName": "catalog-api", + "ownerEmail": "invalid-email", + "port": "80", + "environment": "DEV", + "version": "1.2.3", + "teamName": "platform-team", + "baseUrl": "invalid-url", + "protocol": "HTTP", + "programmingLanguage": "JAVA" + } + } + """; + + mockMvc.perform(MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(payload)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(org.hamcrest.Matchers.allOf( + org.hamcrest.Matchers.containsString("Property 'ownerEmail' does not match expected format"), + org.hamcrest.Matchers.containsString("Property 'ownerEmail' does not match required format EMAIL"), + org.hamcrest.Matchers.containsString("Property 'baseUrl' does not match expected format"), + org.hamcrest.Matchers.containsString("Property 'baseUrl' does not match required format URL"), + org.hamcrest.Matchers.containsString("Property 'port' value must be greater than or equal to 1024") + ))); + } + } } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java index b6c873c9..d204659e 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java @@ -311,10 +311,6 @@ void postTemplate_400_name_invalid_pattern() throws Exception { /// This test verifies that: /// - Validation error message indicates property definitions are /// @throws Exception if the MockMvc request fails - /// Tests the POST /api/v1/entity-templates endpoint when property name field is - /// missing. - /// This test verifies that: - /// @throws Exception if the MockMvc request fails @Test @WithMockUser() @DisplayName("Returns 400 when property name is missing") diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java index 4d2a73bd..c8503d70 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java @@ -25,10 +25,11 @@ import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; +import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; import com.decathlon.idp_core.domain.exception.EntityTemplateAlreadyExistsException; import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.EntityValidationException; import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse; - import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; @@ -105,6 +106,45 @@ void shouldHandleEntityTemplateAlreadyExistsException() { assertEquals(HttpStatus.CONFLICT.name(), body.getError()); assertEquals(expectedMessage, body.getErrorDescription()); } + + /// Tests the handling of [EntityAlreadyExistsException] by the [ApiExceptionHandler]. + /// + /// **This test verifies that:** + /// - EntityAlreadyExistsException is properly caught and handled + /// - HTTP 409 Conflict status is returned + /// - Error response contains the original domain exception message + @Test + @DisplayName("Should handle EntityAlreadyExistsException with 409 status") + void shouldHandleEntityAlreadyExistsException() { + // Given + EntityAlreadyExistsException exception = new EntityAlreadyExistsException("my-web-service", "api-gateway"); + + // When + ResponseEntity response = exceptionHandler.handleEntityAlreadyExistsException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.CONFLICT.name(), body.getError()); + assertEquals(exception.getMessage(), body.getErrorDescription()); + } + + @Test + @DisplayName("Should handle EntityValidationException with 400 status") + void shouldHandleEntityValidationException() { + EntityValidationException exception = new EntityValidationException(java.util.List.of("Invalid property")); + + ResponseEntity response = exceptionHandler.handleEntityValidationException(exception); + + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + assertEquals(exception.getMessage(), body.getErrorDescription()); + } } @Nested @@ -226,6 +266,29 @@ private ConstraintViolation createMockConstraintViolation(String message @DisplayName("HTTP Message Exception Handling") class HttpMessageExceptionTests { + /// Provides test data for [HttpMessageNotReadableException] scenarios. + /// Each argument contains: input message and expected error description. + static Stream httpMessageNotReadableExceptionTestData() { + return Stream.of( + Arguments.of( + "Required request body is missing: public ResponseEntity", + "Request body is required" + ), + Arguments.of( + "JSON parse error: Unexpected character", + "Invalid JSON format in request body" + ), + Arguments.of( + "Cannot deserialize value of type `PropertyType` from String \"INVALID_TYPE\": not one of the values accepted for Enum class", + "Invalid value 'INVALID_TYPE' for property 'type'" + ), + Arguments.of( + "Cannot deserialize value of type `UnknownEnum` from String \"VALUE\": not one of the values accepted for Enum class", + "Invalid enum value in request body" + ) + ); + } + /// Tests the handling of [HttpMessageNotReadableException] when exception message is null. /// /// **This test verifies that:** @@ -252,29 +315,6 @@ void shouldHandleHttpMessageNotReadableExceptionWithNullMessage() { assertEquals("Invalid request body format", body.getErrorDescription()); } - /// Provides test data for [HttpMessageNotReadableException] scenarios. - /// Each argument contains: input message and expected error description. - static Stream httpMessageNotReadableExceptionTestData() { - return Stream.of( - Arguments.of( - "Required request body is missing: public ResponseEntity", - "Request body is required" - ), - Arguments.of( - "JSON parse error: Unexpected character", - "Invalid JSON format in request body" - ), - Arguments.of( - "Cannot deserialize value of type `PropertyType` from String \"INVALID_TYPE\": not one of the values accepted for Enum class", - "Invalid value 'INVALID_TYPE' for property 'type'" - ), - Arguments.of( - "Cannot deserialize value of type `UnknownEnum` from String \"VALUE\": not one of the values accepted for Enum class", - "Invalid enum value in request body" - ) - ); - } - /// Parameterized test for handling [HttpMessageNotReadableException] with various error scenarios. /// /// **This test verifies that different types of HttpMessageNotReadableException are properly @@ -290,7 +330,7 @@ static Stream httpMessageNotReadableExceptionTestData() { /// - User-friendly error description is provided /// - Error response structure is consistent /// - /// @param originalMessage the original exception message to be processed + /// @param originalMessage the original exception message to be processed /// @param expectedErrorDescription the expected user-friendly error description @ParameterizedTest @MethodSource("httpMessageNotReadableExceptionTestData") diff --git a/src/test/resources/integration_test/json/entity-template/v1/postEntityTemplate_400_properties_empty.json b/src/test/resources/integration_test/json/entity-template/v1/postEntityTemplate_400_properties_empty.json new file mode 100644 index 00000000..4c00a50a --- /dev/null +++ b/src/test/resources/integration_test/json/entity-template/v1/postEntityTemplate_400_properties_empty.json @@ -0,0 +1,6 @@ +{ + "identifier": "temp-test-0", + "description": "This is a test template", + "properties_definitions": [], + "relations_definitions": [] +} diff --git a/src/test/resources/integration_test/json/entity-template/v1/putEntityTemplate_400_withoutPropertiesDefinitions.json b/src/test/resources/integration_test/json/entity-template/v1/putEntityTemplate_400_withoutPropertiesDefinitions.json new file mode 100644 index 00000000..996e5608 --- /dev/null +++ b/src/test/resources/integration_test/json/entity-template/v1/putEntityTemplate_400_withoutPropertiesDefinitions.json @@ -0,0 +1,6 @@ +{ + "identifier": "web-service", + "name": "web-service", + "description": "This is a test template", + "relations_definitions": [] +} diff --git a/src/test/resources/integration_test/json/entity/v1/postEntity_201.json b/src/test/resources/integration_test/json/entity/v1/postEntity_201.json index 82367a22..35938589 100644 --- a/src/test/resources/integration_test/json/entity/v1/postEntity_201.json +++ b/src/test/resources/integration_test/json/entity/v1/postEntity_201.json @@ -1,9 +1,15 @@ { - "name": "microservice-2", - "identifier": "microservice-2", + "name": "web-service-valid-1", + "identifier": "web-service-valid-1", "properties": { + "applicationName": "catalog-api", + "ownerEmail": "owner@example.com", "port": "8080", - "environment": "dev" - }, - "relations": [] + "environment": "DEV", + "version": "1.2.3", + "teamName": "platform-team", + "baseUrl": "https://catalog.example.com", + "protocol": "HTTP", + "programmingLanguage": "JAVA" + } } diff --git a/src/test/resources/integration_test/json/entity/v1/postEntity_201_minimal.json b/src/test/resources/integration_test/json/entity/v1/postEntity_201_minimal.json new file mode 100644 index 00000000..678a6bf1 --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/postEntity_201_minimal.json @@ -0,0 +1,4 @@ +{ + "name": "microservice-minimal", + "identifier": "microservice-minimal" +} diff --git a/src/test/resources/integration_test/json/entity/v1/postEntity_201_with_relations.json b/src/test/resources/integration_test/json/entity/v1/postEntity_201_with_relations.json new file mode 100644 index 00000000..86438624 --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/postEntity_201_with_relations.json @@ -0,0 +1,14 @@ +{ + "name": "microservice-with-relations", + "identifier": "microservice-with-relations", + "properties": { + "port": "9090", + "environment": "staging" + }, + "relations": [ + { + "name": "depends-on", + "target_entity_identifiers": ["web-api-1"] + } + ] +} diff --git a/src/test/resources/integration_test/json/entity/v1/postEntity_400_identifier_missing.json b/src/test/resources/integration_test/json/entity/v1/postEntity_400_identifier_missing.json new file mode 100644 index 00000000..20e6fe29 --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/postEntity_400_identifier_missing.json @@ -0,0 +1,7 @@ +{ + "name": "microservice-3", + "properties": { + "port": "8080" + }, + "relations": [] +} diff --git a/src/test/resources/integration_test/json/entity/v1/postEntity_400_name_missing.json b/src/test/resources/integration_test/json/entity/v1/postEntity_400_name_missing.json new file mode 100644 index 00000000..7c0b057e --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/postEntity_400_name_missing.json @@ -0,0 +1,7 @@ +{ + "identifier": "microservice-3", + "properties": { + "port": "8080" + }, + "relations": [] +} diff --git a/src/test/resources/integration_test/json/entity/v1/postEntity_400_property_value_blank.json b/src/test/resources/integration_test/json/entity/v1/postEntity_400_property_value_blank.json new file mode 100644 index 00000000..570184e7 --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/postEntity_400_property_value_blank.json @@ -0,0 +1,8 @@ +{ + "name": "entity-prop-no-value", + "identifier": "entity-prop-no-value", + "properties": { + "applicationName": "" + }, + "relations": [] +} diff --git a/src/test/resources/integration_test/json/entity/v1/postEntity_400_relation_name_blank.json b/src/test/resources/integration_test/json/entity/v1/postEntity_400_relation_name_blank.json new file mode 100644 index 00000000..9bb5cbcc --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/postEntity_400_relation_name_blank.json @@ -0,0 +1,11 @@ +{ + "name": "entity-rel-no-name", + "identifier": "entity-rel-no-name", + "properties": {}, + "relations": [ + { + "name": "", + "target_entity_identifiers": ["some-target"] + } + ] +} diff --git a/src/test/resources/integration_test/json/entity/v1/postEntity_409_duplicate.json b/src/test/resources/integration_test/json/entity/v1/postEntity_409_duplicate.json new file mode 100644 index 00000000..e850f2b8 --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/postEntity_409_duplicate.json @@ -0,0 +1,6 @@ +{ + "name": "Web API 1 duplicate", + "identifier": "web-api-1", + "properties": {}, + "relations": [] +} From e26e26c56affc05d2b5172dd594fbe2621fa1699 Mon Sep 17 00:00:00 2001 From: renny vandomber Date: Mon, 4 May 2026 09:23:01 +0200 Subject: [PATCH 02/53] feat(core): fix sonar issue and copilot review --- .../service/property/PropertyValidationService.java | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java index 983e1e36..e769e0dc 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java @@ -12,6 +12,8 @@ import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Pattern; import org.springframework.stereotype.Service; @@ -30,6 +32,10 @@ public class PropertyValidationService { private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9+_.-]+@(.+)$"); private static final Pattern URL_PATTERN = Pattern.compile("^https?://.*$"); + /// Cache of compiled regex patterns keyed by their source string. + /// Avoids recompiling the same pattern on every property validation call. + private final Map patternCache = new ConcurrentHashMap<>(); + /** * Validates a concrete property value against its property definition. * @@ -62,7 +68,8 @@ private List validateStringPropertyValue(String propertyName, String raw if (rules.maxLength() != null && rawValue.length() > rules.maxLength()) { violations.add(PROPERTY_MAX_LENGTH_VIOLATION.formatted(propertyName, rules.maxLength())); } - if (rules.regex() != null && !Pattern.matches(rules.regex(), rawValue)) { + if (rules.regex() != null + && !patternCache.computeIfAbsent(rules.regex(), Pattern::compile).matcher(rawValue).matches()) { violations.add(PROPERTY_REGEX_VIOLATION.formatted(propertyName)); } if (rules.enumValues() != null && !rules.enumValues().isEmpty() @@ -80,7 +87,7 @@ private List validateNumberPropertyValue(String propertyName, String raw final BigDecimal parsedValue; try { parsedValue = new BigDecimal(rawValue); - } catch (RuntimeException exception) { + } catch (NumberFormatException _) { return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.NUMBER)); } From fd1b3542c653c45bc7d6a5e52ba707e44ac3b951 Mon Sep 17 00:00:00 2001 From: renny vandomber Date: Mon, 4 May 2026 09:47:05 +0200 Subject: [PATCH 03/53] feat(core): fix sonar review --- .../adapters/api/configuration/SecurityConfiguration.java | 1 - .../infrastructure/adapters/api/controller/EntityController.java | 1 - 2 files changed, 2 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java index b882f5b4..8105a5d0 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java @@ -5,7 +5,6 @@ import java.util.List; import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.Customizer; 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 ad37b94b..c2215346 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 @@ -62,7 +62,6 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; /// REST API adapter providing entity management endpoints. /// From a85955429a888822eea7e8d8409990c573d72aa6 Mon Sep 17 00:00:00 2001 From: renny vandomber Date: Mon, 4 May 2026 11:44:56 +0200 Subject: [PATCH 04/53] feat(core): fix sonar qube and test --- .../domain/constant/ValidationMessages.java | 17 - .../model/entity/EntityJpaEntity.java | 4 +- ..._entity_identifier_unique_to_composite.sql | 9 + .../PropertyValidationServiceTest.java | 354 +++++++++++++++--- .../api/handler/ApiExceptionHandlerTest.java | 67 ++++ 5 files changed, 388 insertions(+), 63 deletions(-) create mode 100644 src/main/resources/db/migration/V3_3__change_entity_identifier_unique_to_composite.sql 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 369f4348..a5c0d0f5 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 @@ -29,15 +29,6 @@ public class ValidationMessages { public static final String PROPERTY_REGEX_VIOLATION = "Property '%s' does not match expected format"; public static final String PROPERTY_ENUM_VIOLATION = "Property '%s' must be one of %s"; public static final String PROPERTY_FORMAT_VIOLATION = "Property '%s' does not match required format %s"; - public static final String PROPERTY_RULES_NUMERIC_RULE_NOT_ALLOWED = - "Numeric rule '{rule}' is not allowed for STRING properties"; - public static final String PROPERTY_RULES_MIN_LENGTH_NON_NEGATIVE = - "Rule 'min_length' must be greater than or equal to 0"; - public static final String PROPERTY_RULES_MAX_LENGTH_POSITIVE = - "Rule 'max_length' must be greater than 0"; - public static final String PROPERTY_RULES_BOOLEAN_NOT_ALLOWED = - "BOOLEAN properties do not allow validation rules"; - public static final String PROPERTY_RULES_REGEX_INVALID = "Invalid regex pattern: %s"; // Relation Definition validation messages public static final String RELATION_NAME_MANDATORY = "Relation name is mandatory and cannot be blank"; @@ -54,12 +45,4 @@ public class ValidationMessages { public static final String ENTITY_NOT_FOUND = "Entity not found with template identifier %s and entity identifier '%s'"; public static final String ENTITY_ALREADY_EXISTS = "Entity with name '%s' already exists for template '%s'"; public static final String ENTITY_VALIDATION_FAILED = "Entity validation failed: "; - - public static String minMaxConstraintViolated(String ruleName) { - return "Rule 'min_" + ruleName + "' must be lower than or equal to 'max_" + ruleName + "'"; - } - - public static String ruleNotAllowed(String ruleName, String propertyType) { - return "Rule '" + ruleName + "' is not allowed for " + propertyType + " properties"; - } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/EntityJpaEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/EntityJpaEntity.java index 75c3337e..9e4e0e2b 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/EntityJpaEntity.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/EntityJpaEntity.java @@ -21,7 +21,9 @@ @jakarta.persistence.Entity @Data -@Table(name = "entity") +@Table(name = "entity", uniqueConstraints = { + @UniqueConstraint(columnNames = {"identifier", "template_identifier"}) +}) @Builder @NoArgsConstructor @AllArgsConstructor diff --git a/src/main/resources/db/migration/V3_3__change_entity_identifier_unique_to_composite.sql b/src/main/resources/db/migration/V3_3__change_entity_identifier_unique_to_composite.sql new file mode 100644 index 00000000..11255aa8 --- /dev/null +++ b/src/main/resources/db/migration/V3_3__change_entity_identifier_unique_to_composite.sql @@ -0,0 +1,9 @@ +-- Change unique constraint on entity table: +-- Drop the unique constraint on identifier alone +-- Add a composite unique constraint on (identifier, template_identifier) +-- This allows the same identifier to exist across different templates + +ALTER TABLE entity DROP CONSTRAINT entity_identifier_key; + +ALTER TABLE entity ADD CONSTRAINT entity_identifier_template_identifier_key + UNIQUE (identifier, template_identifier); diff --git a/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java index f8416e69..3f35fc0a 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java @@ -5,6 +5,7 @@ import java.util.List; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import com.decathlon.idp_core.domain.constant.ValidationMessages; @@ -18,60 +19,323 @@ class PropertyValidationServiceTest { private final PropertyValidationService service = new PropertyValidationService(); - @Test - @DisplayName("Should report type mismatch for non numeric NUMBER value") - void shouldReportTypeMismatchWhenNumberValueIsInvalid() { - var definition = propertyDefinition("score", PropertyType.NUMBER, null); + @Nested + @DisplayName("STRING validation") + class StringValidationTests { - var violations = service.validatePropertyValue(definition, "not-a-number"); + @Test + @DisplayName("Should report type mismatch when STRING value is null") + void shouldReportTypeMismatchWhenStringValueIsNull() { + var definition = propertyDefinition("label", PropertyType.STRING, null); - assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("score", PropertyType.NUMBER)), violations); - } + var violations = service.validatePropertyValue(definition, null); + + assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("label", PropertyType.STRING)), violations); + } + + @Test + @DisplayName("Should return no violations when STRING has no rules") + void shouldReturnNoViolationsWhenStringHasNoRules() { + var definition = propertyDefinition("label", PropertyType.STRING, null); + + var violations = service.validatePropertyValue(definition, "hello"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should return no violations when STRING value satisfies all rules") + void shouldReturnNoViolationsWhenStringPassesAllRules() { + var rules = new PropertyRules(null, null, List.of("dev", "prod"), "^[a-z]+$", 10, 2, null, null); + var definition = propertyDefinition("env", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "dev"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should report minLength violation") + void shouldReportMinLengthViolation() { + var rules = new PropertyRules(null, null, null, null, null, 5, null, null); + var definition = propertyDefinition("name", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "ab"); + + assertEquals(List.of(ValidationMessages.PROPERTY_MIN_LENGTH_VIOLATION.formatted("name", 5)), violations); + } + + @Test + @DisplayName("Should report maxLength violation") + void shouldReportMaxLengthViolation() { + var rules = new PropertyRules(null, null, null, null, 5, null, null, null); + var definition = propertyDefinition("name", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "too-long-value"); + + assertEquals(List.of(ValidationMessages.PROPERTY_MAX_LENGTH_VIOLATION.formatted("name", 5)), violations); + } + + @Test + @DisplayName("Should report regex violation") + void shouldReportRegexViolation() { + var rules = new PropertyRules(null, null, null, "^[0-9]+$", null, null, null, null); + var definition = propertyDefinition("code", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "abc"); + + assertEquals(List.of(ValidationMessages.PROPERTY_REGEX_VIOLATION.formatted("code")), violations); + } + + @Test + @DisplayName("Should accept value matching regex") + void shouldAcceptValueMatchingRegex() { + var rules = new PropertyRules(null, null, null, "^[0-9]+$", null, null, null, null); + var definition = propertyDefinition("code", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "12345"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should report enum violation when value not in allowed list") + void shouldReportEnumViolation() { + var rules = new PropertyRules(null, null, List.of("ACTIVE", "INACTIVE"), null, null, null, null, null); + var definition = propertyDefinition("status", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "UNKNOWN"); + + assertEquals(List.of(ValidationMessages.PROPERTY_ENUM_VIOLATION.formatted("status", List.of("ACTIVE", "INACTIVE"))), violations); + } + + @Test + @DisplayName("Should accept enum value with case-insensitive match") + void shouldAcceptEnumValueCaseInsensitive() { + var rules = new PropertyRules(null, null, List.of("ACTIVE", "INACTIVE"), null, null, null, null, null); + var definition = propertyDefinition("status", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "active"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should skip enum check when enumValues is empty") + void shouldSkipEnumCheckWhenEnumValuesIsEmpty() { + var rules = new PropertyRules(null, null, List.of(), null, null, null, null, null); + var definition = propertyDefinition("status", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "anything"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should report format violation for invalid EMAIL") + void shouldReportFormatViolationForInvalidEmail() { + var rules = new PropertyRules(null, PropertyFormat.EMAIL, null, null, null, null, null, null); + var definition = propertyDefinition("email", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "not-an-email"); + + assertEquals(List.of(ValidationMessages.PROPERTY_FORMAT_VIOLATION.formatted("email", PropertyFormat.EMAIL)), violations); + } + + @Test + @DisplayName("Should accept valid EMAIL format") + void shouldAcceptValidEmailFormat() { + var rules = new PropertyRules(null, PropertyFormat.EMAIL, null, null, null, null, null, null); + var definition = propertyDefinition("email", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "user@example.com"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should report format violation for invalid URL") + void shouldReportFormatViolationForInvalidUrl() { + var rules = new PropertyRules(null, PropertyFormat.URL, null, null, null, null, null, null); + var definition = propertyDefinition("url", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "not-a-url"); + + assertEquals(List.of(ValidationMessages.PROPERTY_FORMAT_VIOLATION.formatted("url", PropertyFormat.URL)), violations); + } + + @Test + @DisplayName("Should accept valid URL format") + void shouldAcceptValidUrlFormat() { + var rules = new PropertyRules(null, PropertyFormat.URL, null, null, null, null, null, null); + var definition = propertyDefinition("url", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "https://github.com/org/repo"); - @Test - @DisplayName("Should report string constraint violations") - void shouldReportStringRuleViolations() { - var definition = propertyDefinition("name", PropertyType.STRING, new PropertyRules( - null, - PropertyFormat.EMAIL, - List.of("prod", "dev"), - "^[a-z]+$", - 5, - 3, - null, - null)); - - var violations = service.validatePropertyValue(definition, "AA"); - - assertEquals(4, violations.size()); + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should report multiple violations at once") + void shouldReportMultipleStringViolations() { + var rules = new PropertyRules(null, PropertyFormat.EMAIL, List.of("prod", "dev"), "^[a-z]+$", 5, 3, null, null); + var definition = propertyDefinition("name", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "AA"); + + assertEquals(4, violations.size()); + } + + @Test + @DisplayName("Should use cached pattern for repeated regex validations") + void shouldUseCachedPatternForRepeatedRegex() { + var rules = new PropertyRules(null, null, null, "^[a-z]+$", null, null, null, null); + var definition = propertyDefinition("code", PropertyType.STRING, rules); + + // Validate twice with the same regex to exercise the cache + var violations1 = service.validatePropertyValue(definition, "abc"); + var violations2 = service.validatePropertyValue(definition, "def"); + + assertEquals(List.of(), violations1); + assertEquals(List.of(), violations2); + } } - @Test - @DisplayName("Should report number bound violations") - void shouldReportNumberBoundViolations() { - var definition = propertyDefinition("size", PropertyType.NUMBER, new PropertyRules( - null, - null, - null, - null, - null, - null, - 10, - 5)); - - var violations = service.validatePropertyValue(definition, "3"); - - assertEquals(List.of(ValidationMessages.PROPERTY_MIN_VALUE_VIOLATION.formatted("size", 5)), violations); + @Nested + @DisplayName("NUMBER validation") + class NumberValidationTests { + + @Test + @DisplayName("Should report type mismatch for non-numeric NUMBER value") + void shouldReportTypeMismatchWhenNumberValueIsInvalid() { + var definition = propertyDefinition("score", PropertyType.NUMBER, null); + + var violations = service.validatePropertyValue(definition, "not-a-number"); + + assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("score", PropertyType.NUMBER)), violations); + } + + @Test + @DisplayName("Should return no violations when NUMBER has no rules") + void shouldReturnNoViolationsWhenNumberHasNoRules() { + var definition = propertyDefinition("count", PropertyType.NUMBER, null); + + var violations = service.validatePropertyValue(definition, "42"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should return no violations when NUMBER is within bounds") + void shouldReturnNoViolationsWhenNumberIsWithinBounds() { + var rules = new PropertyRules(null, null, null, null, null, null, 100, 0); + var definition = propertyDefinition("score", PropertyType.NUMBER, rules); + + var violations = service.validatePropertyValue(definition, "50"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should report minValue violation") + void shouldReportMinValueViolation() { + var rules = new PropertyRules(null, null, null, null, null, null, 10, 5); + var definition = propertyDefinition("size", PropertyType.NUMBER, rules); + + var violations = service.validatePropertyValue(definition, "3"); + + assertEquals(List.of(ValidationMessages.PROPERTY_MIN_VALUE_VIOLATION.formatted("size", 5)), violations); + } + + @Test + @DisplayName("Should report maxValue violation") + void shouldReportMaxValueViolation() { + var rules = new PropertyRules(null, null, null, null, null, null, 10, 0); + var definition = propertyDefinition("size", PropertyType.NUMBER, rules); + + var violations = service.validatePropertyValue(definition, "15"); + + assertEquals(List.of(ValidationMessages.PROPERTY_MAX_VALUE_VIOLATION.formatted("size", 10)), violations); + } + + @Test + @DisplayName("Should report both minValue and maxValue violations") + void shouldReportBothMinAndMaxViolations() { + // minValue > maxValue edge case — value below min triggers min violation + var rules = new PropertyRules(null, null, null, null, null, null, 5, 10); + var definition = propertyDefinition("range", PropertyType.NUMBER, rules); + + var violations = service.validatePropertyValue(definition, "7"); + + // 7 < 10 (minValue) → min violation; 7 > 5 (maxValue) → max violation + assertEquals(2, violations.size()); + } + + @Test + @DisplayName("Should accept decimal number values") + void shouldAcceptDecimalNumberValues() { + var rules = new PropertyRules(null, null, null, null, null, null, 100, 0); + var definition = propertyDefinition("rate", PropertyType.NUMBER, rules); + + var violations = service.validatePropertyValue(definition, "99.5"); + + assertEquals(List.of(), violations); + } } - @Test - @DisplayName("Should accept valid boolean value") - void shouldAcceptBooleanValues() { - var definition = propertyDefinition("enabled", PropertyType.BOOLEAN, null); + @Nested + @DisplayName("BOOLEAN validation") + class BooleanValidationTests { + + @Test + @DisplayName("Should accept 'true' value") + void shouldAcceptTrueValue() { + var definition = propertyDefinition("enabled", PropertyType.BOOLEAN, null); + + var violations = service.validatePropertyValue(definition, "true"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should accept 'false' value") + void shouldAcceptFalseValue() { + var definition = propertyDefinition("enabled", PropertyType.BOOLEAN, null); + + var violations = service.validatePropertyValue(definition, "false"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should accept case-insensitive 'TRUE'") + void shouldAcceptUppercaseTrue() { + var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); + + var violations = service.validatePropertyValue(definition, "TRUE"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should accept case-insensitive 'FALSE'") + void shouldAcceptUppercaseFalse() { + var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); + + var violations = service.validatePropertyValue(definition, "FALSE"); + + assertEquals(List.of(), violations); + } + + @Test + @DisplayName("Should report type mismatch for invalid boolean value") + void shouldReportTypeMismatchForInvalidBoolean() { + var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); - var violations = service.validatePropertyValue(definition, "true"); + var violations = service.validatePropertyValue(definition, "yes"); - assertEquals(List.of(), violations); + assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("flag", PropertyType.BOOLEAN)), violations); + } } private PropertyDefinition propertyDefinition(String name, PropertyType type, PropertyRules rules) { diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java index c8503d70..3433491e 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java @@ -26,7 +26,9 @@ import org.springframework.web.bind.MethodArgumentNotValidException; import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.EntityNotFoundException; import com.decathlon.idp_core.domain.exception.EntityTemplateAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.EntityTemplateNameAlreadyExistsException; import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; import com.decathlon.idp_core.domain.exception.EntityValidationException; import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse; @@ -145,6 +147,55 @@ void shouldHandleEntityValidationException() { assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); assertEquals(exception.getMessage(), body.getErrorDescription()); } + + /// Tests the handling of [EntityTemplateNameAlreadyExistsException] by the [ApiExceptionHandler]. + /// + /// **This test verifies that:** + /// - EntityTemplateNameAlreadyExistsException is properly caught and handled + /// - HTTP 409 Conflict status is returned + /// - Error response contains the correct error status and description + @Test + @DisplayName("Should handle EntityTemplateNameAlreadyExistsException with 409 status") + void shouldHandleEntityTemplateNameAlreadyExistsException() { + // Given + String name = "Duplicate Name"; + EntityTemplateNameAlreadyExistsException exception = new EntityTemplateNameAlreadyExistsException(name); + + // When + ResponseEntity response = exceptionHandler.handleEntityTemplateNameAlreadyExistsException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.CONFLICT.name(), body.getError()); + assertEquals(exception.getMessage(), body.getErrorDescription()); + } + + /// Tests the handling of [EntityNotFoundException] by the [ApiExceptionHandler]. + /// + /// **This test verifies that:** + /// - EntityNotFoundException is properly caught and handled + /// - HTTP 404 Not Found status is returned + /// - Error response contains the entity-specific context message + @Test + @DisplayName("Should handle EntityNotFoundException with 404 status") + void shouldHandleEntityNotFoundException() { + // Given + EntityNotFoundException exception = new EntityNotFoundException("web-service", "my-entity"); + + // When + ResponseEntity response = exceptionHandler.handleEntityNotFoundException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.NOT_FOUND.name(), body.getError()); + assertEquals(exception.getMessage(), body.getErrorDescription()); + } } @Nested @@ -282,9 +333,25 @@ static Stream httpMessageNotReadableExceptionTestData() { "Cannot deserialize value of type `PropertyType` from String \"INVALID_TYPE\": not one of the values accepted for Enum class", "Invalid value 'INVALID_TYPE' for property 'type'" ), + Arguments.of( + "Cannot deserialize value of type `PropertyFormat` from String \"INVALID_FORMAT\": not one of the values accepted for Enum class", + "Invalid value 'INVALID_FORMAT' for property 'format'" + ), Arguments.of( "Cannot deserialize value of type `UnknownEnum` from String \"VALUE\": not one of the values accepted for Enum class", "Invalid enum value in request body" + ), + Arguments.of( + "Cannot deserialize value of type `com.example.SomeType`: some other error", + "Cannot deserialize request body property" + ), + Arguments.of( + "Something completely unexpected happened", + "Invalid request body format" + ), + Arguments.of( + "Cannot deserialize value of type `PropertyType`: not one of the values accepted for Enum class", + "Invalid value for property 'type'" ) ); } From 39580890b1154549877e53310f614f0b82528dbd Mon Sep 17 00:00:00 2001 From: renny vandomber Date: Mon, 4 May 2026 14:56:43 +0200 Subject: [PATCH 05/53] feat(core): fix validation type check --- .../domain/model/entity/Property.java | 11 +- .../entity/EntityValidationService.java | 2 +- .../property/PropertyValidationService.java | 41 +++++- .../api/mapper/entity/EntityDtoInMapper.java | 3 +- .../mapper/EntityPersistenceMapper.java | 2 + .../entity/EntityValidationServiceTest.java | 8 +- .../PropertyValidationServiceTest.java | 122 ++++++++++-------- 7 files changed, 126 insertions(+), 63 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java index 4c15dcd9..015d0fbe 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java @@ -22,6 +22,9 @@ /// - Property values must satisfy all validation rules from [PropertyRules] /// - Required properties cannot have empty values /// - Property types must align with the template's [PropertyType] definition +/// +/// @param rawValue the original untyped value from the API input, used for type checking +/// during validation. May be null when loaded from persistence. public record Property( UUID id, @@ -29,6 +32,12 @@ public record Property( String name, @NotBlank(message = PROPERTY_VALUE_MANDATORY) - String value + String value, + + Object rawValue ) { + /// Convenience constructor for persistence and test scenarios where raw value is not needed. + public Property(UUID id, String name, String value) { + this(id, name, value, null); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java index f535e7d2..7b7c18c3 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java @@ -133,7 +133,7 @@ private void validateAgainstTemplate(EntityTemplate template, } propertyValidationService - .validatePropertyValue(definition, property.value()) + .validatePropertyValue(definition, property.value(), property.rawValue()) .forEach(violations::add); } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java index e769e0dc..d2ba01c0 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java @@ -38,12 +38,20 @@ public class PropertyValidationService { /** * Validates a concrete property value against its property definition. + * Type compatibility is checked first against the original raw value + * before applying any rule-based validations. * * @param propertyDefinition property definition with expected type and optional rules - * @param rawValue raw property value + * @param rawValue raw property value as string + * @param originalValue the original untyped value from the API input for type checking, + * may be null when loaded from persistence * @return list of violations for this value; empty when valid */ - public List validatePropertyValue(PropertyDefinition propertyDefinition, String rawValue) { + public List validatePropertyValue(PropertyDefinition propertyDefinition, String rawValue, Object originalValue) { + List typeMismatch = checkOriginalValueType(propertyDefinition.name(), propertyDefinition.type(), originalValue); + if (!typeMismatch.isEmpty()) { + return typeMismatch; + } return switch (propertyDefinition.type()) { case STRING -> validateStringPropertyValue(propertyDefinition.name(), rawValue, propertyDefinition.rules()); case NUMBER -> validateNumberPropertyValue(propertyDefinition.name(), rawValue, propertyDefinition.rules()); @@ -51,6 +59,35 @@ public List validatePropertyValue(PropertyDefinition propertyDefinition, }; } + /// Checks that the original JSON value type is compatible with the expected [PropertyType]. + /// + /// When `originalValue` is non-null, its Java type is inspected: + /// - STRING expects a Java `String` + /// - NUMBER expects a Java `Number` + /// - BOOLEAN expects a Java `Boolean` + /// + /// If `originalValue` is null (e.g. loaded from persistence), the check is skipped + /// and type validation falls through to the string-based validators. + /// + /// @param propertyName property name for error reporting + /// @param expectedType the expected property type from the template definition + /// @param originalValue the original untyped value from the API input + /// @return a single-element list with a type mismatch message, or an empty list if compatible + private List checkOriginalValueType(String propertyName, PropertyType expectedType, Object originalValue) { + if (originalValue == null) { + return List.of(); + } + boolean compatible = switch (expectedType) { + case STRING -> originalValue instanceof String; + case NUMBER -> originalValue instanceof Number || originalValue instanceof String; + case BOOLEAN -> originalValue instanceof Boolean || originalValue instanceof String; + }; + if (!compatible) { + return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, expectedType)); + } + return List.of(); + } + private List validateStringPropertyValue(String propertyName, String rawValue, PropertyRules rules) { if (rawValue == null) { return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.STRING)); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java index 1f6ad3a0..7bc0a59a 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java @@ -50,7 +50,8 @@ public Entity fromEntityDtoInToEntity(EntityDtoIn entityDtoIn, String entityTemp return new Property( null, entry.getKey(), - value + value, + entry.getValue() ); }) .toList(); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java index 120fd654..c22ffbb6 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java @@ -1,6 +1,7 @@ package com.decathlon.idp_core.infrastructure.adapters.persistence.mapper; import org.mapstruct.Mapper; +import org.mapstruct.Mapping; import org.mapstruct.MappingConstants; import com.decathlon.idp_core.domain.model.entity.Entity; @@ -17,6 +18,7 @@ public interface EntityPersistenceMapper { EntityJpaEntity toJpa(Entity domain); + @Mapping(target = "rawValue", ignore = true) Property toDomain(PropertyJpaEntity jpa); PropertyJpaEntity toJpa(Property domain); diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java index 4cdd394d..02c7116b 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java @@ -142,7 +142,7 @@ void shouldAggregateAllViolationsDuringValidateEntity() { when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", " ")).thenReturn(Optional.empty()); - when(propertyValidationService.validatePropertyValue(portDefinition, "80")) + when(propertyValidationService.validatePropertyValue(portDefinition, "80", null)) .thenReturn(List.of("Property 'port' value must be greater than or equal to 1024")); var exception = assertThrows(EntityValidationException.class, () -> entityValidationService.validateEntity(entity)); @@ -157,7 +157,7 @@ void shouldAggregateAllViolationsDuringValidateEntity() { assertEquals(PROPERTY_REQUIRED_MISSING.formatted("ownerEmail", "web-service"), exception.getViolations().get(6)); assertEquals("Property 'port' value must be greater than or equal to 1024", exception.getViolations().get(7)); - verify(propertyValidationService).validatePropertyValue(portDefinition, "80"); + verify(propertyValidationService).validatePropertyValue(portDefinition, "80", null); } @Test @@ -189,10 +189,10 @@ void shouldValidateEntitySuccessfullyWhenNoViolations() { when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) .thenReturn(Optional.empty()); - when(propertyValidationService.validatePropertyValue(versionDefinition, "1.0.0")).thenReturn(List.of()); + when(propertyValidationService.validatePropertyValue(versionDefinition, "1.0.0", null)).thenReturn(List.of()); assertDoesNotThrow(() -> entityValidationService.validateEntity(entity)); - verify(propertyValidationService).validatePropertyValue(versionDefinition, "1.0.0"); + verify(propertyValidationService).validatePropertyValue(versionDefinition, "1.0.0", null); } @Test diff --git a/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java index 3f35fc0a..2ffdef8a 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java @@ -7,6 +7,8 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import com.decathlon.idp_core.domain.constant.ValidationMessages; import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; @@ -28,7 +30,7 @@ class StringValidationTests { void shouldReportTypeMismatchWhenStringValueIsNull() { var definition = propertyDefinition("label", PropertyType.STRING, null); - var violations = service.validatePropertyValue(definition, null); + var violations = service.validatePropertyValue(definition, null, null); assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("label", PropertyType.STRING)), violations); } @@ -38,7 +40,7 @@ void shouldReportTypeMismatchWhenStringValueIsNull() { void shouldReturnNoViolationsWhenStringHasNoRules() { var definition = propertyDefinition("label", PropertyType.STRING, null); - var violations = service.validatePropertyValue(definition, "hello"); + var violations = service.validatePropertyValue(definition, "hello", "hello"); assertEquals(List.of(), violations); } @@ -49,7 +51,7 @@ void shouldReturnNoViolationsWhenStringPassesAllRules() { var rules = new PropertyRules(null, null, List.of("dev", "prod"), "^[a-z]+$", 10, 2, null, null); var definition = propertyDefinition("env", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "dev"); + var violations = service.validatePropertyValue(definition, "dev", "dev"); assertEquals(List.of(), violations); } @@ -60,7 +62,7 @@ void shouldReportMinLengthViolation() { var rules = new PropertyRules(null, null, null, null, null, 5, null, null); var definition = propertyDefinition("name", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "ab"); + var violations = service.validatePropertyValue(definition, "ab", "ab"); assertEquals(List.of(ValidationMessages.PROPERTY_MIN_LENGTH_VIOLATION.formatted("name", 5)), violations); } @@ -71,7 +73,7 @@ void shouldReportMaxLengthViolation() { var rules = new PropertyRules(null, null, null, null, 5, null, null, null); var definition = propertyDefinition("name", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "too-long-value"); + var violations = service.validatePropertyValue(definition, "too-long-value", "too-long-value"); assertEquals(List.of(ValidationMessages.PROPERTY_MAX_LENGTH_VIOLATION.formatted("name", 5)), violations); } @@ -82,7 +84,7 @@ void shouldReportRegexViolation() { var rules = new PropertyRules(null, null, null, "^[0-9]+$", null, null, null, null); var definition = propertyDefinition("code", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "abc"); + var violations = service.validatePropertyValue(definition, "abc", "abc"); assertEquals(List.of(ValidationMessages.PROPERTY_REGEX_VIOLATION.formatted("code")), violations); } @@ -93,7 +95,7 @@ void shouldAcceptValueMatchingRegex() { var rules = new PropertyRules(null, null, null, "^[0-9]+$", null, null, null, null); var definition = propertyDefinition("code", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "12345"); + var violations = service.validatePropertyValue(definition, "12345", "12345"); assertEquals(List.of(), violations); } @@ -104,7 +106,7 @@ void shouldReportEnumViolation() { var rules = new PropertyRules(null, null, List.of("ACTIVE", "INACTIVE"), null, null, null, null, null); var definition = propertyDefinition("status", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "UNKNOWN"); + var violations = service.validatePropertyValue(definition, "UNKNOWN", "UNKNOWN"); assertEquals(List.of(ValidationMessages.PROPERTY_ENUM_VIOLATION.formatted("status", List.of("ACTIVE", "INACTIVE"))), violations); } @@ -115,7 +117,7 @@ void shouldAcceptEnumValueCaseInsensitive() { var rules = new PropertyRules(null, null, List.of("ACTIVE", "INACTIVE"), null, null, null, null, null); var definition = propertyDefinition("status", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "active"); + var violations = service.validatePropertyValue(definition, "active", "active"); assertEquals(List.of(), violations); } @@ -126,7 +128,7 @@ void shouldSkipEnumCheckWhenEnumValuesIsEmpty() { var rules = new PropertyRules(null, null, List.of(), null, null, null, null, null); var definition = propertyDefinition("status", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "anything"); + var violations = service.validatePropertyValue(definition, "anything", "anything"); assertEquals(List.of(), violations); } @@ -137,7 +139,7 @@ void shouldReportFormatViolationForInvalidEmail() { var rules = new PropertyRules(null, PropertyFormat.EMAIL, null, null, null, null, null, null); var definition = propertyDefinition("email", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "not-an-email"); + var violations = service.validatePropertyValue(definition, "not-an-email", "not-an-email"); assertEquals(List.of(ValidationMessages.PROPERTY_FORMAT_VIOLATION.formatted("email", PropertyFormat.EMAIL)), violations); } @@ -148,7 +150,7 @@ void shouldAcceptValidEmailFormat() { var rules = new PropertyRules(null, PropertyFormat.EMAIL, null, null, null, null, null, null); var definition = propertyDefinition("email", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "user@example.com"); + var violations = service.validatePropertyValue(definition, "user@example.com", "user@example.com"); assertEquals(List.of(), violations); } @@ -159,7 +161,7 @@ void shouldReportFormatViolationForInvalidUrl() { var rules = new PropertyRules(null, PropertyFormat.URL, null, null, null, null, null, null); var definition = propertyDefinition("url", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "not-a-url"); + var violations = service.validatePropertyValue(definition, "not-a-url", "not-a-url"); assertEquals(List.of(ValidationMessages.PROPERTY_FORMAT_VIOLATION.formatted("url", PropertyFormat.URL)), violations); } @@ -170,7 +172,7 @@ void shouldAcceptValidUrlFormat() { var rules = new PropertyRules(null, PropertyFormat.URL, null, null, null, null, null, null); var definition = propertyDefinition("url", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "https://github.com/org/repo"); + var violations = service.validatePropertyValue(definition, "https://github.com/org/repo", "https://github.com/org/repo"); assertEquals(List.of(), violations); } @@ -181,7 +183,7 @@ void shouldReportMultipleStringViolations() { var rules = new PropertyRules(null, PropertyFormat.EMAIL, List.of("prod", "dev"), "^[a-z]+$", 5, 3, null, null); var definition = propertyDefinition("name", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "AA"); + var violations = service.validatePropertyValue(definition, "AA", "AA"); assertEquals(4, violations.size()); } @@ -193,12 +195,33 @@ void shouldUseCachedPatternForRepeatedRegex() { var definition = propertyDefinition("code", PropertyType.STRING, rules); // Validate twice with the same regex to exercise the cache - var violations1 = service.validatePropertyValue(definition, "abc"); - var violations2 = service.validatePropertyValue(definition, "def"); + var violations1 = service.validatePropertyValue(definition, "abc", "abc"); + var violations2 = service.validatePropertyValue(definition, "def", "def"); assertEquals(List.of(), violations1); assertEquals(List.of(), violations2); } + + @Test + @DisplayName("Should report type mismatch when a number is sent for a STRING property") + void shouldReportTypeMismatchWhenNumberSentForString() { + var rules = new PropertyRules(null, null, null, null, null, 5, null, null); + var definition = propertyDefinition("label", PropertyType.STRING, rules); + + var violations = service.validatePropertyValue(definition, "12", 12); + + assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("label", PropertyType.STRING)), violations); + } + + @Test + @DisplayName("Should report type mismatch when a boolean is sent for a STRING property") + void shouldReportTypeMismatchWhenBooleanSentForString() { + var definition = propertyDefinition("label", PropertyType.STRING, null); + + var violations = service.validatePropertyValue(definition, "true", true); + + assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("label", PropertyType.STRING)), violations); + } } @Nested @@ -210,7 +233,7 @@ class NumberValidationTests { void shouldReportTypeMismatchWhenNumberValueIsInvalid() { var definition = propertyDefinition("score", PropertyType.NUMBER, null); - var violations = service.validatePropertyValue(definition, "not-a-number"); + var violations = service.validatePropertyValue(definition, "not-a-number", "not-a-number"); assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("score", PropertyType.NUMBER)), violations); } @@ -220,7 +243,7 @@ void shouldReportTypeMismatchWhenNumberValueIsInvalid() { void shouldReturnNoViolationsWhenNumberHasNoRules() { var definition = propertyDefinition("count", PropertyType.NUMBER, null); - var violations = service.validatePropertyValue(definition, "42"); + var violations = service.validatePropertyValue(definition, "42", 42); assertEquals(List.of(), violations); } @@ -231,7 +254,7 @@ void shouldReturnNoViolationsWhenNumberIsWithinBounds() { var rules = new PropertyRules(null, null, null, null, null, null, 100, 0); var definition = propertyDefinition("score", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "50"); + var violations = service.validatePropertyValue(definition, "50", 50); assertEquals(List.of(), violations); } @@ -242,7 +265,7 @@ void shouldReportMinValueViolation() { var rules = new PropertyRules(null, null, null, null, null, null, 10, 5); var definition = propertyDefinition("size", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "3"); + var violations = service.validatePropertyValue(definition, "3", 3); assertEquals(List.of(ValidationMessages.PROPERTY_MIN_VALUE_VIOLATION.formatted("size", 5)), violations); } @@ -253,7 +276,7 @@ void shouldReportMaxValueViolation() { var rules = new PropertyRules(null, null, null, null, null, null, 10, 0); var definition = propertyDefinition("size", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "15"); + var violations = service.validatePropertyValue(definition, "15", 15); assertEquals(List.of(ValidationMessages.PROPERTY_MAX_VALUE_VIOLATION.formatted("size", 10)), violations); } @@ -265,7 +288,7 @@ void shouldReportBothMinAndMaxViolations() { var rules = new PropertyRules(null, null, null, null, null, null, 5, 10); var definition = propertyDefinition("range", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "7"); + var violations = service.validatePropertyValue(definition, "7", 7); // 7 < 10 (minValue) → min violation; 7 > 5 (maxValue) → max violation assertEquals(2, violations.size()); @@ -277,62 +300,53 @@ void shouldAcceptDecimalNumberValues() { var rules = new PropertyRules(null, null, null, null, null, null, 100, 0); var definition = propertyDefinition("rate", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "99.5"); + var violations = service.validatePropertyValue(definition, "99.5", 99.5); assertEquals(List.of(), violations); } - } - - @Nested - @DisplayName("BOOLEAN validation") - class BooleanValidationTests { @Test - @DisplayName("Should accept 'true' value") - void shouldAcceptTrueValue() { - var definition = propertyDefinition("enabled", PropertyType.BOOLEAN, null); + @DisplayName("Should report type mismatch when a boolean is sent for a NUMBER property") + void shouldReportTypeMismatchWhenBooleanSentForNumber() { + var definition = propertyDefinition("count", PropertyType.NUMBER, null); - var violations = service.validatePropertyValue(definition, "true"); + var violations = service.validatePropertyValue(definition, "true", true); - assertEquals(List.of(), violations); + assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("count", PropertyType.NUMBER)), violations); } + } - @Test - @DisplayName("Should accept 'false' value") - void shouldAcceptFalseValue() { - var definition = propertyDefinition("enabled", PropertyType.BOOLEAN, null); - - var violations = service.validatePropertyValue(definition, "false"); - - assertEquals(List.of(), violations); - } + @Nested + @DisplayName("BOOLEAN validation") + class BooleanValidationTests { - @Test - @DisplayName("Should accept case-insensitive 'TRUE'") - void shouldAcceptUppercaseTrue() { + @ParameterizedTest(name = "Should accept valid boolean value: ''{0}''") + @ValueSource(strings = {"true", "false", "TRUE", "FALSE"}) + void shouldAcceptValidBooleanValues(String value) { var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); + Object originalValue = "true".equalsIgnoreCase(value) ? Boolean.TRUE : Boolean.FALSE; - var violations = service.validatePropertyValue(definition, "TRUE"); + var violations = service.validatePropertyValue(definition, value, originalValue); assertEquals(List.of(), violations); } @Test - @DisplayName("Should accept case-insensitive 'FALSE'") - void shouldAcceptUppercaseFalse() { + @DisplayName("Should report type mismatch for invalid boolean value") + void shouldReportTypeMismatchForInvalidBoolean() { var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); - var violations = service.validatePropertyValue(definition, "FALSE"); + var violations = service.validatePropertyValue(definition, "yes", "yes"); - assertEquals(List.of(), violations); + assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("flag", PropertyType.BOOLEAN)), violations); } @Test - @DisplayName("Should report type mismatch for invalid boolean value") - void shouldReportTypeMismatchForInvalidBoolean() { + @DisplayName("Should report type mismatch when a number is sent for a BOOLEAN property") + void shouldReportTypeMismatchWhenNumberSentForBoolean() { var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); - var violations = service.validatePropertyValue(definition, "yes"); + var violations = service.validatePropertyValue(definition, "42", 42); assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("flag", PropertyType.BOOLEAN)), violations); } From fb272c259f8a930f45b1072cda4cf5c35d035e46 Mon Sep 17 00:00:00 2001 From: renny vandomber Date: Tue, 5 May 2026 16:13:06 +0200 Subject: [PATCH 06/53] feat(core): fix review --- .../domain/constant/ValidationMessages.java | 30 ++ .../EntityAlreadyExistsException.java | 13 +- .../{ => entity}/EntityNotFoundException.java | 2 +- .../EntityValidationException.java | 15 +- .../EntityTemplateAlreadyExistsException.java | 3 +- ...ityTemplateNameAlreadyExistsException.java | 3 +- .../EntityTemplateNotFoundException.java | 2 +- ...pertyDefinitionRulesConflictException.java | 25 ++ .../idp_core/domain/model/entity/Entity.java | 3 + .../domain/model/entity/Property.java | 19 +- .../domain/service/EntityTemplateService.java | 6 +- .../domain/service/entity/EntityService.java | 36 ++- .../entity/EntityValidationService.java | 87 +---- .../EntityTemplateValidationService.java | 118 +++++++ .../PropertyDefinitionValidationService.java | 297 ++++++++++++++++++ .../property/PropertyValidationService.java | 84 ++--- .../api/controller/EntityController.java | 6 +- .../api/handler/ApiExceptionHandler.java | 12 +- .../api/mapper/entity/EntityDtoInMapper.java | 23 +- .../api/mapper/entity/EntityDtoOutMapper.java | 81 +++-- .../mapper/EntityPersistenceMapper.java | 19 +- .../service/entity/EntityServiceTest.java | 50 +-- .../entity/EntityValidationServiceTest.java | 124 +++----- .../PropertyValidationServiceTest.java | 78 ++--- .../api/handler/ApiExceptionHandlerTest.java | 12 +- 25 files changed, 773 insertions(+), 375 deletions(-) rename src/main/java/com/decathlon/idp_core/domain/exception/{ => entity}/EntityAlreadyExistsException.java (51%) rename src/main/java/com/decathlon/idp_core/domain/exception/{ => entity}/EntityNotFoundException.java (95%) rename src/main/java/com/decathlon/idp_core/domain/exception/{ => entity}/EntityValidationException.java (50%) rename src/main/java/com/decathlon/idp_core/domain/exception/{ => entity_template}/EntityTemplateAlreadyExistsException.java (92%) rename src/main/java/com/decathlon/idp_core/domain/exception/{ => entity_template}/EntityTemplateNameAlreadyExistsException.java (87%) rename src/main/java/com/decathlon/idp_core/domain/exception/{ => entity_template}/EntityTemplateNotFoundException.java (97%) create mode 100644 src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java 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 a5c0d0f5..9bf3e8a0 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 @@ -30,6 +30,14 @@ public class ValidationMessages { public static final String PROPERTY_ENUM_VIOLATION = "Property '%s' must be one of %s"; public static final String PROPERTY_FORMAT_VIOLATION = "Property '%s' does not match required format %s"; + // Property Rules validation messages - templates and specific constraints + public static final String PROPERTY_RULES_RULE_NOT_ALLOWED_FOR_TYPE = "{rule} rule is not allowed for {type} property type"; + public static final String PROPERTY_RULES_MIN_MAX_CONSTRAINT_VIOLATED = "min_{constraint} must be less than or equal to max_{constraint}"; + public static final String PROPERTY_RULES_MIN_LENGTH_NON_NEGATIVE = "min_length must be greater than or equal to 0"; + public static final String PROPERTY_RULES_MAX_LENGTH_POSITIVE = "max_length must be greater than 0"; + public static final String PROPERTY_RULES_BOOLEAN_NOT_ALLOWED = "Boolean properties do not accept any rules"; + public static final String PROPERTY_RULES_NUMERIC_RULE_NOT_ALLOWED = "Numeric rule {rule} is not allowed for STRING properties"; + // Relation Definition validation messages public static final String RELATION_NAME_MANDATORY = "Relation name is mandatory and cannot be blank"; public static final String RELATION_TARGET_IDENTIFIER_MANDATORY = "Target entity identifier is mandatory and cannot be blank"; @@ -45,4 +53,26 @@ public class ValidationMessages { public static final String ENTITY_NOT_FOUND = "Entity not found with template identifier %s and entity identifier '%s'"; public static final String ENTITY_ALREADY_EXISTS = "Entity with name '%s' already exists for template '%s'"; public static final String ENTITY_VALIDATION_FAILED = "Entity validation failed: "; + + public static final String PROPERTY_RULES_MUTUALLY_EXCLUSIVE = "{rule1} and {rule2} are mutually exclusive for STRING properties"; + + // Helper method to construct rules incompatibility message + public static String rulesAreIncompatible(String rule1, String rule2) { + return PROPERTY_RULES_MUTUALLY_EXCLUSIVE + .replace("{rule1}", rule1) + .replace("{rule2}", rule2); + } + + // Helper method to construct rule-not-allowed message + public static String ruleNotAllowed(String rule, String propertyType) { + return PROPERTY_RULES_RULE_NOT_ALLOWED_FOR_TYPE + .replace("{rule}", rule) + .replace("{type}", propertyType); + } + + // Helper method to construct min/max constraint violation message + public static String minMaxConstraintViolated(String constraint) { + return PROPERTY_RULES_MIN_MAX_CONSTRAINT_VIOLATED + .replace("{constraint}", constraint); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/EntityAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityAlreadyExistsException.java similarity index 51% rename from src/main/java/com/decathlon/idp_core/domain/exception/EntityAlreadyExistsException.java rename to src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityAlreadyExistsException.java index bd76169b..82437486 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/EntityAlreadyExistsException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityAlreadyExistsException.java @@ -1,10 +1,19 @@ -package com.decathlon.idp_core.domain.exception; +package com.decathlon.idp_core.domain.exception.entity; import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_ALREADY_EXISTS; import com.decathlon.idp_core.domain.model.entity.Entity; -/// Domain exception for duplicate [Entity] business entities within a template scope. +/// Domain exception for duplicate [Entity] business entities within the same template context. +/// +/// **Business purpose:** Represents the business rule violation when attempting +/// to create an Entity that already exist within a specific template context. +/// This enforces the business invariant that entities must be unique within their template context. +/// +/// **Why this exception exists:** +/// - Enforces business constraint that entity operations require unique entities within a template context +/// - Provides domain-specific error information for API responses +/// - Maintains template-entity relationship integrity public class EntityAlreadyExistsException extends RuntimeException { /// Constructs a new exception with template and entity identifiers. diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/EntityNotFoundException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityNotFoundException.java similarity index 95% rename from src/main/java/com/decathlon/idp_core/domain/exception/EntityNotFoundException.java rename to src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityNotFoundException.java index cc7d4a83..42c60f67 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/EntityNotFoundException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityNotFoundException.java @@ -1,4 +1,4 @@ -package com.decathlon.idp_core.domain.exception; +package com.decathlon.idp_core.domain.exception.entity; import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_NOT_FOUND; diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/EntityValidationException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityValidationException.java similarity index 50% rename from src/main/java/com/decathlon/idp_core/domain/exception/EntityValidationException.java rename to src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityValidationException.java index ca9da644..42756f0e 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/EntityValidationException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityValidationException.java @@ -1,4 +1,4 @@ -package com.decathlon.idp_core.domain.exception; +package com.decathlon.idp_core.domain.exception.entity; import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_VALIDATION_FAILED; @@ -7,6 +7,19 @@ import lombok.Getter; /// Domain exception for entity schema validation failures +/// +/// **Business purpose:** Represents the business rule violation when attempting +/// to create an entity, or update an entity, with property values that +/// do not conform to the validation rules defined in the entity's template. +/// This includes violations of required properties, type mismatches, and template rules +/// This enforces the business invariant that entities must conform to the validation +/// rules defined in their template's property definitions and relation constraints. +/// +/// **Why this exception exists:** +/// - Enforces business constraint that entity operations require valid property values +/// that conform to template rules +/// - Provides domain-specific error information for API responses +/// - Maintains template-entity relationship integrity @Getter public class EntityValidationException extends RuntimeException { diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateAlreadyExistsException.java similarity index 92% rename from src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateAlreadyExistsException.java rename to src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateAlreadyExistsException.java index aff0ad4b..12aee0d6 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateAlreadyExistsException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateAlreadyExistsException.java @@ -1,9 +1,10 @@ -package com.decathlon.idp_core.domain.exception; +package com.decathlon.idp_core.domain.exception.entity_template; import static com.decathlon.idp_core.domain.constant.ValidationMessages.TEMPLATE_ALREADY_EXISTS; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.service.EntityTemplateService; +import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler; /// Exception thrown when attempting to create an [EntityTemplate] with an identifier that already exists. /// diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateNameAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateNameAlreadyExistsException.java similarity index 87% rename from src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateNameAlreadyExistsException.java rename to src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateNameAlreadyExistsException.java index 885bc833..d1c7104c 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateNameAlreadyExistsException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateNameAlreadyExistsException.java @@ -1,9 +1,10 @@ -package com.decathlon.idp_core.domain.exception; +package com.decathlon.idp_core.domain.exception.entity_template; import static com.decathlon.idp_core.domain.constant.ValidationMessages.TEMPLATE_NAME_ALREADY_EXISTS; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.service.EntityTemplateService; +import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler; /// Exception thrown when attempting to create or update an [EntityTemplate] with a name that already exists. /// diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateNotFoundException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateNotFoundException.java similarity index 97% rename from src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateNotFoundException.java rename to src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateNotFoundException.java index bca9ccd9..c765a4f4 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/EntityTemplateNotFoundException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateNotFoundException.java @@ -1,4 +1,4 @@ -package com.decathlon.idp_core.domain.exception; +package com.decathlon.idp_core.domain.exception.entity_template; import java.util.UUID; diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java b/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java new file mode 100644 index 00000000..f68a840e --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java @@ -0,0 +1,25 @@ +package com.decathlon.idp_core.domain.exception.property; + +import com.decathlon.idp_core.domain.model.enums.PropertyType; + +/// Domain exception for property rule validation violations. +/// +/// **Business purpose:** Represents the business rule violation when property rules +/// conflict with their assigned property type. This ensures data integrity +/// by preventing invalid rule configurations before persistence. +/// +/// **Usage patterns:** +/// - Property template creation with invalid rules +/// - Property template updates introducing rule conflicts +public class PropertyDefinitionRulesConflictException extends RuntimeException { + + /// Constructs a new exception for rule type conflict. + /// + /// @param propertyName the name of the property with invalid rules + /// @param propertyType the data type of the property + /// @param violationMessage detailed explanation of what rule is invalid + public PropertyDefinitionRulesConflictException(String propertyName, PropertyType propertyType, String violationMessage) { + super("Property '" + propertyName + "' of type " + propertyType + + ": " + violationMessage); + } +} \ No newline at end of file diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java index 6250a5ac..2b772413 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java @@ -7,6 +7,8 @@ import java.util.List; import java.util.UUID; +import org.springframework.validation.annotation.Validated; + import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import jakarta.validation.constraints.NotBlank; @@ -21,6 +23,7 @@ /// /// Ubiquitous language: An Entity is a materialized instance of a template schema, /// containing actual values that comply with the template's structure and rules. + public record Entity( UUID id, diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java index 015d0fbe..78501247 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java @@ -1,7 +1,6 @@ package com.decathlon.idp_core.domain.model.entity; import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_NAME_MANDATORY; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_VALUE_MANDATORY; import java.util.UUID; @@ -20,24 +19,16 @@ /// **Business invariants:** /// - Property names must match a [PropertyDefinition] name in the entity's template /// - Property values must satisfy all validation rules from [PropertyRules] -/// - Required properties cannot have empty values -/// - Property types must align with the template's [PropertyType] definition -/// -/// @param rawValue the original untyped value from the API input, used for type checking -/// during validation. May be null when loaded from persistence. +/// - Required properties cannot have null/blank values +/// - Property values must be typed according to the template's [PropertyType] definition +/// (carried as [Object] so the original JSON type — String, Number, Boolean — is preserved +/// for strict type-mismatch detection at validation time). public record Property( UUID id, @NotBlank(message = PROPERTY_NAME_MANDATORY) String name, - @NotBlank(message = PROPERTY_VALUE_MANDATORY) - String value, - - Object rawValue + Object value ) { - /// Convenience constructor for persistence and test scenarios where raw value is not needed. - public Property(UUID id, String name, String value) { - this(id, name, value, null); - } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/EntityTemplateService.java b/src/main/java/com/decathlon/idp_core/domain/service/EntityTemplateService.java index d9cf3763..7d27fc5f 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/EntityTemplateService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/EntityTemplateService.java @@ -12,9 +12,9 @@ import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; -import com.decathlon.idp_core.domain.exception.EntityTemplateAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.EntityTemplateNameAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNameAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; 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.entity_template.PropertyRules; 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 3f5de082..4fa2da22 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java @@ -2,18 +2,22 @@ import java.util.List; -import lombok.AllArgsConstructor; +import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; -import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.EntityNotFoundException; -import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; -import com.decathlon.idp_core.domain.exception.EntityValidationException; +import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntitySummary; +import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; +import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; import jakarta.transaction.Transactional; import jakarta.validation.Valid; @@ -29,10 +33,13 @@ /// - Entity data integrity validation (entity, properties, relations) /// - Entity summary generation for efficient queries @Service -@AllArgsConstructor +@Validated +@RequiredArgsConstructor public class EntityService { private final EntityRepositoryPort entityRepository; + private final EntityTemplateRepositoryPort entityTemplateRepository; private final EntityValidationService entityValidationService; + private final EntityTemplateValidationService entityTemplateValidationService; /// Retrieves entities filtered by template with existence validation. /// @@ -72,8 +79,8 @@ public List getEntitiesSummariesByIndentifiers(List ident /// @throws EntityTemplateNotFoundException when template doesn't exist /// @throws EntityNotFoundException when entity doesn't exist @Transactional - public Entity getEntityByTemplateIdentifierAnIdentifier(String templateIdentifier, String entityIdentifier) { - entityValidationService.checkTemplateExist(templateIdentifier); + public Entity getEntityByTemplateIdentifierAndIdentifier(String templateIdentifier, String entityIdentifier) { + entityTemplateValidationService.checkTemplateExists(templateIdentifier); return entityRepository.findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); @@ -81,8 +88,10 @@ public Entity getEntityByTemplateIdentifierAnIdentifier(String templateIdentifie /// Creates and persists a new entity with business validation. /// - /// **Contract:** Validates template existence, entity identifier uniqueness within - /// the template scope, and entity/property/relation data integrity before persisting. + /// **Contract:** Resolves the referenced template (single round-trip — combined + /// existence check and fetch), enforces entity identifier uniqueness within the + /// template scope, then validates entity/property data integrity against the + /// resolved template before persisting. /// /// @param entity validated entity to create and persist /// @return the persisted entity with generated identifiers @@ -91,9 +100,10 @@ public Entity getEntityByTemplateIdentifierAnIdentifier(String templateIdentifie /// @throws EntityValidationException when entity, property, or relation data is invalid @Transactional public Entity createEntity(@Valid Entity entity) { - entityValidationService.checkTemplateExist(entity.templateIdentifier()); - entityValidationService.checkEntityAlreadyExist(entity); - entityValidationService.validateEntity(entity); + EntityTemplate template = entityTemplateRepository.findByIdentifier(entity.templateIdentifier()) + .orElseThrow(() -> new EntityTemplateNotFoundException("identifier", entity.templateIdentifier())); + entityValidationService.checkUniqueness(entity); + entityValidationService.validateEntity(entity, template); return entityRepository.save(entity); } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java index 7b7c18c3..4902016c 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java @@ -1,12 +1,6 @@ package com.decathlon.idp_core.domain.service.entity; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_IDENTIFIER_MANDATORY; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_NAME_MANDATORY; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_NAME_MANDATORY; import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_REQUIRED_MISSING; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_VALUE_MANDATORY; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_NAME_MANDATORY_SIMPLE; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TARGET_IDENTIFIERS_NOT_NULL; import java.util.List; import java.util.Map; @@ -16,16 +10,13 @@ import lombok.AllArgsConstructor; import org.springframework.stereotype.Service; -import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; -import com.decathlon.idp_core.domain.exception.EntityValidationException; +import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.Property; -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.PropertyDefinition; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; -import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; import com.decathlon.idp_core.domain.service.property.PropertyValidationService; /// Domain validator for [Entity] aggregates. @@ -39,34 +30,21 @@ public class EntityValidationService { private final EntityRepositoryPort entityRepository; - private final EntityTemplateRepositoryPort entityTemplateRepository; private final PropertyValidationService propertyValidationService; - /// Check entity template existence to ensure valid template reference before deeper validations. - /// @param entity the entity whose template existence is to be checked - /// @throws EntityTemplateNotFoundException if the template referenced by the entity does not exist - void checkTemplateExist(final String entity) { - if (!entityTemplateRepository.existsByIdentifier(entity)) { - throw new EntityTemplateNotFoundException("identifier", entity); - } - } - - /// Validates intrinsic entity data integrity and template-driven rules. + /// Validates intrinsic entity data integrity and template-driven rules. + /// + /// **Contract:** the caller is responsible for resolving the [EntityTemplate] + /// (typically via [com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort]) + /// and passing it in. This avoids a redundant database round-trip and clarifies + /// the dependency graph of the validation service. /// /// @param entity the entity to validate + /// @param template the already-resolved template the entity must conform to /// @throws EntityValidationException when one or more validation rules are violated /// @throws EntityAlreadyExistsException if an entity with the same identifier exists for the template - /// @throws EntityTemplateNotFoundException if the referenced template does not exist - void validateEntity(Entity entity) { - checkEntityAlreadyExist(entity); - EntityTemplate template = entityTemplateRepository.findByIdentifier(entity.templateIdentifier()) - .orElseThrow(() -> new EntityTemplateNotFoundException("identifier", entity.templateIdentifier())); - + void validateEntity(Entity entity, EntityTemplate template) { Violations violations = new Violations(); - - validateEntityHeader(entity, violations); - validatePropertiesShape(entity.properties(), violations); - validateRelationsShape(entity.relations(), violations); validateAgainstTemplate(template, entity.properties(), violations); if (!violations.isEmpty()) { @@ -74,45 +52,10 @@ void validateEntity(Entity entity) { } } - private void validateEntityHeader(Entity entity, Violations violations) { - violations.addIfBlank(entity.name(), ENTITY_NAME_MANDATORY); - violations.addIfBlank(entity.identifier(), ENTITY_IDENTIFIER_MANDATORY); - } - - private void validatePropertiesShape(List properties, Violations violations) { - if (properties == null) { - return; - } - for (int i = 0; i < properties.size(); i++) { - Property prop = properties.get(i); - if (prop.name() == null || prop.name().isBlank()) { - violations.addIndexed("Property", i, PROPERTY_NAME_MANDATORY); - } - if (prop.value() == null || prop.value().isBlank()) { - violations.addIndexed("Property", i, PROPERTY_VALUE_MANDATORY); - } - } - } - - private void validateRelationsShape(List relations, Violations violations) { - if (relations == null) { - return; - } - for (int i = 0; i < relations.size(); i++) { - Relation rel = relations.get(i); - if (rel.name() == null || rel.name().isBlank()) { - violations.addIndexed("Relation", i, RELATION_NAME_MANDATORY_SIMPLE); - } - if (rel.targetEntityIdentifiers() == null) { - violations.addIndexed("Relation", i, RELATION_TARGET_IDENTIFIERS_NOT_NULL); - } - } - } - /// Validates entity properties against the template's property definitions, enforcing required fields and value rules. /// @param template the entity template whose property definitions are used for validation /// @param properties the list of properties from the entity to validate - /// @param violations the accumulator for validation violation messages + /// @param violations the accumulator for validation v iolation messages private void validateAgainstTemplate(EntityTemplate template, List properties, Violations violations) { @@ -123,7 +66,9 @@ private void validateAgainstTemplate(EntityTemplate template, for (PropertyDefinition definition : definitions) { Property property = propertiesByName.get(definition.name()); - boolean missing = property == null || property.value() == null || property.value().isBlank(); + boolean missing = property == null + || property.value() == null + || (property.value() instanceof String s && s.isBlank()); if (missing) { if (definition.required()) { @@ -133,7 +78,7 @@ private void validateAgainstTemplate(EntityTemplate template, } propertyValidationService - .validatePropertyValue(definition, property.value(), property.rawValue()) + .validatePropertyValue(definition, property.value()) .forEach(violations::add); } } @@ -141,7 +86,7 @@ private void validateAgainstTemplate(EntityTemplate template, /// Checks for existing entity with same template and identifier to prevent duplicates. /// @param entity the entity to check for existence /// @throws EntityAlreadyExistsException if an entity with the same template and identifier already exists - void checkEntityAlreadyExist(final Entity entity) { + void checkUniqueness(final Entity entity) { if (entity.identifier() != null && entityRepository .findByTemplateIdentifierAndIdentifier(entity.templateIdentifier(), entity.identifier()) diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java new file mode 100644 index 00000000..34aab2f2 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java @@ -0,0 +1,118 @@ +package com.decathlon.idp_core.domain.service.entity_template; + +import java.util.Objects; + +import org.springframework.stereotype.Service; + +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNameAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; +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.port.EntityTemplateRepositoryPort; + +import lombok.RequiredArgsConstructor; + +/// Domain service to centralize all functional validation rules for [EntityTemplate] operations. +/// +/// **Key responsibilities:** +/// - Identifier and name uniqueness enforcement for create and update operations +/// - Property-rule compatibility validation (type vs. rule constraints) delegated to [PropertyDefinitionValidationService] +/// - Template existence verification before deletion +@Service +@RequiredArgsConstructor +public class EntityTemplateValidationService { + + private final EntityTemplateRepositoryPort entityTemplateRepositoryPort; + private final PropertyDefinitionValidationService propertyDefinitionValidationService; + + /// Validates all business rules before creating a new entity template. + /// + /// **Business rules enforced:** + /// - If `identifier` is provided it must not already exist in the system. + /// - If `name` is provided it must not already exist in the system. + /// - Property rules must be compatible with their declared property type. + /// + /// @param entityTemplate the template candidate to validate + /// @throws EntityTemplateAlreadyExistsException when identifier is already taken + /// @throws EntityTemplateNameAlreadyExistsException when name is already taken + public void validateForCreate(EntityTemplate entityTemplate) { + validateIdentifierUniqueness(entityTemplate.identifier()); + validateNameUniqueness(entityTemplate.name()); + validatePropertyRules(entityTemplate); + } + + /// Validates all business rules before persisting an updated entity template. + /// + /// **Business rules enforced:** + /// - If the identifier changed, the new value must not collide with another template. + /// - If the name changed, the new value must not collide with another template. + /// - Property rules in the merged template must be compatible with their declared type. + /// + /// @param currentIdentifier the identifier of the template being replaced + /// @param existingName the current name of the template being replaced + /// @param mergedTemplate the fully-merged template carrying the desired state + /// @throws EntityTemplateAlreadyExistsException when the new identifier is already taken + /// @throws EntityTemplateNameAlreadyExistsException when the new name is already taken + public void validateForUpdate(String currentIdentifier, String existingName, EntityTemplate mergedTemplate) { + if (!currentIdentifier.equals(mergedTemplate.identifier())) { + validateIdentifierUniqueness(mergedTemplate.identifier()); + } + if (!Objects.equals(existingName, mergedTemplate.name())) { + validateNameUniqueness(mergedTemplate.name()); + } + validatePropertyRules(mergedTemplate); + } + + /// Validates that a template identifier is non-null and refers to an existing template. + /// + /// @param identifier the identifier of the template to delete + /// @throws IllegalArgumentException when `identifier` is null + /// @throws EntityTemplateNotFoundException when no template matches `identifier` + public void validateForDelete(String identifier) { + if (identifier == null) { + throw new IllegalArgumentException("Template identifier must not be null"); + } + checkTemplateExists(identifier); + } + + /// Checks that the entity template exists. + /// + /// @param identifier the identifier to check for existence + /// @throws EntityTemplateNotFoundException when no template matches `identifier` + public void checkTemplateExists(String identifier) { + if (!entityTemplateRepositoryPort.existsByIdentifier(identifier)) { + throw new EntityTemplateNotFoundException("identifier", identifier); + } + } + + /// Checks that no other template already uses the given identifier. + /// + /// @param identifier the identifier to check for uniqueness + /// @throws EntityTemplateAlreadyExistsException when identifier is already taken + public void validateIdentifierUniqueness(String identifier) { + if (entityTemplateRepositoryPort.existsByIdentifier(identifier)) { + throw new EntityTemplateAlreadyExistsException(identifier); + } + } + + /// Checks that no other template already uses the given name. + /// + /// @param name the name to check for uniqueness + /// @throws EntityTemplateNameAlreadyExistsException when name is already taken + public void validateNameUniqueness(String name) { + if (entityTemplateRepositoryPort.existsByName(name)) { + throw new EntityTemplateNameAlreadyExistsException(name); + } + } + + public void validatePropertyRules(EntityTemplate entityTemplate) { + if (entityTemplate.propertiesDefinitions() == null) { + return; + } + for (PropertyDefinition property : entityTemplate.propertiesDefinitions()) { + propertyDefinitionValidationService.validatePropertyDefinitionRules(property); + } + } + +} \ No newline at end of file diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java new file mode 100644 index 00000000..cebf279f --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java @@ -0,0 +1,297 @@ +package com.decathlon.idp_core.domain.service.entity_template; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_RULES_BOOLEAN_NOT_ALLOWED; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_RULES_MAX_LENGTH_POSITIVE; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_RULES_MIN_LENGTH_NON_NEGATIVE; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_RULES_NUMERIC_RULE_NOT_ALLOWED; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.minMaxConstraintViolated; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ruleNotAllowed; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.rulesAreIncompatible; + + +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +import org.springframework.stereotype.Service; + +import com.decathlon.idp_core.domain.exception.property.PropertyDefinitionRulesConflictException; +import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; +import com.decathlon.idp_core.domain.model.entity_template.PropertyRules; +import com.decathlon.idp_core.domain.model.enums.PropertyType; + +/// Domain service for validating property rule compatibility with property types. +/// +/// **Business rules:** +/// - STRING: Allows format, enum_values, regex, max_length, min_length. Rejects numeric rules. +/// - NUMBER: Allows max_value, min_value. Rejects string and format rules. +/// - BOOLEAN: Rejects all rules; rules field must be null or empty. +/// +@Service +public class PropertyDefinitionValidationService { + + // Rule name constants + public static final String REGEX = "regex"; + public static final String LENGTH = "length"; + public static final String VALUE = "value"; + public static final String FORMAT = "format"; + public static final String ENUM_VALUES = "enum_values"; + public static final String MAX_LENGTH = "max_length"; + public static final String MIN_LENGTH = "min_length"; + public static final String MAX_VALUE = "max_value"; + public static final String MIN_VALUE = "min_value"; + + /// Validates property rules are compatible with the property's data type. + /// + /// **Contract:** Performs comprehensive validation including: + /// - Rule type compatibility with property type + /// - Numeric constraint ordering (min ≤ max) + /// - Boolean properties reject all rules + /// + /// @param propertyDefinition the property definition containing type and rules + /// @throws PropertyDefinitionRulesConflictException when rules violate business invariants + public void validatePropertyDefinitionRules(PropertyDefinition propertyDefinition) { + if (propertyDefinition.rules() == null) { + return; + } + + PropertyRules rules = propertyDefinition.rules(); + PropertyType type = propertyDefinition.type(); + + switch (type) { + case STRING: + validateStringPropertyRules(propertyDefinition.name(), rules); + break; + case NUMBER: + validateNumberPropertyRules(propertyDefinition.name(), rules); + break; + case BOOLEAN: + validateBooleanPropertyRules(propertyDefinition.name(), rules); + break; + default: + throw new IllegalArgumentException("Unknown property type: " + type); + } + } + + /// Validates rules for STRING property type. + /// + /// **Allowed rules:** format, enum_values, regex, max_length, min_length + /// **Rejected rules:** max_value, min_value (numeric) + /// **Conflicting rules:** format, regex, and enum_values are mutually exclusive; + /// enum_values is also mutually exclusive with max_length and min_length + /// **Constraints:** 0 ≤ min_length ≤ max_length, regex must be valid + /// + /// @param propertyName name of the property (for error reporting) + /// @param rules the property rules to validate + /// @throws PropertyDefinitionRulesConflictException when rules defined violate any of the above constraints + private void validateStringPropertyRules(String propertyName, PropertyRules rules) { + validateStringIncompatibleRules(propertyName, rules); + validateStringConstraints(propertyName, rules); + + // Validate regex pattern is valid + if (rules.regex() != null && !rules.regex().isBlank()) { + validateRegexPattern(propertyName, rules.regex()); + } + } + + /// Validates numeric constraints for STRING property rules. + /// + /// **Constraints enforced:** + /// - min_length must be non-negative (≥ 0) + /// - max_length must be positive (> 0) + /// - min_length must be less than or equal to max_length + /// + /// @param propertyName name of the property (for error reporting) + /// @param rules the property rules to validate + /// @throws PropertyDefinitionRulesConflictException when any constraint is violated + private void validateStringConstraints(String propertyName, PropertyRules rules) { + // Validate min_length is non-negative + if (rules.minLength() != null && rules.minLength() < 0) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.STRING, + PROPERTY_RULES_MIN_LENGTH_NON_NEGATIVE + ); + } + // Validate max_length is not zero or negative + if (rules.maxLength() != null && rules.maxLength() <= 0) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.STRING, + PROPERTY_RULES_MAX_LENGTH_POSITIVE + ); + } + // Validate min_length is below or equal to max_length + if (rules.minLength() != null && rules.maxLength() != null && rules.minLength() > rules.maxLength()) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.STRING, + minMaxConstraintViolated(LENGTH) + ); + } + } + + /// Validates rule compatibility and mutual exclusivity for STRING property rules. + /// + /// **Incompatibility rules enforced:** + /// - Numeric rules (max_value, min_value) are not allowed for STRING type + /// - format, regex, and enum_values are mutually exclusive + /// - enum_values and length constraints (max_length, min_length) are mutually exclusive + /// + /// @param propertyName name of the property (for error reporting) + /// @param rules the property rules to validate + /// @throws PropertyDefinitionRulesConflictException when incompatible rules are both present + private void validateStringIncompatibleRules(String propertyName, PropertyRules rules){ + // Reject numeric rules for STRING type + if (rules.maxValue() != null || rules.minValue() != null) { + String ruleName = rules.maxValue() != null ? MAX_VALUE : MIN_VALUE; + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.STRING, + PROPERTY_RULES_NUMERIC_RULE_NOT_ALLOWED.replace("{rule}", ruleName) + ); + } + + // format, regex, and enum_values are incompatible with each other + if (rules.format() != null && rules.enumValues() != null) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.STRING, + rulesAreIncompatible(FORMAT, ENUM_VALUES) + ); + } + if (rules.format() != null && rules.regex() != null) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.STRING, + rulesAreIncompatible(FORMAT, REGEX) + ); + } + if (rules.regex() != null && rules.enumValues() != null) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.STRING, + rulesAreIncompatible(REGEX, ENUM_VALUES) + ); + } + + // enum_values and length constraints are incompatible with each other + if (rules.enumValues() != null && rules.maxLength() != null) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.STRING, + rulesAreIncompatible(ENUM_VALUES, MAX_LENGTH) + ); + } + if (rules.enumValues() != null && rules.minLength() != null) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.STRING, + rulesAreIncompatible(ENUM_VALUES, MIN_LENGTH) + ); + } + + } + + /// Validates rules for NUMBER property type. + /// + /// **Allowed rules:** max_value, min_value + /// **Rejected rules:** format, enum_values, regex, max_length, min_length (string) + /// **Constraints:** min_value ≤ max_value + /// + /// @param propertyName name of the property (for error reporting) + /// @param rules the property rules to validate + /// @throws PropertyDefinitionRulesConflictException when string rules are present + /// or min/max value constraints are violated + private void validateNumberPropertyRules(String propertyName, PropertyRules rules) { + if (rules.format() != null) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.NUMBER, + ruleNotAllowed(FORMAT, PropertyType.NUMBER.name()) + ); + } + + if (rules.enumValues() != null) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.NUMBER, + ruleNotAllowed(ENUM_VALUES, PropertyType.NUMBER.name()) + ); + } + + if (rules.regex() != null) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.NUMBER, + ruleNotAllowed(REGEX, PropertyType.NUMBER.name()) + ); + } + + if (rules.minLength() != null) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.NUMBER, + ruleNotAllowed(MIN_LENGTH, PropertyType.NUMBER.name()) + ); + } + + if (rules.maxLength() != null) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.NUMBER, + ruleNotAllowed(MAX_LENGTH, PropertyType.NUMBER.name()) + ); + } + + if (rules.minValue() != null && rules.maxValue() != null && rules.minValue() > rules.maxValue()) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.NUMBER, + minMaxConstraintViolated(VALUE) + ); + } + } + + /// Validates rules for BOOLEAN property type. + /// + /// **Allowed rules:** None + /// **Rejected rules:** All rules must be null or empty + /// + /// @param propertyName name of the property (for error reporting) + /// @param rules the property rules to validate + /// @throws PropertyDefinitionRulesConflictException when any rule is set for BOOLEAN + private void validateBooleanPropertyRules(String propertyName, PropertyRules rules) { + if (rules.format() != null || + rules.enumValues() != null || + rules.regex() != null || + rules.maxLength() != null || + rules.minLength() != null || + rules.maxValue() != null || + rules.minValue() != null) { + + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.BOOLEAN, + PROPERTY_RULES_BOOLEAN_NOT_ALLOWED + ); + } + } + + /// Validates that the provided regex pattern is syntactically valid. + /// + /// @param propertyName name of the property (for error reporting) + /// @param regexPattern the regex pattern to validate + /// @throws PropertyDefinitionRulesConflictException if the pattern is syntactically invalid + private void validateRegexPattern(String propertyName, String regexPattern) { + try { + Pattern.compile(regexPattern); + } catch (PatternSyntaxException e) { + throw new PropertyDefinitionRulesConflictException( + propertyName, + PropertyType.STRING, + "Invalid regex pattern: " + e.getMessage() + ); + } + } + +} \ No newline at end of file diff --git a/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java index d2ba01c0..604891cf 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java @@ -38,20 +38,16 @@ public class PropertyValidationService { /** * Validates a concrete property value against its property definition. - * Type compatibility is checked first against the original raw value - * before applying any rule-based validations. + * The value's runtime Java type is checked first against the expected + * [PropertyType] (STRING ⇒ {@link String}, NUMBER ⇒ {@link Number}, + * BOOLEAN ⇒ {@link Boolean}). When the type matches, the value is + * normalized to a string and the type-specific rules are evaluated. * * @param propertyDefinition property definition with expected type and optional rules - * @param rawValue raw property value as string - * @param originalValue the original untyped value from the API input for type checking, - * may be null when loaded from persistence + * @param rawValue raw property value preserving its original JSON type * @return list of violations for this value; empty when valid */ - public List validatePropertyValue(PropertyDefinition propertyDefinition, String rawValue, Object originalValue) { - List typeMismatch = checkOriginalValueType(propertyDefinition.name(), propertyDefinition.type(), originalValue); - if (!typeMismatch.isEmpty()) { - return typeMismatch; - } + public List validatePropertyValue(PropertyDefinition propertyDefinition, Object rawValue) { return switch (propertyDefinition.type()) { case STRING -> validateStringPropertyValue(propertyDefinition.name(), rawValue, propertyDefinition.rules()); case NUMBER -> validateNumberPropertyValue(propertyDefinition.name(), rawValue, propertyDefinition.rules()); @@ -59,37 +55,9 @@ public List validatePropertyValue(PropertyDefinition propertyDefinition, }; } - /// Checks that the original JSON value type is compatible with the expected [PropertyType]. - /// - /// When `originalValue` is non-null, its Java type is inspected: - /// - STRING expects a Java `String` - /// - NUMBER expects a Java `Number` - /// - BOOLEAN expects a Java `Boolean` - /// - /// If `originalValue` is null (e.g. loaded from persistence), the check is skipped - /// and type validation falls through to the string-based validators. - /// - /// @param propertyName property name for error reporting - /// @param expectedType the expected property type from the template definition - /// @param originalValue the original untyped value from the API input - /// @return a single-element list with a type mismatch message, or an empty list if compatible - private List checkOriginalValueType(String propertyName, PropertyType expectedType, Object originalValue) { - if (originalValue == null) { - return List.of(); - } - boolean compatible = switch (expectedType) { - case STRING -> originalValue instanceof String; - case NUMBER -> originalValue instanceof Number || originalValue instanceof String; - case BOOLEAN -> originalValue instanceof Boolean || originalValue instanceof String; - }; - if (!compatible) { - return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, expectedType)); - } - return List.of(); - } - private List validateStringPropertyValue(String propertyName, String rawValue, PropertyRules rules) { - if (rawValue == null) { + private List validateStringPropertyValue(String propertyName, Object rawValue, PropertyRules rules) { + if (!(rawValue instanceof String stringValue)) { return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.STRING)); } @@ -99,33 +67,41 @@ private List validateStringPropertyValue(String propertyName, String raw var violations = new ArrayList(); - if (rules.minLength() != null && rawValue.length() < rules.minLength()) { + if (rules.minLength() != null && stringValue.length() < rules.minLength()) { violations.add(PROPERTY_MIN_LENGTH_VIOLATION.formatted(propertyName, rules.minLength())); } - if (rules.maxLength() != null && rawValue.length() > rules.maxLength()) { + if (rules.maxLength() != null && stringValue.length() > rules.maxLength()) { violations.add(PROPERTY_MAX_LENGTH_VIOLATION.formatted(propertyName, rules.maxLength())); } if (rules.regex() != null - && !patternCache.computeIfAbsent(rules.regex(), Pattern::compile).matcher(rawValue).matches()) { + && !patternCache.computeIfAbsent(rules.regex(), Pattern::compile).matcher(stringValue).matches()) { violations.add(PROPERTY_REGEX_VIOLATION.formatted(propertyName)); } if (rules.enumValues() != null && !rules.enumValues().isEmpty() - && rules.enumValues().stream().noneMatch(enumValue -> enumValue.equalsIgnoreCase(rawValue))) { + && rules.enumValues().stream().noneMatch(enumValue -> enumValue.equalsIgnoreCase(stringValue))) { violations.add(PROPERTY_ENUM_VIOLATION.formatted(propertyName, rules.enumValues())); } - if (rules.format() != null && !matchesFormat(rules.format(), rawValue)) { + if (rules.format() != null && !matchesFormat(rules.format(), stringValue)) { violations.add(PROPERTY_FORMAT_VIOLATION.formatted(propertyName, rules.format())); } return List.copyOf(violations); } - private List validateNumberPropertyValue(String propertyName, String rawValue, PropertyRules rules) { + private List validateNumberPropertyValue(String propertyName, Object rawValue, PropertyRules rules) { final BigDecimal parsedValue; - try { - parsedValue = new BigDecimal(rawValue); - } catch (NumberFormatException _) { - return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.NUMBER)); + switch (rawValue) { + case Number number -> parsedValue = new BigDecimal(number.toString()); + case String string -> { + try { + parsedValue = new BigDecimal(string); + } catch (NumberFormatException _) { + return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.NUMBER)); + } + } + case null, default -> { + return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.NUMBER)); + } } if (rules == null) { @@ -144,8 +120,12 @@ private List validateNumberPropertyValue(String propertyName, String raw return List.copyOf(violations); } - private List validateBooleanPropertyValue(String propertyName, String rawValue) { - if ("true".equalsIgnoreCase(rawValue) || "false".equalsIgnoreCase(rawValue)) { + private List validateBooleanPropertyValue(String propertyName, Object rawValue) { + if (rawValue instanceof Boolean) { + return List.of(); + } + if (rawValue instanceof String string + && ("true".equalsIgnoreCase(string) || "false".equalsIgnoreCase(string))) { return List.of(); } return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.BOOLEAN)); 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 c2215346..f9f8d90f 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 @@ -31,7 +31,7 @@ import static org.springframework.http.HttpStatus.CREATED; import static org.springframework.http.HttpStatus.OK; -import lombok.AllArgsConstructor; +import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -74,7 +74,7 @@ @RestController @RequestMapping("/api/v1/entities") @Tag(name = "Entities Management", description = "Operations related to entity management") -@AllArgsConstructor +@RequiredArgsConstructor public class EntityController { private final EntityService entityService; @@ -127,7 +127,7 @@ public Page getEntities( public EntityDtoOut getEntity( @PathVariable String templateIdentifier, @PathVariable String entityIdentifier) { - Entity entity = entityService.getEntityByTemplateIdentifierAnIdentifier(templateIdentifier, entityIdentifier); + Entity entity = entityService.getEntityByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier); return entityDtoOutMapper.fromEntity(entity); } 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 1cfbf69a..393e68c8 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 @@ -13,12 +13,12 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.method.annotation.HandlerMethodValidationException; -import com.decathlon.idp_core.domain.exception.EntityNotFoundException; -import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.EntityTemplateAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.EntityTemplateNameAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; -import com.decathlon.idp_core.domain.exception.EntityValidationException; +import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; +import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNameAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java index 7bc0a59a..45dc45af 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java @@ -4,7 +4,7 @@ import java.util.List; import java.util.Map; -import lombok.AllArgsConstructor; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import com.decathlon.idp_core.domain.model.entity.Entity; @@ -28,7 +28,7 @@ /// **API contract support:** Enables clean separation between API request format /// and internal domain model structure for maintainable API evolution. @Component -@AllArgsConstructor +@RequiredArgsConstructor public class EntityDtoInMapper { /// Converts an entity creation request DTO to a domain entity. @@ -40,20 +40,11 @@ public Entity fromEntityDtoInToEntity(EntityDtoIn entityDtoIn, String entityTemp List properties = entityDtoIn.getProperties() == null ? Collections.emptyList() : entityDtoIn.getProperties().entrySet().stream() - .map((Map.Entry entry) -> { - String value; - if (entry.getValue() != null) { - value = String.valueOf(entry.getValue()); - } else { - value = null; - } - return new Property( - null, - entry.getKey(), - value, - entry.getValue() - ); - }) + .map((Map.Entry entry) -> new Property( + null, + entry.getKey(), + entry.getValue() + )) .toList(); List relations = entityDtoIn.getRelations() == null ? Collections.emptyList() 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 00721348..12162246 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 @@ -9,6 +9,7 @@ import java.util.stream.Collectors; import java.util.stream.Stream; +import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.stereotype.Component; @@ -20,14 +21,12 @@ 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.service.entity.EntityService; import com.decathlon.idp_core.domain.service.EntityTemplateService; import com.decathlon.idp_core.domain.service.RelationService; +import com.decathlon.idp_core.domain.service.entity.EntityService; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityDtoOut; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntitySummaryDto; -import lombok.AllArgsConstructor; - /// Adapter mapper for converting domain [Entity] objects to API DTOs. /// /// **Infrastructure mapping responsibilities:** @@ -46,7 +45,7 @@ /// - Integrates with Jackson for JSON serialization patterns /// - Stateless design ensures thread safety in web containers @Component -@AllArgsConstructor +@RequiredArgsConstructor public class EntityDtoOutMapper { private final EntityTemplateService entityTemplateService; @@ -72,11 +71,11 @@ public EntityDtoOut fromEntity(Entity entity) { /// to minimize database queries. Builds summary maps for efficient relationship /// resolution across the entire page. /// - /// @param entities paginated domain entities from repository layer + /// @param entities paginated domain entities from repository layer /// @param entityTemplateIdentifier template identifier for batch template resolution /// @return paginated API DTOs with complete relationship data public Page fromEntitiesPageToDtoPage(Page entities, - String entityTemplateIdentifier) { + String entityTemplateIdentifier) { Map pageEntitiesSummaries = buildRelatedEntitiesSummaryMapByPage(entities); Map> relationTargetOwnershipsMap = buildRelationsAsTargetSummaryMapByPage( @@ -94,7 +93,7 @@ public Page fromEntitiesPageToDtoPage(Page entities, /// @param entity the entity to map /// @param entityTemplate the template for property type mapping /// @return the mapped DTO - private EntityDtoOut fromEntityUsingEntityTemplate(Entity entity, EntityTemplate entityTemplate) { + private EntityDtoOut fromEntityUsingEntityTemplate(Entity entity, EntityTemplate entityTemplate) { Map props = mapPropertiesDto(entity, entityTemplate); List allTargetIdentifiers = getAllTargetIdentifiersFromEntityRelations(entity); @@ -120,13 +119,13 @@ private EntityDtoOut fromEntityUsingEntityTemplate(Entity entity, EntityTemplate /// /// @param entity the entity to map /// @param entityTemplate the template for property type mapping - /// @param relatedEntitiesSummaries map of entity summaries for relation - /// targets + /// @param relatedEntitiesSummaries map of entity summaries for relation + /// targets /// @param relationTargetOwnershipsMap map of relations-as-target for the entity /// @return the mapped DTO private EntityDtoOut fromEntityUsingEntityTemplateAndSummaryMap(Entity entity, EntityTemplate entityTemplate, - Map relatedEntitiesSummaries, - Map> relationTargetOwnershipsMap) { + Map relatedEntitiesSummaries, + Map> relationTargetOwnershipsMap) { Map props = mapPropertiesDto(entity, entityTemplate); Map> relationMap = mapRelationsDto(entity, relatedEntitiesSummaries); @@ -163,44 +162,42 @@ private Map mapPropertiesDto(Entity entity, EntityTemplate entit Property::name, prop -> { PropertyDefinition def = propertiesDefinitions.get(prop.name()); - if (def != null) { - PropertyType type = def.type(); - String value = prop.value(); - if (PropertyType.NUMBER.equals(type)) { - try { - return Double.valueOf(value); - } catch (NumberFormatException _) { - return null; - } - } else if (PropertyType.BOOLEAN.equals(type)) { - return Boolean.valueOf(value); + Object rawValue = prop.value(); + if (def == null || rawValue == null) { + return rawValue; + } + String stringValue = String.valueOf(rawValue); + PropertyType type = def.type(); + if (PropertyType.NUMBER.equals(type)) { + try { + return Double.valueOf(stringValue); + } catch (NumberFormatException _) { + return null; } - // Default to string - return value; - } else { - // Fallback if propertyDefinition is missing - return prop.value(); + } else if (PropertyType.BOOLEAN.equals(type)) { + return Boolean.valueOf(stringValue); } + return stringValue; })); } /// Maps the relations of an entity to a map of relation names to lists of target /// entity summaries. /// - /// @param entity the entity whose relations to map + /// @param entity the entity whose relations to map /// @param relatedEntitiesSummaries map of entity summaries for relation targets /// @return a map of relation names to lists of target entity summaries private Map> mapRelationsDto(Entity entity, - Map relatedEntitiesSummaries) { + Map relatedEntitiesSummaries) { return entity.relations() == null ? Collections.emptyMap() : entity.relations().stream() - .collect(Collectors.groupingBy( - Relation::name, - Collectors.flatMapping(rel -> rel.targetEntityIdentifiers().stream() + .collect(Collectors.groupingBy( + Relation::name, + Collectors.flatMapping(rel -> rel.targetEntityIdentifiers().stream() .map(relatedEntitiesSummaries::get) .filter(Objects::nonNull), - Collectors.toList()))); + Collectors.toList()))); } /// @@ -208,11 +205,11 @@ private Map> mapRelationsDto(Entity entity, /// lists of source entity summaries. /// /// @param entity the entity whose relations-as-target to - /// map + /// map /// @param relationTargetOwnershipsMap map of relations-as-target for the entity /// @return a map of relation names to lists of source entity summaries private Map> mapRelationsAsTargetDto(Entity entity, - Map> relationTargetOwnershipsMap) { + Map> relationTargetOwnershipsMap) { List relationAsTargetSummaries = relationTargetOwnershipsMap .get(entity.identifier()); if (relationAsTargetSummaries == null) { @@ -271,8 +268,8 @@ private List getAllTargetIdentifiersFromEntityRelations(Entity entity) { return entity.relations() == null ? Collections.emptyList() : new ArrayList<>(entity.relations().stream() - .flatMap(rel -> rel.targetEntityIdentifiers().stream()) - .collect(Collectors.toSet())); + .flatMap(rel -> rel.targetEntityIdentifiers().stream()) + .collect(Collectors.toSet())); } /// @@ -286,7 +283,7 @@ private List getUniqueTargetIdentifiersInPage(Page entities) { .flatMap(entity -> entity.relations() == null ? Stream.empty() : entity.relations().stream() - .flatMap(rel -> rel.targetEntityIdentifiers().stream())) + .flatMap(rel -> rel.targetEntityIdentifiers().stream())) .collect(Collectors.toSet())); } @@ -309,10 +306,10 @@ private Map buildEntitiesSummariesMap(List tar return targetIdentifiers.isEmpty() ? Collections.emptyMap() : entityService.getEntitiesSummariesByIndentifiers(targetIdentifiers) - .stream() - .collect(Collectors.toMap( - EntitySummary::identifier, - es -> new EntitySummaryDto(es.identifier(), es.name()))); + .stream() + .collect(Collectors.toMap( + EntitySummary::identifier, + es -> new EntitySummaryDto(es.identifier(), es.name()))); } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java index c22ffbb6..9117cc46 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java @@ -3,6 +3,7 @@ import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.MappingConstants; +import org.mapstruct.Named; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.Property; @@ -18,12 +19,28 @@ public interface EntityPersistenceMapper { EntityJpaEntity toJpa(Entity domain); - @Mapping(target = "rawValue", ignore = true) + @Mapping(target = "value", source = "value", qualifiedByName = "propertyValueFromString") Property toDomain(PropertyJpaEntity jpa); + @Mapping(target = "value", source = "value", qualifiedByName = "propertyValueToString") PropertyJpaEntity toJpa(Property domain); Relation toDomain(RelationJpaEntity jpa); RelationJpaEntity toJpa(Relation domain); + + /// Converts a domain property value (carried as [Object] to preserve the + /// original JSON type) into its canonical String representation for storage. + @Named("propertyValueToString") + default String propertyValueToString(Object value) { + return value == null ? null : String.valueOf(value); + } + + /// Promotes a persisted String value to the domain [Object] representation. + /// Persistence is the source of truth for textual storage; richer typing + /// (Number/Boolean) is reconstructed by the API output mapper using the template. + @Named("propertyValueFromString") + default Object propertyValueFromString(String value) { + return value; + } } 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 a0a2d156..e5a14d3d 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 @@ -23,12 +23,15 @@ import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; -import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.EntityNotFoundException; -import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntitySummary; +import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; +import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; @ExtendWith(MockitoExtension.class) @DisplayName("EntityService Tests") @@ -37,9 +40,15 @@ class EntityServiceTest { @Mock private EntityRepositoryPort entityRepository; + @Mock + private EntityTemplateRepositoryPort entityTemplateRepository; + @Mock private EntityValidationService entityValidationService; + @Mock + private EntityTemplateValidationService entityTemplateValidationService; + @InjectMocks private EntityService entityService; @@ -87,10 +96,10 @@ void shouldReturnEntityByTemplateAndIdentifier() { when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) .thenReturn(Optional.of(entity)); - var result = entityService.getEntityByTemplateIdentifierAnIdentifier("web-service", "catalog-api"); + var result = entityService.getEntityByTemplateIdentifierAndIdentifier("web-service", "catalog-api"); assertSame(entity, result); - verify(entityValidationService).checkTemplateExist("web-service"); + verify(entityTemplateValidationService).checkTemplateExists("web-service"); verify(entityRepository).findByTemplateIdentifierAndIdentifier("web-service", "catalog-api"); } @@ -101,38 +110,43 @@ void shouldThrowWhenEntityNotFoundByTemplateAndIdentifier() { .thenReturn(Optional.empty()); assertThrows(EntityNotFoundException.class, - () -> entityService.getEntityByTemplateIdentifierAnIdentifier("web-service", "missing-entity")); + () -> entityService.getEntityByTemplateIdentifierAndIdentifier("web-service", "missing-entity")); } @Test @DisplayName("Should create entity when validations pass") void shouldCreateEntityWhenValidationsPass() { var entity = entity("web-service", "catalog-api", "Catalog API"); + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", List.of(), List.of()); + when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); when(entityRepository.save(entity)).thenReturn(entity); var result = entityService.createEntity(entity); assertSame(entity, result); - InOrder inOrder = inOrder(entityValidationService, entityRepository); - inOrder.verify(entityValidationService).checkTemplateExist("web-service"); - inOrder.verify(entityValidationService).checkEntityAlreadyExist(entity); - inOrder.verify(entityValidationService).validateEntity(entity); + InOrder inOrder = inOrder(entityTemplateRepository, entityValidationService, entityRepository); + inOrder.verify(entityTemplateRepository).findByIdentifier("web-service"); + inOrder.verify(entityValidationService).checkUniqueness(entity); + inOrder.verify(entityValidationService).validateEntity(entity, template); inOrder.verify(entityRepository).save(entity); + verifyNoInteractions(entityTemplateValidationService); } @Test @DisplayName("Should not save when entity already exists") void shouldNotSaveWhenEntityAlreadyExists() { var entity = entity("web-service", "catalog-api", "Catalog API"); + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", List.of(), List.of()); var alreadyExists = new EntityAlreadyExistsException("web-service", "catalog-api"); - org.mockito.Mockito.doThrow(alreadyExists).when(entityValidationService).checkEntityAlreadyExist(entity); + when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); + org.mockito.Mockito.doThrow(alreadyExists).when(entityValidationService).checkUniqueness(entity); assertThrows(EntityAlreadyExistsException.class, () -> entityService.createEntity(entity)); - verify(entityValidationService).checkTemplateExist("web-service"); - verify(entityValidationService).checkEntityAlreadyExist(entity); + verify(entityTemplateRepository).findByIdentifier("web-service"); + verify(entityValidationService).checkUniqueness(entity); verifyNoMoreInteractions(entityRepository); } @@ -140,16 +154,14 @@ void shouldNotSaveWhenEntityAlreadyExists() { @DisplayName("Should stop immediately when template does not exist") void shouldStopWhenTemplateDoesNotExistOnCreate() { var entity = entity("missing-template", "catalog-api", "Catalog API"); - var templateNotFound = new EntityTemplateNotFoundException("identifier", "missing-template"); - org.mockito.Mockito.doThrow(templateNotFound) - .when(entityValidationService) - .checkTemplateExist("missing-template"); + when(entityTemplateRepository.findByIdentifier("missing-template")).thenReturn(Optional.empty()); assertThrows(EntityTemplateNotFoundException.class, () -> entityService.createEntity(entity)); - verify(entityValidationService).checkTemplateExist("missing-template"); - verifyNoInteractions(entityRepository); + verify(entityTemplateRepository).findByIdentifier("missing-template"); + verifyNoInteractions(entityValidationService); + verifyNoMoreInteractions(entityRepository); } private Entity entity(String templateIdentifier, String identifier, String name) { diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java index 02c7116b..8b719e0b 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java @@ -1,12 +1,6 @@ package com.decathlon.idp_core.domain.service.entity; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_IDENTIFIER_MANDATORY; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_NAME_MANDATORY; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_NAME_MANDATORY; import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_REQUIRED_MISSING; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_VALUE_MANDATORY; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_NAME_MANDATORY_SIMPLE; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TARGET_IDENTIFIERS_NOT_NULL; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -26,9 +20,8 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; -import com.decathlon.idp_core.domain.exception.EntityValidationException; +import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.Property; import com.decathlon.idp_core.domain.model.entity.Relation; @@ -37,7 +30,6 @@ import com.decathlon.idp_core.domain.model.entity_template.PropertyRules; import com.decathlon.idp_core.domain.model.enums.PropertyType; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; -import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; import com.decathlon.idp_core.domain.service.property.PropertyValidationService; @ExtendWith(MockitoExtension.class) @@ -47,8 +39,6 @@ class EntityValidationServiceTest { @Mock private EntityRepositoryPort entityRepository; - @Mock - private EntityTemplateRepositoryPort entityTemplateRepository; @Mock private PropertyValidationService propertyValidationService; @@ -56,23 +46,6 @@ class EntityValidationServiceTest { @InjectMocks private EntityValidationService entityValidationService; - @Test - @DisplayName("Should pass checkTemplateExist when template exists") - void shouldPassCheckTemplateExistWhenTemplateExists() { - when(entityTemplateRepository.existsByIdentifier("web-service")).thenReturn(true); - - assertDoesNotThrow(() -> entityValidationService.checkTemplateExist("web-service")); - } - - @Test - @DisplayName("Should throw checkTemplateExist when template does not exist") - void shouldThrowCheckTemplateExistWhenTemplateDoesNotExist() { - when(entityTemplateRepository.existsByIdentifier("missing-template")).thenReturn(false); - - assertThrows(EntityTemplateNotFoundException.class, - () -> entityValidationService.checkTemplateExist("missing-template")); - } - @Test @DisplayName("Should throw when entity with same identifier already exists") void shouldThrowWhenEntityAlreadyExists() { @@ -80,7 +53,7 @@ void shouldThrowWhenEntityAlreadyExists() { when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) .thenReturn(Optional.of(entity)); - assertThrows(EntityAlreadyExistsException.class, () -> entityValidationService.checkEntityAlreadyExist(entity)); + assertThrows(EntityAlreadyExistsException.class, () -> entityValidationService.checkUniqueness(entity)); } @Test @@ -88,22 +61,14 @@ void shouldThrowWhenEntityAlreadyExists() { void shouldNotQueryRepositoryWhenIdentifierIsNull() { var entity = entity("web-service", null, "Catalog API", List.of(), List.of()); - assertDoesNotThrow(() -> entityValidationService.checkEntityAlreadyExist(entity)); + assertDoesNotThrow(() -> entityValidationService.checkUniqueness(entity)); verify(entityRepository, never()).findByTemplateIdentifierAndIdentifier(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); } - @Test - @DisplayName("Should throw when template is missing during validateEntity") - void shouldThrowWhenTemplateMissingDuringValidateEntity() { - var entity = entity("missing-template", "catalog-api", "Catalog API", List.of(), List.of()); - when(entityTemplateRepository.findByIdentifier("missing-template")).thenReturn(Optional.empty()); - - assertThrows(EntityTemplateNotFoundException.class, () -> entityValidationService.validateEntity(entity)); - } @Test - @DisplayName("Should aggregate entity, property, relation, required and rule violations") + @DisplayName("Should aggregate property requirements and rule violations") void shouldAggregateAllViolationsDuringValidateEntity() { var portDefinition = new PropertyDefinition( UUID.randomUUID(), @@ -129,35 +94,24 @@ void shouldAggregateAllViolationsDuringValidateEntity() { List.of(requiredDefinition, portDefinition), List.of()); - var mockedRelation = org.mockito.Mockito.mock(Relation.class); - when(mockedRelation.name()).thenReturn(" "); - when(mockedRelation.targetEntityIdentifiers()).thenReturn(null); - var entity = entity( "web-service", - " ", - " ", + " ", // Blank identifier (handled by Jakarta, not this service) + " ", // Blank name (handled by Jakarta, not this service) List.of(new Property(UUID.randomUUID(), " ", " "), new Property(UUID.randomUUID(), "port", "80")), - List.of(mockedRelation)); + List.of()); // No relations - when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); - when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", " ")).thenReturn(Optional.empty()); - when(propertyValidationService.validatePropertyValue(portDefinition, "80", null)) + when(propertyValidationService.validatePropertyValue(portDefinition, "80")) .thenReturn(List.of("Property 'port' value must be greater than or equal to 1024")); - var exception = assertThrows(EntityValidationException.class, () -> entityValidationService.validateEntity(entity)); + var exception = assertThrows(EntityValidationException.class, () -> entityValidationService.validateEntity(entity, template)); - assertEquals(8, exception.getViolations().size()); - assertEquals(ENTITY_NAME_MANDATORY, exception.getViolations().get(0)); - assertEquals(ENTITY_IDENTIFIER_MANDATORY, exception.getViolations().get(1)); - assertEquals("Property[0]: " + PROPERTY_NAME_MANDATORY, exception.getViolations().get(2)); - assertEquals("Property[0]: " + PROPERTY_VALUE_MANDATORY, exception.getViolations().get(3)); - assertEquals("Relation[0]: " + RELATION_NAME_MANDATORY_SIMPLE, exception.getViolations().get(4)); - assertEquals("Relation[0]: " + RELATION_TARGET_IDENTIFIERS_NOT_NULL, exception.getViolations().get(5)); - assertEquals(PROPERTY_REQUIRED_MISSING.formatted("ownerEmail", "web-service"), exception.getViolations().get(6)); - assertEquals("Property 'port' value must be greater than or equal to 1024", exception.getViolations().get(7)); + // Expecting exactly 2 errors: the missing required property, and the invalid port value. + assertEquals(2, exception.getViolations().size()); + assertEquals(PROPERTY_REQUIRED_MISSING.formatted("ownerEmail", "web-service"), exception.getViolations().get(0)); + assertEquals("Property 'port' value must be greater than or equal to 1024", exception.getViolations().get(1)); - verify(propertyValidationService).validatePropertyValue(portDefinition, "80", null); + verify(propertyValidationService).validatePropertyValue(portDefinition, "80"); } @Test @@ -186,13 +140,11 @@ void shouldValidateEntitySuccessfullyWhenNoViolations() { List.of(new Property(UUID.randomUUID(), "version", "1.0.0")), null); - when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); - when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) - .thenReturn(Optional.empty()); - when(propertyValidationService.validatePropertyValue(versionDefinition, "1.0.0", null)).thenReturn(List.of()); - assertDoesNotThrow(() -> entityValidationService.validateEntity(entity)); - verify(propertyValidationService).validatePropertyValue(versionDefinition, "1.0.0", null); + when(propertyValidationService.validatePropertyValue(versionDefinition, "1.0.0")).thenReturn(List.of()); + + assertDoesNotThrow(() -> entityValidationService.validateEntity(entity, template)); + verify(propertyValidationService).validatePropertyValue(versionDefinition, "1.0.0"); } @Test @@ -216,14 +168,42 @@ void shouldSkipPropertyRuleValidationWhenOptionalPropertyMissing() { var entity = entity("web-service", "catalog-api", "Catalog API", List.of(), List.of()); - when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); - when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) - .thenReturn(Optional.empty()); - - assertDoesNotThrow(() -> entityValidationService.validateEntity(entity)); + assertDoesNotThrow(() -> entityValidationService.validateEntity(entity, template)); verifyNoInteractions(propertyValidationService); } + @Test + @DisplayName("Should validate property of type STRING with a numeric string value '1234'") + void shouldValidateStringPropertyWithNumericStringValue() { + var stringDefinition = new PropertyDefinition( + UUID.randomUUID(), + "versionCode", + "Version Code as String", + PropertyType.STRING, + false, + null + ); + + var template = new EntityTemplate( + UUID.randomUUID(), + "web-service", + "Web Service", + "desc", + List.of(stringDefinition), + List.of()); + + var entity = entity( + "web-service", + "catalog-api", + "Catalog API", + List.of(new Property(UUID.randomUUID(), "versionCode", "1234")), + null); + when(propertyValidationService.validatePropertyValue(stringDefinition, "1234")).thenReturn(List.of()); + + assertDoesNotThrow(() -> entityValidationService.validateEntity(entity, template)); + verify(propertyValidationService).validatePropertyValue(stringDefinition, "1234"); + } + private Entity entity( String templateIdentifier, String identifier, diff --git a/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java index 2ffdef8a..14ed3f64 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java @@ -30,7 +30,7 @@ class StringValidationTests { void shouldReportTypeMismatchWhenStringValueIsNull() { var definition = propertyDefinition("label", PropertyType.STRING, null); - var violations = service.validatePropertyValue(definition, null, null); + var violations = service.validatePropertyValue(definition, null); assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("label", PropertyType.STRING)), violations); } @@ -40,7 +40,7 @@ void shouldReportTypeMismatchWhenStringValueIsNull() { void shouldReturnNoViolationsWhenStringHasNoRules() { var definition = propertyDefinition("label", PropertyType.STRING, null); - var violations = service.validatePropertyValue(definition, "hello", "hello"); + var violations = service.validatePropertyValue(definition, "hello"); assertEquals(List.of(), violations); } @@ -51,7 +51,7 @@ void shouldReturnNoViolationsWhenStringPassesAllRules() { var rules = new PropertyRules(null, null, List.of("dev", "prod"), "^[a-z]+$", 10, 2, null, null); var definition = propertyDefinition("env", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "dev", "dev"); + var violations = service.validatePropertyValue(definition, "dev"); assertEquals(List.of(), violations); } @@ -62,7 +62,7 @@ void shouldReportMinLengthViolation() { var rules = new PropertyRules(null, null, null, null, null, 5, null, null); var definition = propertyDefinition("name", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "ab", "ab"); + var violations = service.validatePropertyValue(definition, "ab"); assertEquals(List.of(ValidationMessages.PROPERTY_MIN_LENGTH_VIOLATION.formatted("name", 5)), violations); } @@ -73,7 +73,7 @@ void shouldReportMaxLengthViolation() { var rules = new PropertyRules(null, null, null, null, 5, null, null, null); var definition = propertyDefinition("name", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "too-long-value", "too-long-value"); + var violations = service.validatePropertyValue(definition, "too-long-value"); assertEquals(List.of(ValidationMessages.PROPERTY_MAX_LENGTH_VIOLATION.formatted("name", 5)), violations); } @@ -84,7 +84,7 @@ void shouldReportRegexViolation() { var rules = new PropertyRules(null, null, null, "^[0-9]+$", null, null, null, null); var definition = propertyDefinition("code", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "abc", "abc"); + var violations = service.validatePropertyValue(definition, "abc"); assertEquals(List.of(ValidationMessages.PROPERTY_REGEX_VIOLATION.formatted("code")), violations); } @@ -95,7 +95,7 @@ void shouldAcceptValueMatchingRegex() { var rules = new PropertyRules(null, null, null, "^[0-9]+$", null, null, null, null); var definition = propertyDefinition("code", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "12345", "12345"); + var violations = service.validatePropertyValue(definition, "12345"); assertEquals(List.of(), violations); } @@ -106,7 +106,7 @@ void shouldReportEnumViolation() { var rules = new PropertyRules(null, null, List.of("ACTIVE", "INACTIVE"), null, null, null, null, null); var definition = propertyDefinition("status", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "UNKNOWN", "UNKNOWN"); + var violations = service.validatePropertyValue(definition, "UNKNOWN"); assertEquals(List.of(ValidationMessages.PROPERTY_ENUM_VIOLATION.formatted("status", List.of("ACTIVE", "INACTIVE"))), violations); } @@ -117,7 +117,7 @@ void shouldAcceptEnumValueCaseInsensitive() { var rules = new PropertyRules(null, null, List.of("ACTIVE", "INACTIVE"), null, null, null, null, null); var definition = propertyDefinition("status", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "active", "active"); + var violations = service.validatePropertyValue(definition, "active"); assertEquals(List.of(), violations); } @@ -128,7 +128,7 @@ void shouldSkipEnumCheckWhenEnumValuesIsEmpty() { var rules = new PropertyRules(null, null, List.of(), null, null, null, null, null); var definition = propertyDefinition("status", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "anything", "anything"); + var violations = service.validatePropertyValue(definition, "anything"); assertEquals(List.of(), violations); } @@ -139,7 +139,7 @@ void shouldReportFormatViolationForInvalidEmail() { var rules = new PropertyRules(null, PropertyFormat.EMAIL, null, null, null, null, null, null); var definition = propertyDefinition("email", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "not-an-email", "not-an-email"); + var violations = service.validatePropertyValue(definition, "not-an-email"); assertEquals(List.of(ValidationMessages.PROPERTY_FORMAT_VIOLATION.formatted("email", PropertyFormat.EMAIL)), violations); } @@ -150,7 +150,7 @@ void shouldAcceptValidEmailFormat() { var rules = new PropertyRules(null, PropertyFormat.EMAIL, null, null, null, null, null, null); var definition = propertyDefinition("email", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "user@example.com", "user@example.com"); + var violations = service.validatePropertyValue(definition, "user@example.com"); assertEquals(List.of(), violations); } @@ -161,7 +161,7 @@ void shouldReportFormatViolationForInvalidUrl() { var rules = new PropertyRules(null, PropertyFormat.URL, null, null, null, null, null, null); var definition = propertyDefinition("url", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "not-a-url", "not-a-url"); + var violations = service.validatePropertyValue(definition, "not-a-url"); assertEquals(List.of(ValidationMessages.PROPERTY_FORMAT_VIOLATION.formatted("url", PropertyFormat.URL)), violations); } @@ -172,7 +172,7 @@ void shouldAcceptValidUrlFormat() { var rules = new PropertyRules(null, PropertyFormat.URL, null, null, null, null, null, null); var definition = propertyDefinition("url", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "https://github.com/org/repo", "https://github.com/org/repo"); + var violations = service.validatePropertyValue(definition, "https://github.com/org/repo"); assertEquals(List.of(), violations); } @@ -183,7 +183,7 @@ void shouldReportMultipleStringViolations() { var rules = new PropertyRules(null, PropertyFormat.EMAIL, List.of("prod", "dev"), "^[a-z]+$", 5, 3, null, null); var definition = propertyDefinition("name", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "AA", "AA"); + var violations = service.validatePropertyValue(definition, "AA"); assertEquals(4, violations.size()); } @@ -195,33 +195,12 @@ void shouldUseCachedPatternForRepeatedRegex() { var definition = propertyDefinition("code", PropertyType.STRING, rules); // Validate twice with the same regex to exercise the cache - var violations1 = service.validatePropertyValue(definition, "abc", "abc"); - var violations2 = service.validatePropertyValue(definition, "def", "def"); + var violations1 = service.validatePropertyValue(definition, "abc"); + var violations2 = service.validatePropertyValue(definition, "def"); assertEquals(List.of(), violations1); assertEquals(List.of(), violations2); } - - @Test - @DisplayName("Should report type mismatch when a number is sent for a STRING property") - void shouldReportTypeMismatchWhenNumberSentForString() { - var rules = new PropertyRules(null, null, null, null, null, 5, null, null); - var definition = propertyDefinition("label", PropertyType.STRING, rules); - - var violations = service.validatePropertyValue(definition, "12", 12); - - assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("label", PropertyType.STRING)), violations); - } - - @Test - @DisplayName("Should report type mismatch when a boolean is sent for a STRING property") - void shouldReportTypeMismatchWhenBooleanSentForString() { - var definition = propertyDefinition("label", PropertyType.STRING, null); - - var violations = service.validatePropertyValue(definition, "true", true); - - assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("label", PropertyType.STRING)), violations); - } } @Nested @@ -233,7 +212,7 @@ class NumberValidationTests { void shouldReportTypeMismatchWhenNumberValueIsInvalid() { var definition = propertyDefinition("score", PropertyType.NUMBER, null); - var violations = service.validatePropertyValue(definition, "not-a-number", "not-a-number"); + var violations = service.validatePropertyValue(definition, "not-a-number"); assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("score", PropertyType.NUMBER)), violations); } @@ -243,7 +222,7 @@ void shouldReportTypeMismatchWhenNumberValueIsInvalid() { void shouldReturnNoViolationsWhenNumberHasNoRules() { var definition = propertyDefinition("count", PropertyType.NUMBER, null); - var violations = service.validatePropertyValue(definition, "42", 42); + var violations = service.validatePropertyValue(definition, "42"); assertEquals(List.of(), violations); } @@ -254,7 +233,7 @@ void shouldReturnNoViolationsWhenNumberIsWithinBounds() { var rules = new PropertyRules(null, null, null, null, null, null, 100, 0); var definition = propertyDefinition("score", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "50", 50); + var violations = service.validatePropertyValue(definition, "50"); assertEquals(List.of(), violations); } @@ -265,7 +244,7 @@ void shouldReportMinValueViolation() { var rules = new PropertyRules(null, null, null, null, null, null, 10, 5); var definition = propertyDefinition("size", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "3", 3); + var violations = service.validatePropertyValue(definition, "3"); assertEquals(List.of(ValidationMessages.PROPERTY_MIN_VALUE_VIOLATION.formatted("size", 5)), violations); } @@ -276,7 +255,7 @@ void shouldReportMaxValueViolation() { var rules = new PropertyRules(null, null, null, null, null, null, 10, 0); var definition = propertyDefinition("size", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "15", 15); + var violations = service.validatePropertyValue(definition, "15"); assertEquals(List.of(ValidationMessages.PROPERTY_MAX_VALUE_VIOLATION.formatted("size", 10)), violations); } @@ -288,7 +267,7 @@ void shouldReportBothMinAndMaxViolations() { var rules = new PropertyRules(null, null, null, null, null, null, 5, 10); var definition = propertyDefinition("range", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "7", 7); + var violations = service.validatePropertyValue(definition, "7"); // 7 < 10 (minValue) → min violation; 7 > 5 (maxValue) → max violation assertEquals(2, violations.size()); @@ -300,7 +279,7 @@ void shouldAcceptDecimalNumberValues() { var rules = new PropertyRules(null, null, null, null, null, null, 100, 0); var definition = propertyDefinition("rate", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "99.5", 99.5); + var violations = service.validatePropertyValue(definition, "99.5"); assertEquals(List.of(), violations); } @@ -310,7 +289,7 @@ void shouldAcceptDecimalNumberValues() { void shouldReportTypeMismatchWhenBooleanSentForNumber() { var definition = propertyDefinition("count", PropertyType.NUMBER, null); - var violations = service.validatePropertyValue(definition, "true", true); + var violations = service.validatePropertyValue(definition, "true"); assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("count", PropertyType.NUMBER)), violations); } @@ -324,9 +303,8 @@ class BooleanValidationTests { @ValueSource(strings = {"true", "false", "TRUE", "FALSE"}) void shouldAcceptValidBooleanValues(String value) { var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); - Object originalValue = "true".equalsIgnoreCase(value) ? Boolean.TRUE : Boolean.FALSE; - var violations = service.validatePropertyValue(definition, value, originalValue); + var violations = service.validatePropertyValue(definition, value); assertEquals(List.of(), violations); } @@ -336,7 +314,7 @@ void shouldAcceptValidBooleanValues(String value) { void shouldReportTypeMismatchForInvalidBoolean() { var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); - var violations = service.validatePropertyValue(definition, "yes", "yes"); + var violations = service.validatePropertyValue(definition, "yes"); assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("flag", PropertyType.BOOLEAN)), violations); } @@ -346,7 +324,7 @@ void shouldReportTypeMismatchForInvalidBoolean() { void shouldReportTypeMismatchWhenNumberSentForBoolean() { var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); - var violations = service.validatePropertyValue(definition, "42", 42); + var violations = service.validatePropertyValue(definition, "42"); assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("flag", PropertyType.BOOLEAN)), violations); } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java index 3433491e..f7be973d 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java @@ -25,12 +25,12 @@ import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; -import com.decathlon.idp_core.domain.exception.EntityAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.EntityNotFoundException; -import com.decathlon.idp_core.domain.exception.EntityTemplateAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.EntityTemplateNameAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.EntityTemplateNotFoundException; -import com.decathlon.idp_core.domain.exception.EntityValidationException; +import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNameAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse; import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; From 6f466d606d9f820abdeea80ba4fcf6c95d2b2089 Mon Sep 17 00:00:00 2001 From: renny vandomber Date: Tue, 5 May 2026 16:32:06 +0200 Subject: [PATCH 07/53] feat(core): fix end of file --- .../property/PropertyDefinitionRulesConflictException.java | 2 +- .../entity_template/EntityTemplateValidationService.java | 2 +- .../entity_template/PropertyDefinitionValidationService.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java b/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java index f68a840e..3ce489ed 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java @@ -22,4 +22,4 @@ public PropertyDefinitionRulesConflictException(String propertyName, PropertyTyp super("Property '" + propertyName + "' of type " + propertyType + ": " + violationMessage); } -} \ No newline at end of file +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java index 34aab2f2..980a95ca 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java @@ -115,4 +115,4 @@ public void validatePropertyRules(EntityTemplate entityTemplate) { } } -} \ No newline at end of file +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java index cebf279f..a35f88d2 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java @@ -294,4 +294,4 @@ private void validateRegexPattern(String propertyName, String regexPattern) { } } -} \ No newline at end of file +} From 6db02d48689ffb48d39ab2153a0c5aa65f9487a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Wed, 6 May 2026 11:34:39 +0200 Subject: [PATCH 08/53] feat(core): call entity template service in entity service --- .../domain/port/EntityRepositoryPort.java | 2 +- .../domain/service/entity/EntityService.java | 18 +++---- .../entity/EntityValidationService.java | 5 +- .../persistence/PostgresEntityAdapter.java | 7 +-- .../service/entity/EntityServiceTest.java | 52 +++++++++++-------- .../entity/EntityValidationServiceTest.java | 4 +- 6 files changed, 50 insertions(+), 38 deletions(-) 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 0b2c4b83..7f84d5a2 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 @@ -32,7 +32,7 @@ public interface EntityRepositoryPort { Optional findByTemplateIdentifierAndName(String templateIdentifier, String entityName); - Optional> findByTemplateIdentifier(String templateIdentifier, Pageable pageable); + Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable); List findByIdentifierIn(List identifiers); 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 4fa2da22..3350bdd8 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java @@ -2,7 +2,6 @@ import java.util.List; -import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -10,16 +9,18 @@ import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; -import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; 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.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntitySummary; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; -import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; +import com.decathlon.idp_core.domain.service.EntityTemplateService; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; + import jakarta.transaction.Transactional; import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; /// Domain service orchestrating [Entity] business operations and validations. /// @@ -37,9 +38,9 @@ @RequiredArgsConstructor public class EntityService { private final EntityRepositoryPort entityRepository; - private final EntityTemplateRepositoryPort entityTemplateRepository; private final EntityValidationService entityValidationService; private final EntityTemplateValidationService entityTemplateValidationService; + private final EntityTemplateService entityTemplateService; /// Retrieves entities filtered by template with existence validation. /// @@ -52,9 +53,9 @@ public class EntityService { /// @throws EntityTemplateNotFoundException when template doesn't exist @Transactional public Page getEntitiesByTemplateIdentifier(Pageable pageable, String templateIdentifier) { + entityTemplateValidationService.checkTemplateExists(templateIdentifier); + return entityRepository.findByTemplateIdentifier(templateIdentifier, pageable); - return entityRepository.findByTemplateIdentifier(templateIdentifier, pageable) - .orElseThrow(() -> new EntityTemplateNotFoundException(templateIdentifier)); } /// Provides lightweight entity summaries for efficient bulk operations. @@ -100,9 +101,8 @@ public Entity getEntityByTemplateIdentifierAndIdentifier(String templateIdentifi /// @throws EntityValidationException when entity, property, or relation data is invalid @Transactional public Entity createEntity(@Valid Entity entity) { - EntityTemplate template = entityTemplateRepository.findByIdentifier(entity.templateIdentifier()) - .orElseThrow(() -> new EntityTemplateNotFoundException("identifier", entity.templateIdentifier())); - entityValidationService.checkUniqueness(entity); + EntityTemplate template = entityTemplateService.getEntityTemplateByIdentifier(entity.templateIdentifier()); + entityValidationService.validateUniqueness(entity); entityValidationService.validateEntity(entity, template); return entityRepository.save(entity); } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java index 4902016c..b926db78 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java @@ -7,7 +7,6 @@ import java.util.Optional; import java.util.stream.Collectors; -import lombok.AllArgsConstructor; import org.springframework.stereotype.Service; import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; @@ -19,6 +18,8 @@ import com.decathlon.idp_core.domain.port.EntityRepositoryPort; import com.decathlon.idp_core.domain.service.property.PropertyValidationService; +import lombok.AllArgsConstructor; + /// Domain validator for [Entity] aggregates. /// /// Validation pipeline: @@ -86,7 +87,7 @@ private void validateAgainstTemplate(EntityTemplate template, /// Checks for existing entity with same template and identifier to prevent duplicates. /// @param entity the entity to check for existence /// @throws EntityAlreadyExistsException if an entity with the same template and identifier already exists - void checkUniqueness(final Entity entity) { + void validateUniqueness(final Entity entity) { if (entity.identifier() != null && entityRepository .findByTemplateIdentifierAndIdentifier(entity.templateIdentifier(), entity.identifier()) 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 0319c667..2325b0fe 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 @@ -4,7 +4,6 @@ import java.util.Optional; import java.util.UUID; -import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; @@ -15,6 +14,8 @@ import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.EntityPersistenceMapper; import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.JpaEntityRepository; +import lombok.RequiredArgsConstructor; + @Component @RequiredArgsConstructor public class PostgresEntityAdapter implements EntityRepositoryPort { @@ -45,9 +46,9 @@ public Optional findByTemplateIdentifierAndName(String templateIdentifie } @Override - public Optional> findByTemplateIdentifier(String templateIdentifier, Pageable pageable) { + public Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable) { var pageableEntity = jpaEntityRepository.findByTemplateIdentifier(templateIdentifier, pageable); - return Optional.of(pageableEntity.map(mapper::toDomain)); + return pageableEntity.map(mapper::toDomain); } @Override 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 e5a14d3d..65b99321 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 @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; @@ -31,6 +32,7 @@ import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; +import com.decathlon.idp_core.domain.service.EntityTemplateService; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; @ExtendWith(MockitoExtension.class) @@ -49,6 +51,9 @@ class EntityServiceTest { @Mock private EntityTemplateValidationService entityTemplateValidationService; + @Mock + private EntityTemplateService entityTemplateService; + @InjectMocks private EntityService entityService; @@ -59,7 +64,7 @@ void shouldReturnEntitiesByTemplateIdentifier() { var entity = entity("template-a", "entity-a", "Entity A"); var page = new PageImpl<>(List.of(entity)); - when(entityRepository.findByTemplateIdentifier("template-a", pageable)).thenReturn(Optional.of(page)); + when(entityRepository.findByTemplateIdentifier("template-a", pageable)).thenReturn(page); var result = entityService.getEntitiesByTemplateIdentifier(pageable, "template-a"); @@ -67,15 +72,17 @@ void shouldReturnEntitiesByTemplateIdentifier() { verify(entityRepository).findByTemplateIdentifier("template-a", pageable); } - @Test - @DisplayName("Should throw when template has no entities page") - void shouldThrowWhenTemplatePageNotFound() { - var pageable = Pageable.ofSize(10); - when(entityRepository.findByTemplateIdentifier("missing-template", pageable)).thenReturn(Optional.empty()); + // @Test + // @DisplayName("Should throw when template has no entities page") + // void shouldThrowWhenTemplatePageNotFound() { + // var pageable = Pageable.ofSize(10); + // when(entityRepository.findByTemplateIdentifier("missing-template", + // pageable)).thenReturn(Optional.empty()); - assertThrows(EntityTemplateNotFoundException.class, - () -> entityService.getEntitiesByTemplateIdentifier(pageable, "missing-template")); - } + // assertThrows(EntityTemplateNotFoundException.class, + // () -> entityService.getEntitiesByTemplateIdentifier(pageable, + // "missing-template")); + // } @Test @DisplayName("Should return entity summaries by identifiers") @@ -117,17 +124,18 @@ void shouldThrowWhenEntityNotFoundByTemplateAndIdentifier() { @DisplayName("Should create entity when validations pass") void shouldCreateEntityWhenValidationsPass() { var entity = entity("web-service", "catalog-api", "Catalog API"); - var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", List.of(), List.of()); - when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", List.of(), + List.of()); + when(entityTemplateService.getEntityTemplateByIdentifier("web-service")).thenReturn(template); when(entityRepository.save(entity)).thenReturn(entity); var result = entityService.createEntity(entity); assertSame(entity, result); - InOrder inOrder = inOrder(entityTemplateRepository, entityValidationService, entityRepository); - inOrder.verify(entityTemplateRepository).findByIdentifier("web-service"); - inOrder.verify(entityValidationService).checkUniqueness(entity); + InOrder inOrder = inOrder(entityTemplateService, entityValidationService, entityRepository); + inOrder.verify(entityTemplateService).getEntityTemplateByIdentifier("web-service"); + inOrder.verify(entityValidationService).validateUniqueness(entity); inOrder.verify(entityValidationService).validateEntity(entity, template); inOrder.verify(entityRepository).save(entity); verifyNoInteractions(entityTemplateValidationService); @@ -137,16 +145,17 @@ void shouldCreateEntityWhenValidationsPass() { @DisplayName("Should not save when entity already exists") void shouldNotSaveWhenEntityAlreadyExists() { var entity = entity("web-service", "catalog-api", "Catalog API"); - var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", List.of(), List.of()); + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", List.of(), + List.of()); var alreadyExists = new EntityAlreadyExistsException("web-service", "catalog-api"); - when(entityTemplateRepository.findByIdentifier("web-service")).thenReturn(Optional.of(template)); - org.mockito.Mockito.doThrow(alreadyExists).when(entityValidationService).checkUniqueness(entity); + when(entityTemplateService.getEntityTemplateByIdentifier("web-service")).thenReturn(template); + doThrow(alreadyExists).when(entityValidationService).validateUniqueness(entity); assertThrows(EntityAlreadyExistsException.class, () -> entityService.createEntity(entity)); - verify(entityTemplateRepository).findByIdentifier("web-service"); - verify(entityValidationService).checkUniqueness(entity); + verify(entityTemplateService).getEntityTemplateByIdentifier("web-service"); + verify(entityValidationService).validateUniqueness(entity); verifyNoMoreInteractions(entityRepository); } @@ -155,11 +164,12 @@ void shouldNotSaveWhenEntityAlreadyExists() { void shouldStopWhenTemplateDoesNotExistOnCreate() { var entity = entity("missing-template", "catalog-api", "Catalog API"); - when(entityTemplateRepository.findByIdentifier("missing-template")).thenReturn(Optional.empty()); + when(entityTemplateService.getEntityTemplateByIdentifier("missing-template")) + .thenThrow(new EntityTemplateNotFoundException("identifier", "missing-template")); assertThrows(EntityTemplateNotFoundException.class, () -> entityService.createEntity(entity)); - verify(entityTemplateRepository).findByIdentifier("missing-template"); + verify(entityTemplateService).getEntityTemplateByIdentifier("missing-template"); verifyNoInteractions(entityValidationService); verifyNoMoreInteractions(entityRepository); } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java index 8b719e0b..6b00a96a 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java @@ -53,7 +53,7 @@ void shouldThrowWhenEntityAlreadyExists() { when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) .thenReturn(Optional.of(entity)); - assertThrows(EntityAlreadyExistsException.class, () -> entityValidationService.checkUniqueness(entity)); + assertThrows(EntityAlreadyExistsException.class, () -> entityValidationService.validateUniqueness(entity)); } @Test @@ -61,7 +61,7 @@ void shouldThrowWhenEntityAlreadyExists() { void shouldNotQueryRepositoryWhenIdentifierIsNull() { var entity = entity("web-service", null, "Catalog API", List.of(), List.of()); - assertDoesNotThrow(() -> entityValidationService.checkUniqueness(entity)); + assertDoesNotThrow(() -> entityValidationService.validateUniqueness(entity)); verify(entityRepository, never()).findByTemplateIdentifierAndIdentifier(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); } From a1212e0b2ce74b771474944ef2801a450e8a60d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Mon, 11 May 2026 16:13:36 +0200 Subject: [PATCH 09/53] feat(core): update the validate template methods calls --- .../idp_core/domain/service/entity/EntityService.java | 6 +++--- .../idp_core/domain/service/entity/EntityServiceTest.java | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) 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 3350bdd8..7da8ca28 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 @@ -15,7 +15,7 @@ import com.decathlon.idp_core.domain.model.entity.EntitySummary; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; -import com.decathlon.idp_core.domain.service.EntityTemplateService; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; import jakarta.transaction.Transactional; @@ -53,7 +53,7 @@ public class EntityService { /// @throws EntityTemplateNotFoundException when template doesn't exist @Transactional public Page getEntitiesByTemplateIdentifier(Pageable pageable, String templateIdentifier) { - entityTemplateValidationService.checkTemplateExists(templateIdentifier); + entityTemplateValidationService.validateTemplateExists(templateIdentifier); return entityRepository.findByTemplateIdentifier(templateIdentifier, pageable); } @@ -81,7 +81,7 @@ public List getEntitiesSummariesByIndentifiers(List ident /// @throws EntityNotFoundException when entity doesn't exist @Transactional public Entity getEntityByTemplateIdentifierAndIdentifier(String templateIdentifier, String entityIdentifier) { - entityTemplateValidationService.checkTemplateExists(templateIdentifier); + entityTemplateValidationService.validateTemplateExists(templateIdentifier); return entityRepository.findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); 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 65b99321..6699c567 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 @@ -32,7 +32,7 @@ import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; -import com.decathlon.idp_core.domain.service.EntityTemplateService; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; @ExtendWith(MockitoExtension.class) @@ -106,7 +106,7 @@ void shouldReturnEntityByTemplateAndIdentifier() { var result = entityService.getEntityByTemplateIdentifierAndIdentifier("web-service", "catalog-api"); assertSame(entity, result); - verify(entityTemplateValidationService).checkTemplateExists("web-service"); + verify(entityTemplateValidationService).validateTemplateExists("web-service"); verify(entityRepository).findByTemplateIdentifierAndIdentifier("web-service", "catalog-api"); } From 30f86d601c69a17844519c029e013c40caf747d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Wed, 13 May 2026 18:01:14 +0200 Subject: [PATCH 10/53] feat(core): update the validate template methods calls --- ...sql => V3_4__change_entity_identifier_unique_to_composite.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/main/resources/db/migration/{V3_3__change_entity_identifier_unique_to_composite.sql => V3_4__change_entity_identifier_unique_to_composite.sql} (100%) diff --git a/src/main/resources/db/migration/V3_3__change_entity_identifier_unique_to_composite.sql b/src/main/resources/db/migration/V3_4__change_entity_identifier_unique_to_composite.sql similarity index 100% rename from src/main/resources/db/migration/V3_3__change_entity_identifier_unique_to_composite.sql rename to src/main/resources/db/migration/V3_4__change_entity_identifier_unique_to_composite.sql From 5f12fec7209dd3b7caa752c119e4c9816d1b5882 Mon Sep 17 00:00:00 2001 From: renny vandomber Date: Mon, 18 May 2026 11:45:49 +0200 Subject: [PATCH 11/53] feat(core): fix review --- .../domain/model/entity/Property.java | 2 +- .../domain/service/entity/EntityService.java | 3 +- .../entity/EntityValidationService.java | 22 ++++++------- .../adapters/api/dto/in/EntityDtoIn.java | 2 +- .../api/mapper/entity/EntityDtoInMapper.java | 2 +- .../mapper/EntityPersistenceMapper.java | 17 ---------- .../service/entity/EntityServiceTest.java | 22 ++----------- .../entity/EntityValidationServiceTest.java | 32 ++++++++++++++----- 8 files changed, 41 insertions(+), 61 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java index 78501247..5e7281ed 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java @@ -29,6 +29,6 @@ public record Property( @NotBlank(message = PROPERTY_NAME_MANDATORY) String name, - Object value + String value ) { } 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 7da8ca28..72e40ad1 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 @@ -102,8 +102,7 @@ public Entity getEntityByTemplateIdentifierAndIdentifier(String templateIdentifi @Transactional public Entity createEntity(@Valid Entity entity) { EntityTemplate template = entityTemplateService.getEntityTemplateByIdentifier(entity.templateIdentifier()); - entityValidationService.validateUniqueness(entity); - entityValidationService.validateEntity(entity, template); + entityValidationService.validateForCreation(entity, template); return entityRepository.save(entity); } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java index b926db78..8143e6c3 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java @@ -44,22 +44,17 @@ public class EntityValidationService { /// @param template the already-resolved template the entity must conform to /// @throws EntityValidationException when one or more validation rules are violated /// @throws EntityAlreadyExistsException if an entity with the same identifier exists for the template - void validateEntity(Entity entity, EntityTemplate template) { - Violations violations = new Violations(); - validateAgainstTemplate(template, entity.properties(), violations); - - if (!violations.isEmpty()) { - throw new EntityValidationException(violations.asList()); - } + void validateForCreation(Entity entity, EntityTemplate template) { + validateUniqueness(entity); + validateAgainstTemplate(template, entity.properties()); } /// Validates entity properties against the template's property definitions, enforcing required fields and value rules. /// @param template the entity template whose property definitions are used for validation /// @param properties the list of properties from the entity to validate - /// @param violations the accumulator for validation v iolation messages private void validateAgainstTemplate(EntityTemplate template, - List properties, - Violations violations) { + List properties) { + Violations violations = new Violations(); List definitions = Optional.ofNullable(template.propertiesDefinitions()).orElse(List.of()); Map propertiesByName = Optional.ofNullable(properties).orElse(List.of()).stream() .filter(p -> p.name() != null) @@ -69,7 +64,7 @@ private void validateAgainstTemplate(EntityTemplate template, Property property = propertiesByName.get(definition.name()); boolean missing = property == null || property.value() == null - || (property.value() instanceof String s && s.isBlank()); + || (property.value().isBlank()); if (missing) { if (definition.required()) { @@ -82,12 +77,15 @@ private void validateAgainstTemplate(EntityTemplate template, .validatePropertyValue(definition, property.value()) .forEach(violations::add); } + if (!violations.isEmpty()) { + throw new EntityValidationException(violations.asList()); + } } /// Checks for existing entity with same template and identifier to prevent duplicates. /// @param entity the entity to check for existence /// @throws EntityAlreadyExistsException if an entity with the same template and identifier already exists - void validateUniqueness(final Entity entity) { + private void validateUniqueness(final Entity entity) { if (entity.identifier() != null && entityRepository .findByTemplateIdentifierAndIdentifier(entity.templateIdentifier(), entity.identifier()) diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoIn.java index 0531655a..75877117 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoIn.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoIn.java @@ -50,7 +50,7 @@ public class EntityDtoIn { private String identifier; @Schema(description = FIELD_ENTITY_PROPERTIES, example = "{\"port\": \"8080\", \"environment\": \"dev\"}") - private Map properties; + private Map properties; @Valid @Schema(description = FIELD_ENTITY_RELATIONS) diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java index 45dc45af..5548ec05 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java @@ -40,7 +40,7 @@ public Entity fromEntityDtoInToEntity(EntityDtoIn entityDtoIn, String entityTemp List properties = entityDtoIn.getProperties() == null ? Collections.emptyList() : entityDtoIn.getProperties().entrySet().stream() - .map((Map.Entry entry) -> new Property( + .map((Map.Entry entry) -> new Property( null, entry.getKey(), entry.getValue() diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java index 9117cc46..cc7edac1 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java @@ -19,28 +19,11 @@ public interface EntityPersistenceMapper { EntityJpaEntity toJpa(Entity domain); - @Mapping(target = "value", source = "value", qualifiedByName = "propertyValueFromString") Property toDomain(PropertyJpaEntity jpa); - @Mapping(target = "value", source = "value", qualifiedByName = "propertyValueToString") PropertyJpaEntity toJpa(Property domain); Relation toDomain(RelationJpaEntity jpa); RelationJpaEntity toJpa(Relation domain); - - /// Converts a domain property value (carried as [Object] to preserve the - /// original JSON type) into its canonical String representation for storage. - @Named("propertyValueToString") - default String propertyValueToString(Object value) { - return value == null ? null : String.valueOf(value); - } - - /// Promotes a persisted String value to the domain [Object] representation. - /// Persistence is the source of truth for textual storage; richer typing - /// (Number/Boolean) is reconstructed by the API output mapper using the template. - @Named("propertyValueFromString") - default Object propertyValueFromString(String value) { - return value; - } } 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 6699c567..22b4cb9c 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 @@ -31,7 +31,6 @@ import com.decathlon.idp_core.domain.model.entity.EntitySummary; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; -import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; @@ -42,8 +41,6 @@ class EntityServiceTest { @Mock private EntityRepositoryPort entityRepository; - @Mock - private EntityTemplateRepositoryPort entityTemplateRepository; @Mock private EntityValidationService entityValidationService; @@ -72,18 +69,6 @@ void shouldReturnEntitiesByTemplateIdentifier() { verify(entityRepository).findByTemplateIdentifier("template-a", pageable); } - // @Test - // @DisplayName("Should throw when template has no entities page") - // void shouldThrowWhenTemplatePageNotFound() { - // var pageable = Pageable.ofSize(10); - // when(entityRepository.findByTemplateIdentifier("missing-template", - // pageable)).thenReturn(Optional.empty()); - - // assertThrows(EntityTemplateNotFoundException.class, - // () -> entityService.getEntitiesByTemplateIdentifier(pageable, - // "missing-template")); - // } - @Test @DisplayName("Should return entity summaries by identifiers") void shouldReturnEntitySummariesByIdentifiers() { @@ -135,8 +120,7 @@ void shouldCreateEntityWhenValidationsPass() { InOrder inOrder = inOrder(entityTemplateService, entityValidationService, entityRepository); inOrder.verify(entityTemplateService).getEntityTemplateByIdentifier("web-service"); - inOrder.verify(entityValidationService).validateUniqueness(entity); - inOrder.verify(entityValidationService).validateEntity(entity, template); + inOrder.verify(entityValidationService).validateForCreation(entity, template); inOrder.verify(entityRepository).save(entity); verifyNoInteractions(entityTemplateValidationService); } @@ -150,12 +134,12 @@ void shouldNotSaveWhenEntityAlreadyExists() { var alreadyExists = new EntityAlreadyExistsException("web-service", "catalog-api"); when(entityTemplateService.getEntityTemplateByIdentifier("web-service")).thenReturn(template); - doThrow(alreadyExists).when(entityValidationService).validateUniqueness(entity); + doThrow(alreadyExists).when(entityValidationService).validateForCreation(entity, template); assertThrows(EntityAlreadyExistsException.class, () -> entityService.createEntity(entity)); verify(entityTemplateService).getEntityTemplateByIdentifier("web-service"); - verify(entityValidationService).validateUniqueness(entity); + verify(entityValidationService).validateForCreation(entity, template); verifyNoMoreInteractions(entityRepository); } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java index 6b00a96a..6411bd68 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java @@ -9,6 +9,7 @@ import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; +import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -49,19 +50,34 @@ class EntityValidationServiceTest { @Test @DisplayName("Should throw when entity with same identifier already exists") void shouldThrowWhenEntityAlreadyExists() { + var template = new EntityTemplate( + UUID.randomUUID(), + "web-service", + "Web Service", + "desc", + Collections.emptyList(), + List.of()); var entity = entity("web-service", "catalog-api", "Catalog API", List.of(), List.of()); when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) .thenReturn(Optional.of(entity)); - assertThrows(EntityAlreadyExistsException.class, () -> entityValidationService.validateUniqueness(entity)); + assertThrows(EntityAlreadyExistsException.class, () -> entityValidationService.validateForCreation(entity, template)); } @Test @DisplayName("Should not query repository when identifier is null") void shouldNotQueryRepositoryWhenIdentifierIsNull() { + var template = new EntityTemplate( + UUID.randomUUID(), + "web-service", + "Web Service", + "desc", + Collections.emptyList(), + List.of()); + var entity = entity("web-service", null, "Catalog API", List.of(), List.of()); - assertDoesNotThrow(() -> entityValidationService.validateUniqueness(entity)); + assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); verify(entityRepository, never()).findByTemplateIdentifierAndIdentifier(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); } @@ -69,7 +85,7 @@ void shouldNotQueryRepositoryWhenIdentifierIsNull() { @Test @DisplayName("Should aggregate property requirements and rule violations") - void shouldAggregateAllViolationsDuringValidateEntity() { + void shouldAggregateAllViolationsDuringValidateForCreation() { var portDefinition = new PropertyDefinition( UUID.randomUUID(), "port", @@ -104,7 +120,7 @@ void shouldAggregateAllViolationsDuringValidateEntity() { when(propertyValidationService.validatePropertyValue(portDefinition, "80")) .thenReturn(List.of("Property 'port' value must be greater than or equal to 1024")); - var exception = assertThrows(EntityValidationException.class, () -> entityValidationService.validateEntity(entity, template)); + var exception = assertThrows(EntityValidationException.class, () -> entityValidationService.validateForCreation(entity, template)); // Expecting exactly 2 errors: the missing required property, and the invalid port value. assertEquals(2, exception.getViolations().size()); @@ -116,7 +132,7 @@ void shouldAggregateAllViolationsDuringValidateEntity() { @Test @DisplayName("Should validate entity successfully when no violations") - void shouldValidateEntitySuccessfullyWhenNoViolations() { + void shouldValidateForCreationSuccessfullyWhenNoViolations() { var versionDefinition = new PropertyDefinition( UUID.randomUUID(), "version", @@ -143,7 +159,7 @@ void shouldValidateEntitySuccessfullyWhenNoViolations() { when(propertyValidationService.validatePropertyValue(versionDefinition, "1.0.0")).thenReturn(List.of()); - assertDoesNotThrow(() -> entityValidationService.validateEntity(entity, template)); + assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); verify(propertyValidationService).validatePropertyValue(versionDefinition, "1.0.0"); } @@ -168,7 +184,7 @@ void shouldSkipPropertyRuleValidationWhenOptionalPropertyMissing() { var entity = entity("web-service", "catalog-api", "Catalog API", List.of(), List.of()); - assertDoesNotThrow(() -> entityValidationService.validateEntity(entity, template)); + assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); verifyNoInteractions(propertyValidationService); } @@ -200,7 +216,7 @@ void shouldValidateStringPropertyWithNumericStringValue() { null); when(propertyValidationService.validatePropertyValue(stringDefinition, "1234")).thenReturn(List.of()); - assertDoesNotThrow(() -> entityValidationService.validateEntity(entity, template)); + assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); verify(propertyValidationService).validatePropertyValue(stringDefinition, "1234"); } From ecc92b2202fe6a2b6d2c0b1f4e4742b30434309e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Tue, 19 May 2026 11:41:01 +0200 Subject: [PATCH 12/53] feat(core): add a entity graph service and endpoint --- .../idp_core/domain/model/entity/Entity.java | 2 - .../model/entity/EntityCompositeKey.java | 36 +++++ .../model/entity_graph/EntityGraphNode.java | 26 ++++ .../entity_graph/EntityGraphRelation.java | 21 +++ .../port/EntityGraphRepositoryPort.java | 37 +++++ .../entity_graph/EntityGraphService.java | 144 ++++++++++++++++++ .../api/configuration/SwaggerDescription.java | 13 ++ .../api/controller/EntityGraphController.java | 78 ++++++++++ .../dto/out/entity/EntityGraphNodeDtoOut.java | 29 ++++ .../out/entity/EntityGraphRelationDtoOut.java | 26 ++++ .../entity/RelationAsTargetSummaryDtoOut.java | 13 ++ .../entity/EntityGraphDtoOutMapper.java | 50 ++++++ .../PostgresEntityGraphAdapter.java | 69 +++++++++ .../repository/JpaEntityRepository.java | 60 ++++++++ .../repository/JpaRelationRepository.java | 4 +- 15 files changed, 605 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/decathlon/idp_core/domain/model/entity/EntityCompositeKey.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphRelation.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/RelationAsTargetSummaryDtoOut.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java index 2b772413..ab10abee 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java @@ -7,8 +7,6 @@ import java.util.List; import java.util.UUID; -import org.springframework.validation.annotation.Validated; - import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityCompositeKey.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityCompositeKey.java new file mode 100644 index 00000000..30a0f994 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityCompositeKey.java @@ -0,0 +1,36 @@ +package com.decathlon.idp_core.domain.model.entity; + +import java.util.Objects; + +/** + * Composite key for uniquely identifying an entity across templates. + * Since the same identifier can exist in different templates, we need both fields. + */ +public record EntityCompositeKey(String templateIdentifier, String identifier) { + public static EntityCompositeKey fromString(String compositeKey) { + String[] parts = compositeKey.split(":", 2); + if (parts.length != 2) { + throw new IllegalArgumentException("Invalid composite key format: " + compositeKey); + } + return new EntityCompositeKey(parts[0], parts[1]); + } + + @Override + public String toString() { + return templateIdentifier + ":" + identifier; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + EntityCompositeKey that = (EntityCompositeKey) o; + return Objects.equals(templateIdentifier, that.templateIdentifier) && + Objects.equals(identifier, that.identifier); + } + + @Override + public int hashCode() { + return Objects.hash(templateIdentifier, identifier); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java new file mode 100644 index 00000000..9348a38a --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java @@ -0,0 +1,26 @@ +package com.decathlon.idp_core.domain.model.entity_graph; + +import java.util.List; + +/// A node in the entity relationship graph, containing summary information +/// and its resolved relations (recursively up to a configurable depth). +/// +/// **Business purpose:** +/// - Visualizing entity dependency graphs +/// - Understanding relationship chains between entities +/// - Providing a hierarchical view of entity connections +/// +/// @param summary the lightweight entity identification data +/// @param relations the resolved outbound relations with their target graph nodes +/// @param relationsAsTarget incoming relations where this entity is the target +public record EntityGraphNode( + String identifier, + String name, + List relations, + List relationsAsTarget +) { + public EntityGraphNode { + relations = relations != null ? List.copyOf(relations) : List.of(); + relationsAsTarget = relationsAsTarget != null ? List.copyOf(relationsAsTarget) : List.of(); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphRelation.java b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphRelation.java new file mode 100644 index 00000000..d770639d --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphRelation.java @@ -0,0 +1,21 @@ +package com.decathlon.idp_core.domain.model.entity_graph; + +import java.util.List; + +/// Represents a single named relation in the entity graph with its resolved target nodes. +/// +/// **Business purpose:** +/// - Groups related entities under their relation name +/// - Enables graph traversal by relation type +/// +/// @param name the relation name as defined in the entity template +/// @param targetTemplateIdentifier the template identifier of the target entities +/// @param targets the resolved target entity graph nodes (recursively populated up to depth) +public record EntityGraphRelation( + String name, + List targets +) { + public EntityGraphRelation { + targets = targets != null ? List.copyOf(targets) : List.of(); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java new file mode 100644 index 00000000..e1ae73f9 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java @@ -0,0 +1,37 @@ +package com.decathlon.idp_core.domain.port; + +import java.util.Map; + +import com.decathlon.idp_core.domain.model.entity.Entity; +import com.decathlon.idp_core.domain.model.entity.EntityCompositeKey; + +/// Driven port defining the contract for entity relationship graph retrieval. +/// +/// Separated from [EntityRepositoryPort] to follow the Interface Segregation Principle: +/// graph traversal is a distinct read concern backed by recursive CTE queries, +/// with no overlap with standard CRUD operations. +/// +/// **Contract expectations for implementations:** +/// - Must traverse both outbound and inbound relations up to the requested depth +/// - Must return the root entity itself as part of the result map +/// - Must return an empty map when the root entity does not exist +/// - Depth must be clamped server-side; implementations may ignore values outside a valid range +/// +/// **Transaction behavior:** Implementations should use a read-only transaction +/// as this port performs no write operations. +public interface EntityGraphRepositoryPort { + + /// Fetches all entities in the relationship graph rooted at the given composite key. + /// + /// Uses a recursive CTE to traverse both outbound and inbound relations up to the + /// specified depth, then batch-loads all entities in a minimal number of queries. + /// + /// @param templateIdentifier the template identifier of the root entity + /// @param entityIdentifier the business identifier of the root entity within its template + /// @param depth the maximum traversal depth (1-10) + /// @return map of [EntityCompositeKey] to [Entity] for O(1) lookup; empty if root not found + Map findEntityGraph( + String templateIdentifier, + String entityIdentifier, + int depth); +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java new file mode 100644 index 00000000..46ca3f88 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java @@ -0,0 +1,144 @@ +package com.decathlon.idp_core.domain.service.entity_graph; + +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; +import com.decathlon.idp_core.domain.model.entity.Entity; +import com.decathlon.idp_core.domain.model.entity.EntityCompositeKey; +import com.decathlon.idp_core.domain.model.entity.Relation; +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphRelation; +import com.decathlon.idp_core.domain.port.EntityGraphRepositoryPort; +import com.decathlon.idp_core.domain.port.EntityRepositoryPort; + +import lombok.RequiredArgsConstructor; + +/// Domain service for building entity relationship graphs. +/// +/// Resolves an entity's outbound and inbound relations recursively up to a configurable depth, +/// returning a tree of [EntityGraphNode] records containing summary information +/// for each connected entity. +/// +/// **Business purpose:** +/// - Visualizing entity dependency graphs in the catalog UI +/// - Understanding relationship chains (e.g., service → database → infrastructure) +/// - Providing hierarchical views for impact analysis and change propagation +/// +/// **Design decisions:** +/// - Uses depth-limited traversal to prevent unbounded recursion +/// - Optimized with recursive CTE and batch loading to minimize database queries +/// - Does not detect cycles — relies on depth limit to terminate +@Service +@RequiredArgsConstructor +public class EntityGraphService { + + private static final int MAX_DEPTH = 10; + + private final EntityRepositoryPort entityRepositoryPort; + private final EntityGraphRepositoryPort entityGraphRepositoryPort; + + /// Builds the relationship graph for an entity starting from its composite key. + /// + /// **Optimization:** Uses a recursive CTE to fetch all entities in the graph in 2 queries + /// (1 for composite key pairs, 1 for batch loading), regardless of depth. + /// + /// @param templateIdentifier the template identifier of the root entity + /// @param entityIdentifier the business identifier of the root entity + /// @param depth the maximum traversal depth (clamped to [1, MAX_DEPTH]) + /// @return the root graph node with resolved relations + /// @throws EntityNotFoundException when no entity matches the given identifiers + @Transactional(readOnly = true) + public EntityGraphNode getEntityGraph(String templateIdentifier, String entityIdentifier, int depth) { + int effectiveDepth = Math.clamp(depth, 1, MAX_DEPTH); + + // Verify root entity exists before fetching the graph + Entity rootEntity = entityRepositoryPort + .findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) + .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); + + // Optimized batch fetch: load all entities in the graph keyed by composite key + Map entityMap = entityGraphRepositoryPort + .findEntityGraph(templateIdentifier, entityIdentifier, effectiveDepth); + + EntityCompositeKey rootKey = new EntityCompositeKey(rootEntity.templateIdentifier(), rootEntity.identifier()); + + // Build the graph from pre-loaded entities (no more database calls) + return buildGraphNode(rootKey, entityMap, effectiveDepth); + } + + /// Builds a graph node from a pre-loaded entity map (no database calls). + /// Recursively resolves both outbound and inbound relations from the cached entities. + private EntityGraphNode buildGraphNode(EntityCompositeKey key, + Map entityMap, + int remainingDepth) { + Entity entity = entityMap.get(key); + if (entity == null) { + return new EntityGraphNode(key.identifier(), key.identifier(), List.of(), List.of()); + } + + if (remainingDepth <= 0) { + return new EntityGraphNode(entity.identifier(), entity.name(), List.of(), List.of()); + } + + // Resolve outbound relations from pre-loaded entities + List outboundRelations = entity.relations().stream() + .map(relation -> new EntityGraphRelation( + relation.name(), + relation.targetEntityIdentifiers().stream() + .map(targetId -> { + // Relations only store identifier; look up by identifier across all entries + EntityCompositeKey targetKey = findKeyByIdentifier(targetId, entityMap); + return buildGraphNode(targetKey, entityMap, remainingDepth - 1); + }) + .toList() + )) + .toList(); + + // Resolve inbound relations from pre-loaded entities + List inboundRelations = buildRelationsAsTargetFromMap( + entity.identifier(), entityMap, remainingDepth - 1); + + return new EntityGraphNode(entity.identifier(), entity.name(), outboundRelations, inboundRelations); + } + + /// Looks up a composite key from the map by identifier alone. + /// Falls back to a synthetic key if no match is found (entity not in graph). + private EntityCompositeKey findKeyByIdentifier(String identifier, Map entityMap) { + return entityMap.keySet().stream() + .filter(k -> k.identifier().equals(identifier)) + .findFirst() + .orElse(new EntityCompositeKey("", identifier)); + } + + /// Builds incoming relations (where this entity is the target) from the pre-loaded entity map. + /// Scans all entities to find relations pointing to this entity. + private List buildRelationsAsTargetFromMap(String targetIdentifier, + Map entityMap, + int remainingDepth) { + Map> sourcesByRelationName = new java.util.HashMap<>(); + + for (Map.Entry entry : entityMap.entrySet()) { + Entity sourceEntity = entry.getValue(); + for (Relation relation : sourceEntity.relations()) { + if (relation.targetEntityIdentifiers().contains(targetIdentifier)) { + sourcesByRelationName + .computeIfAbsent(relation.name(), k -> new java.util.ArrayList<>()) + .add(entry.getKey()); + } + } + } + + return sourcesByRelationName.entrySet().stream() + .map(e -> new EntityGraphRelation( + e.getKey(), + e.getValue().stream() + .map(sourceKey -> buildGraphNode(sourceKey, entityMap, remainingDepth)) + .toList() + )) + .toList(); + } +} 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 d6457250..b7f85ec8 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 @@ -148,4 +148,17 @@ public class SwaggerDescription { public static final String PARAM_PAGE_DESCRIPTION = "Page number for pagination. Defaults to 0."; public static final String PARAM_SIZE_DESCRIPTION = "Number of items per page. Defaults to 20."; public static final String PARAM_SORT_DESCRIPTION = "Sorting criteria in the format: property(,asc|desc). Defaults to identifier,asc."; + + // --- Entity Graph descriptions --- + public static final String ENDPOINT_GET_ENTITY_GRAPH_SUMMARY = "Get entity relationship graph"; + public static final String ENDPOINT_GET_ENTITY_GRAPH_DESCRIPTION = "Retrieves the entity relationship graph starting from the specified entity, resolving outbound relations recursively up to the requested depth."; + public static final String RESPONSE_ENTITY_GRAPH_SUCCESS = "Entity graph successfully retrieved"; + public static final String PARAM_DEPTH_DESCRIPTION = "Maximum traversal depth for relationship resolution. Clamped between 1 and 10."; + public static final String ENTITY_GRAPH_NODE_DESCRIPTION = "A node in the entity relationship graph"; + public static final String ENTITY_GRAPH_SUMMARY_DESCRIPTION = "Summary information identifying the entity"; + public static final String ENTITY_GRAPH_RELATIONS_DESCRIPTION = "Resolved outbound relations with target entity nodes"; + public static final String ENTITY_GRAPH_RELATION_NAME_DESCRIPTION = "The relation name as defined in the entity template"; + public static final String ENTITY_GRAPH_RELATION_TARGET_TEMPLATE_DESCRIPTION = "The template identifier of target entities"; + public static final String ENTITY_GRAPH_RELATION_TARGETS_DESCRIPTION = "Resolved target entity graph nodes"; + public static final String ENTITY_GRAPH_RELATIONS_AS_TARGET_DESCRIPTION = "Incoming relations where this entity is the target"; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java new file mode 100644 index 00000000..727b963b --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java @@ -0,0 +1,78 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.controller; + +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITY_GRAPH_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITY_GRAPH_SUMMARY; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.NOT_FOUND_CODE; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.OK_CODE; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_DEPTH_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_GRAPH_SUCCESS; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER; +import static org.springframework.http.HttpStatus.OK; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; +import com.decathlon.idp_core.domain.service.entity_graph.EntityGraphService; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityGraphNodeDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse; +import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.EntityGraphDtoOutMapper; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; + +/// REST controller for entity relationship graph operations. +/// +/// Provides endpoints to retrieve hierarchical relationship graphs starting from +/// a specified entity, enabling visualization of entity dependencies and connections. +@RestController +@RequestMapping("/api/v1/entities") +@RequiredArgsConstructor +@Tag(name = "Entity Graph", description = "Entity relationship graph operations") +public class EntityGraphController { + + private final EntityGraphService entityGraphService; + + /// Retrieves the entity relationship graph starting from the specified entity. + /// + /// Resolves outbound relations recursively up to the requested depth, + /// returning a tree structure with entity summary information at each node. + /// + /// @param templateIdentifier the template identifier of the root entity + /// @param entityIdentifier the business identifier of the root entity + /// @param depth the maximum traversal depth (default 1, clamped between 1 and 10) + /// @return the root graph node with resolved relations + @GetMapping("/{templateIdentifier}/{entityIdentifier}/graph") + @ResponseStatus(OK) + @Operation( + summary = ENDPOINT_GET_ENTITY_GRAPH_SUMMARY, + description = ENDPOINT_GET_ENTITY_GRAPH_DESCRIPTION, + responses = { + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_GRAPH_SUCCESS, + content = @Content(schema = @Schema(implementation = EntityGraphNodeDtoOut.class))), + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + } + ) + public EntityGraphNodeDtoOut getEntityGraph( + @PathVariable @NotBlank String templateIdentifier, + @PathVariable @NotBlank String entityIdentifier, + @Parameter(description = PARAM_DEPTH_DESCRIPTION) + @RequestParam(defaultValue = "1") int depth) { + + EntityGraphNode graphNode = entityGraphService.getEntityGraph( + templateIdentifier, entityIdentifier, depth); + + return EntityGraphDtoOutMapper.toDto(graphNode); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java new file mode 100644 index 00000000..5d159bf9 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java @@ -0,0 +1,29 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity; + +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATIONS_AS_TARGET_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATIONS_DESCRIPTION; + +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; + +/// Output DTO representing a node in the entity relationship graph. +/// +/// Contains summary information about the entity and its resolved outbound relations +/// grouped by relation name, and incoming relations where this entity is the target. +@JsonNaming(SnakeCaseStrategy.class) +public record EntityGraphNodeDtoOut( + + String identifier, + String name, + + @Schema(description = ENTITY_GRAPH_RELATIONS_DESCRIPTION) + Map> relations, + + @Schema(description = ENTITY_GRAPH_RELATIONS_AS_TARGET_DESCRIPTION) + Map> relationsAsTarget +) {} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java new file mode 100644 index 00000000..55f0794e --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java @@ -0,0 +1,26 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity; + +import java.util.List; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; + +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATION_NAME_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATION_TARGET_TEMPLATE_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATION_TARGETS_DESCRIPTION; + +/// Output DTO representing a single named relation in the entity graph. +@JsonNaming(SnakeCaseStrategy.class) +public record EntityGraphRelationDtoOut( + + @Schema(description = ENTITY_GRAPH_RELATION_NAME_DESCRIPTION) + String name, + + @Schema(description = ENTITY_GRAPH_RELATION_TARGET_TEMPLATE_DESCRIPTION) + String targetTemplateIdentifier, + + @Schema(description = ENTITY_GRAPH_RELATION_TARGETS_DESCRIPTION) + List targets +) {} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/RelationAsTargetSummaryDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/RelationAsTargetSummaryDtoOut.java new file mode 100644 index 00000000..1e734bd1 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/RelationAsTargetSummaryDtoOut.java @@ -0,0 +1,13 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +/// Output DTO representing an incoming relationship where the entity is the target. +@JsonNaming(SnakeCaseStrategy.class) +public record RelationAsTargetSummaryDtoOut( + String targetEntityIdentifier, + String relationName, + String sourceEntityIdentifier, + String sourceEntityName +) {} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java new file mode 100644 index 00000000..8b3e6be3 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java @@ -0,0 +1,50 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphRelation; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityGraphNodeDtoOut; + +/// Mapper for converting domain [EntityGraphNode] to its API output DTO +/// representation. +/// +/// Uses Record Patterns for recursive tree mapping since MapStruct does not +/// handle recursive structures cleanly. +public final class EntityGraphDtoOutMapper { + + private EntityGraphDtoOutMapper() { + // Utility class + } + + /// Maps a domain graph node to its DTO representation. + /// + /// @param node the domain graph node + /// @return the output DTO + public static EntityGraphNodeDtoOut toDto(EntityGraphNode node) { + if (node == null) { + return null; + } + return new EntityGraphNodeDtoOut( + node.identifier(), node.name(), + mapRelations(node.relations()), + mapRelations(node.relationsAsTarget())); + } + + private static Map> mapRelations(List relations) { + if (relations == null || relations.isEmpty()) { + return Map.of(); + } + return relations.stream() + .collect(Collectors.toMap( + EntityGraphRelation::name, + relation -> relation.targets().stream() + .map(EntityGraphDtoOutMapper::toDto) + .toList(), + (existing, replacement) -> existing, + LinkedHashMap::new)); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java new file mode 100644 index 00000000..8527027f --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java @@ -0,0 +1,69 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Component; + +import com.decathlon.idp_core.domain.model.entity.Entity; +import com.decathlon.idp_core.domain.model.entity.EntityCompositeKey; +import com.decathlon.idp_core.domain.port.EntityGraphRepositoryPort; +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 lombok.RequiredArgsConstructor; + +/// Persistence adapter dedicated to entity relationship graph traversal. +/// +/// Separated from [PostgresEntityAdapter] because graph queries use a distinct +/// recursive CTE strategy that has no overlap with standard CRUD operations, +/// following the Interface Segregation Principle. +/// +/// **Query strategy:** +/// 1. One recursive CTE query to collect all (identifier, template_identifier) pairs in the graph. +/// 2. One batch query to load entities with their relations (avoids N+1). +/// 3. One batch query to load properties separately (avoids MultipleBagFetchException). +@Component +@RequiredArgsConstructor +public class PostgresEntityGraphAdapter implements EntityGraphRepositoryPort { + + private final JpaEntityRepository jpaEntityRepository; + private final EntityPersistenceMapper mapper; + + @Override + public Map findEntityGraph( + String templateIdentifier, + String entityIdentifier, + int depth) { + // Step 1: collect all (identifier, template_identifier) pairs via recursive CTE + List graphPairs = jpaEntityRepository.findEntityGraphIdentifiers( + templateIdentifier, entityIdentifier, depth); + + if (graphPairs.isEmpty()) { + return Map.of(); + } + + // Step 2: extract unique identifiers for batch loading + List identifiers = graphPairs.stream() + .map(pair -> (String) pair[0]) + .distinct() + .toList(); + + // Step 3: batch-load entities with relations, then properties in separate queries + // to avoid Hibernate's MultipleBagFetchException + List jpaEntities = + jpaEntityRepository.findAllByIdentifierInWithRelations(identifiers); + jpaEntityRepository.findAllByIdentifierInWithProperties(identifiers); + + // Step 4: map to domain and key by composite key for O(1) lookup + return jpaEntities.stream() + .map(mapper::toDomain) + .collect(Collectors.toMap( + e -> new EntityCompositeKey(e.templateIdentifier(), e.identifier()), + Function.identity() + )); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java index 97675e91..fb94167f 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java @@ -27,6 +27,8 @@ public interface JpaEntityRepository extends JpaRepository findByTemplateIdentifierAndIdentifier(String templateIdentifier, String identifier); + Optional findByIdentifier(String identifier); + Optional findByTemplateIdentifierAndName(String templateIdentifier, String name); Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable); @@ -56,4 +58,62 @@ WHERE r IN ( void deleteRelationsByTemplateIdentifierAndRelationName( @Param("templateIdentifier") String templateIdentifier, @Param("relationNames") Collection relationNames); + + /// Batch fetch entities by identifiers with eager loading of relations and properties. + /// Uses two separate queries to avoid Hibernate's MultipleBagFetchException. + /// First fetches entities with relations, then fetches properties separately. + @Query("SELECT DISTINCT e FROM EntityJpaEntity e LEFT JOIN FETCH e.relations WHERE e.identifier IN :identifiers") + List findAllByIdentifierInWithRelations(@Param("identifiers") Collection identifiers); + + /// Fetch properties for entities that were already loaded. + /// This is called after findAllByIdentifierInWithRelations to complete the entity graph. + @Query("SELECT DISTINCT e FROM EntityJpaEntity e LEFT JOIN FETCH e.properties WHERE e.identifier IN :identifiers") + List findAllByIdentifierInWithProperties(@Param("identifiers") Collection identifiers); + + @Query(value = """ + WITH RECURSIVE + -- Traverse outbound relations (this entity -> targets) + outbound_graph(identifier, template_identifier, depth) AS ( + SELECT e.identifier, e.template_identifier, 0 + FROM entity e + WHERE e.identifier = :entityIdentifier + AND e.template_identifier = :templateIdentifier + + UNION ALL + + SELECT e2.identifier, e2.template_identifier, og.depth + 1 + FROM outbound_graph og + JOIN entity e ON e.identifier = og.identifier AND e.template_identifier = og.template_identifier + JOIN entity_relations er ON er.entity_id = e.id + JOIN relation r ON r.id = er.relation_id + JOIN relation_target_entities rte ON rte.relation_id = r.id + JOIN entity e2 ON e2.identifier = rte.target_entity_identifier + WHERE og.depth < :depth + ), + -- Traverse inbound relations (sources -> this entity as target) + inbound_graph(identifier, template_identifier, depth) AS ( + SELECT e.identifier, e.template_identifier, 0 + FROM entity e + WHERE e.identifier = :entityIdentifier + AND e.template_identifier = :templateIdentifier + + UNION ALL + + SELECT e2.identifier, e2.template_identifier, ig.depth + 1 + FROM inbound_graph ig + JOIN entity e ON e.identifier = ig.identifier AND e.template_identifier = ig.template_identifier + JOIN relation_target_entities rte ON rte.target_entity_identifier = e.identifier + JOIN relation r ON r.id = rte.relation_id + JOIN entity_relations er ON er.relation_id = r.id + JOIN entity e2 ON e2.id = er.entity_id + WHERE ig.depth < :depth + ) + SELECT DISTINCT identifier, template_identifier FROM outbound_graph + UNION + SELECT DISTINCT identifier, template_identifier FROM inbound_graph + """, nativeQuery = true) + List findEntityGraphIdentifiers( + @Param("templateIdentifier") String templateIdentifier, + @Param("entityIdentifier") String entityIdentifier, + @Param("depth") int depth); } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaRelationRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaRelationRepository.java index 6c2c5434..57c0c666 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaRelationRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaRelationRepository.java @@ -15,7 +15,9 @@ public interface JpaRelationRepository extends JpaRepository { @Query(""" - SELECT tei AS targetEntityIdentifier, r.name AS relationName, e.identifier AS sourceEntityIdentifier, e.name AS sourceEntityName + SELECT new com.decathlon.idp_core.domain.model.entity.RelationAsTargetSummary( + tei, r.name, e.identifier, e.name + ) FROM EntityJpaEntity e JOIN e.relations r JOIN r.targetEntityIdentifiers tei From 1293b541033d9c30400ff79182a3aa2004c93017 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Tue, 19 May 2026 12:06:00 +0200 Subject: [PATCH 13/53] feat(core): add a entity graph service and endpoint --- .../dto/out/entity/EntityGraphNodeDtoOut.java | 8 +- .../out/entity/EntityGraphRelationDtoOut.java | 15 +- .../entity_graph/EntityGraphServiceTest.java | 284 ++++++++++++++++++ 3 files changed, 301 insertions(+), 6 deletions(-) create mode 100644 src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java index 5d159bf9..980fb262 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java @@ -26,4 +26,10 @@ public record EntityGraphNodeDtoOut( @Schema(description = ENTITY_GRAPH_RELATIONS_AS_TARGET_DESCRIPTION) Map> relationsAsTarget -) {} +) { + /// Defensive copies prevent external mutation of the mutable Map collections + public EntityGraphNodeDtoOut { + relations = relations != null ? Map.copyOf(relations) : Map.of(); + relationsAsTarget = relationsAsTarget != null ? Map.copyOf(relationsAsTarget) : Map.of(); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java index 55f0794e..df126293 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java @@ -1,5 +1,9 @@ package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATION_NAME_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATION_TARGETS_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATION_TARGET_TEMPLATE_DESCRIPTION; + import java.util.List; import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; @@ -7,10 +11,6 @@ import io.swagger.v3.oas.annotations.media.Schema; -import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATION_NAME_DESCRIPTION; -import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATION_TARGET_TEMPLATE_DESCRIPTION; -import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATION_TARGETS_DESCRIPTION; - /// Output DTO representing a single named relation in the entity graph. @JsonNaming(SnakeCaseStrategy.class) public record EntityGraphRelationDtoOut( @@ -23,4 +23,9 @@ public record EntityGraphRelationDtoOut( @Schema(description = ENTITY_GRAPH_RELATION_TARGETS_DESCRIPTION) List targets -) {} +) { + /// Defensive copy prevents external mutation of the mutable List collection + public EntityGraphRelationDtoOut { + targets = targets != null ? List.copyOf(targets) : List.of(); + } +} diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java new file mode 100644 index 00000000..0b07bd02 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java @@ -0,0 +1,284 @@ +package com.decathlon.idp_core.domain.service.entity_graph; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +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 org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; +import com.decathlon.idp_core.domain.model.entity.Entity; +import com.decathlon.idp_core.domain.model.entity.EntityCompositeKey; +import com.decathlon.idp_core.domain.model.entity.Relation; +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; +import com.decathlon.idp_core.domain.port.EntityGraphRepositoryPort; +import com.decathlon.idp_core.domain.port.EntityRepositoryPort; + +@ExtendWith(MockitoExtension.class) +@DisplayName("EntityGraphService Tests") +class EntityGraphServiceTest { + + private static final String TEMPLATE = "web-service"; + private static final String DB_TEMPLATE = "database"; + private static final String CACHE_TEMPLATE = "cache"; + private static final String INFRA_TEMPLATE = "infrastructure"; + private static final int DEFAULT_DEPTH = 3; + + @Mock + private EntityRepositoryPort entityRepositoryPort; + + @Mock + private EntityGraphRepositoryPort entityGraphRepositoryPort; + + @InjectMocks + private EntityGraphService entityGraphService; + + // --- Fixtures --- + + private Entity entity(String templateIdentifier, String identifier, String name) { + return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, List.of(), List.of()); + } + + private Entity entityWithRelations(String templateIdentifier, String identifier, String name, + List relations) { + return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, List.of(), relations); + } + + private Relation relation(String name, String targetTemplateIdentifier, List targetIdentifiers) { + return new Relation(UUID.randomUUID(), name, targetTemplateIdentifier, targetIdentifiers); + } + + private EntityCompositeKey key(String templateIdentifier, String identifier) { + return new EntityCompositeKey(templateIdentifier, identifier); + } + + // --- Tests --- + + @Nested + @DisplayName("getEntityGraph — root entity not found") + class RootEntityNotFound { + + @Test + @DisplayName("Should throw EntityNotFoundException when root entity does not exist") + void shouldThrowWhenRootEntityNotFound() { + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "missing")) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> entityGraphService.getEntityGraph(TEMPLATE, "missing", DEFAULT_DEPTH)) + .isInstanceOf(EntityNotFoundException.class); + + verify(entityRepositoryPort).findByTemplateIdentifierAndIdentifier(TEMPLATE, "missing"); + verifyNoInteractions(entityGraphRepositoryPort); + } + } + + @Nested + @DisplayName("getEntityGraph — single root, no relations") + class SingleRootNoRelations { + + @Test + @DisplayName("Should return a leaf node when entity has no relations") + void shouldReturnLeafNodeWhenNoRelations() { + var root = entity(TEMPLATE, "api", "API Service"); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(root)); + when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH)) + .thenReturn(Map.of(key(TEMPLATE, "api"), root)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH); + + assertThat(result.identifier()).isEqualTo("api"); + assertThat(result.name()).isEqualTo("API Service"); + assertThat(result.relations()).isEmpty(); + assertThat(result.relationsAsTarget()).isEmpty(); + } + } + + @Nested + @DisplayName("getEntityGraph — outbound relations") + class OutboundRelations { + + @Test + @DisplayName("Should resolve outbound relations to graph nodes") + void shouldResolveOutboundRelations() { + var db = entity(DB_TEMPLATE, "postgres", "Postgres DB"); + var api = entityWithRelations(TEMPLATE, "api", "API Service", + List.of(relation("uses", DB_TEMPLATE, List.of("postgres")))); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH)) + .thenReturn(Map.of( + key(TEMPLATE, "api"), api, + key(DB_TEMPLATE, "postgres"), db + )); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH); + + assertThat(result.relations()).hasSize(1); + assertThat(result.relations().getFirst().name()).isEqualTo("uses"); + assertThat(result.relations().getFirst().targets()).hasSize(1); + assertThat(result.relations().getFirst().targets().getFirst().identifier()).isEqualTo("postgres"); + } + + @Test + @DisplayName("Should create a fallback node when relation target is not in the graph map") + void shouldReturnFallbackNodeWhenTargetNotInMap() { + // Simulates a target entity outside the loaded depth — still produces a placeholder node + var api = entityWithRelations(TEMPLATE, "api", "API Service", + List.of(relation("uses", DB_TEMPLATE, List.of("unknown-db")))); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH)) + .thenReturn(Map.of(key(TEMPLATE, "api"), api)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH); + + assertThat(result.relations()).hasSize(1); + // Fallback node uses identifier as both id and name when entity is not in map + assertThat(result.relations().getFirst().targets().getFirst().identifier()).isEqualTo("unknown-db"); + } + } + + @Nested + @DisplayName("getEntityGraph — inbound relations") + class InboundRelations { + + @Test + @DisplayName("Should resolve inbound relations for entities that are targeted by others") + void shouldResolveInboundRelations() { + var db = entity(DB_TEMPLATE, "postgres", "Postgres DB"); + var api = entityWithRelations(TEMPLATE, "api", "API Service", + List.of(relation("uses", DB_TEMPLATE, List.of("postgres")))); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(DB_TEMPLATE, "postgres")) + .thenReturn(Optional.of(db)); + when(entityGraphRepositoryPort.findEntityGraph(DB_TEMPLATE, "postgres", DEFAULT_DEPTH)) + .thenReturn(Map.of( + key(TEMPLATE, "api"), api, + key(DB_TEMPLATE, "postgres"), db + )); + + EntityGraphNode result = entityGraphService.getEntityGraph(DB_TEMPLATE, "postgres", DEFAULT_DEPTH); + + // postgres is targeted by api via "uses" + assertThat(result.relationsAsTarget()).hasSize(1); + assertThat(result.relationsAsTarget().getFirst().name()).isEqualTo("uses"); + assertThat(result.relationsAsTarget().getFirst().targets().getFirst().identifier()).isEqualTo("api"); + } + } + + @Nested + @DisplayName("getEntityGraph — depth clamping") + class DepthClamping { + + @Test + @DisplayName("Should clamp depth below 1 to 1") + void shouldClampDepthBelowOne() { + var root = entity(TEMPLATE, "api", "API Service"); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(root)); + when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", 1)) + .thenReturn(Map.of(key(TEMPLATE, "api"), root)); + + entityGraphService.getEntityGraph(TEMPLATE, "api", 0); + + verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "api", 1); + } + + @Test + @DisplayName("Should clamp depth above 10 to 10") + void shouldClampDepthAboveTen() { + var root = entity(TEMPLATE, "api", "API Service"); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(root)); + when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", 10)) + .thenReturn(Map.of(key(TEMPLATE, "api"), root)); + + entityGraphService.getEntityGraph(TEMPLATE, "api", 99); + + verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "api", 10); + } + } + + @Nested + @DisplayName("getEntityGraph — depth limit stops recursion") + class DepthLimit { + + @Test + @DisplayName("Should return a leaf node for targets at the depth boundary") + void shouldReturnLeafNodeAtDepthBoundary() { + // api --uses--> postgres --runs-on--> server-1 + // At depth=1: postgres node is resolved but its own relations are NOT expanded + var server = entity(INFRA_TEMPLATE, "server-1", "Server 1"); + var db = entityWithRelations(DB_TEMPLATE, "postgres", "Postgres DB", + List.of(relation("runs-on", INFRA_TEMPLATE, List.of("server-1")))); + var api = entityWithRelations(TEMPLATE, "api", "API Service", + List.of(relation("uses", DB_TEMPLATE, List.of("postgres")))); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", 1)) + .thenReturn(Map.of( + key(TEMPLATE, "api"), api, + key(DB_TEMPLATE, "postgres"), db, + key(INFRA_TEMPLATE, "server-1"), server + )); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1); + + // postgres node is included but its child relations are empty (remaining depth = 0) + var dbNode = result.relations().getFirst().targets().getFirst(); + assertThat(dbNode.identifier()).isEqualTo("postgres"); + assertThat(dbNode.relations()).isEmpty(); + } + } + + @Nested + @DisplayName("getEntityGraph — multiple outbound relations") + class MultipleRelations { + + @Test + @DisplayName("Should resolve multiple named relation types correctly") + void shouldResolveMultipleNamedRelations() { + var db = entity(DB_TEMPLATE, "postgres", "Postgres DB"); + var cache = entity(CACHE_TEMPLATE, "redis", "Redis Cache"); + var api = entityWithRelations(TEMPLATE, "api", "API Service", List.of( + relation("uses-db", DB_TEMPLATE, List.of("postgres")), + relation("uses-cache", CACHE_TEMPLATE, List.of("redis")) + )); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH)) + .thenReturn(Map.of( + key(TEMPLATE, "api"), api, + key(DB_TEMPLATE, "postgres"), db, + key(CACHE_TEMPLATE, "redis"), cache + )); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH); + + assertThat(result.relations()).hasSize(2); + var relationNames = result.relations().stream().map(r -> r.name()).toList(); + assertThat(relationNames).containsExactlyInAnyOrder("uses-db", "uses-cache"); + } + } +} From 7411267d36e7e7e78fa24b95962a01eb281e30a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Tue, 19 May 2026 16:54:45 +0200 Subject: [PATCH 14/53] feat(core): add a entity graph service and endpoint --- .../model/entity_graph/EntityGraphNode.java | 2 + .../entity_graph/EntityGraphService.java | 6 +- .../api/configuration/SwaggerDescription.java | 15 +++ .../api/controller/EntityGraphController.java | 39 ++++++ .../dto/out/entity/EntityGraphEdgeDtoOut.java | 31 +++++ .../dto/out/entity/EntityGraphFlatDtoOut.java | 33 +++++ .../dto/out/entity/EntityGraphNodeDtoOut.java | 1 + .../out/entity/EntityGraphNodeFlatDtoOut.java | 31 +++++ .../entity/EntityGraphDtoOutMapper.java | 2 +- .../entity/EntityGraphFlatDtoOutMapper.java | 123 ++++++++++++++++++ .../entity_graph/EntityGraphServiceTest.java | 2 - 11 files changed, 279 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphEdgeDtoOut.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphFlatDtoOut.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java index 9348a38a..2a795f54 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java @@ -10,10 +10,12 @@ /// - Understanding relationship chains between entities /// - Providing a hierarchical view of entity connections /// +/// @param templateIdentifier the template identifier this entity belongs to /// @param summary the lightweight entity identification data /// @param relations the resolved outbound relations with their target graph nodes /// @param relationsAsTarget incoming relations where this entity is the target public record EntityGraphNode( + String templateIdentifier, String identifier, String name, List relations, diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java index 46ca3f88..367e154e 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java @@ -77,11 +77,11 @@ private EntityGraphNode buildGraphNode(EntityCompositeKey key, int remainingDepth) { Entity entity = entityMap.get(key); if (entity == null) { - return new EntityGraphNode(key.identifier(), key.identifier(), List.of(), List.of()); + return new EntityGraphNode(key.templateIdentifier(), key.identifier(), key.identifier(), List.of(), List.of()); } if (remainingDepth <= 0) { - return new EntityGraphNode(entity.identifier(), entity.name(), List.of(), List.of()); + return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), List.of(), List.of()); } // Resolve outbound relations from pre-loaded entities @@ -102,7 +102,7 @@ private EntityGraphNode buildGraphNode(EntityCompositeKey key, List inboundRelations = buildRelationsAsTargetFromMap( entity.identifier(), entityMap, remainingDepth - 1); - return new EntityGraphNode(entity.identifier(), entity.name(), outboundRelations, inboundRelations); + return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), outboundRelations, inboundRelations); } /// Looks up a composite key from the map by identifier alone. 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 b7f85ec8..67013ba8 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 @@ -161,4 +161,19 @@ public class SwaggerDescription { public static final String ENTITY_GRAPH_RELATION_TARGET_TEMPLATE_DESCRIPTION = "The template identifier of target entities"; public static final String ENTITY_GRAPH_RELATION_TARGETS_DESCRIPTION = "Resolved target entity graph nodes"; public static final String ENTITY_GRAPH_RELATIONS_AS_TARGET_DESCRIPTION = "Incoming relations where this entity is the target"; + + // --- Entity Graph flat (nodes & edges) descriptions --- + public static final String ENDPOINT_GET_ENTITY_GRAPH_FLAT_SUMMARY = "Get entity relationship graph as flat nodes and edges"; + public static final String ENDPOINT_GET_ENTITY_GRAPH_FLAT_DESCRIPTION = "Retrieves the entity relationship graph as a flat nodes-and-edges structure, suitable for frontend visualization tools such as React Flow, Vis.js, and Cytoscape."; + public static final String RESPONSE_ENTITY_GRAPH_FLAT_SUCCESS = "Flat entity graph successfully retrieved"; + public static final String ENTITY_GRAPH_FLAT_NODES_DESCRIPTION = "All entity nodes in the graph"; + public static final String ENTITY_GRAPH_FLAT_EDGES_DESCRIPTION = "All directed relation edges in the graph"; + public static final String ENTITY_GRAPH_FLAT_NODE_ID_DESCRIPTION = "Unique node identifier composed of templateIdentifier:identifier"; + public static final String ENTITY_GRAPH_FLAT_NODE_LABEL_DESCRIPTION = "Human-readable entity name"; + public static final String ENTITY_GRAPH_FLAT_NODE_TEMPLATE_DESCRIPTION = "Template identifier this entity belongs to"; + public static final String ENTITY_GRAPH_FLAT_NODE_IDENTIFIER_DESCRIPTION = "Business identifier of the entity within its template"; + public static final String ENTITY_GRAPH_FLAT_EDGE_ID_DESCRIPTION = "Unique edge identifier"; + public static final String ENTITY_GRAPH_FLAT_EDGE_SOURCE_DESCRIPTION = "Node id of the source entity"; + public static final String ENTITY_GRAPH_FLAT_EDGE_TARGET_DESCRIPTION = "Node id of the target entity"; + public static final String ENTITY_GRAPH_FLAT_EDGE_TYPE_DESCRIPTION = "Relation name as defined in the entity template"; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java index 727b963b..c33a558a 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java @@ -1,10 +1,13 @@ package com.decathlon.idp_core.infrastructure.adapters.api.controller; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITY_GRAPH_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITY_GRAPH_FLAT_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITY_GRAPH_FLAT_SUMMARY; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITY_GRAPH_SUMMARY; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.NOT_FOUND_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.OK_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_DEPTH_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_GRAPH_FLAT_SUCCESS; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_GRAPH_SUCCESS; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER; import static org.springframework.http.HttpStatus.OK; @@ -18,9 +21,11 @@ import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; import com.decathlon.idp_core.domain.service.entity_graph.EntityGraphService; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityGraphFlatDtoOut; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityGraphNodeDtoOut; import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse; import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.EntityGraphDtoOutMapper; +import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.EntityGraphFlatDtoOutMapper; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -75,4 +80,38 @@ public EntityGraphNodeDtoOut getEntityGraph( return EntityGraphDtoOutMapper.toDto(graphNode); } + + /// Retrieves the entity relationship graph as a flat nodes-and-edges structure. + /// + /// Returns all entities as nodes and all directed relations as edges, following + /// the de-facto standard for frontend visualization tools such as React Flow, + /// Vis.js, and Cytoscape. Nodes are deduplicated; edges encode directionality. + /// + /// @param templateIdentifier the template identifier of the root entity + /// @param entityIdentifier the business identifier of the root entity + /// @param depth the maximum traversal depth (default 1, clamped between 1 and 10) + /// @return flat DTO containing nodes and edges arrays + @GetMapping("/{templateIdentifier}/{entityIdentifier}/graph/flat") + @ResponseStatus(OK) + @Operation( + summary = ENDPOINT_GET_ENTITY_GRAPH_FLAT_SUMMARY, + description = ENDPOINT_GET_ENTITY_GRAPH_FLAT_DESCRIPTION, + responses = { + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_GRAPH_FLAT_SUCCESS, + content = @Content(schema = @Schema(implementation = EntityGraphFlatDtoOut.class))), + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + } + ) + public EntityGraphFlatDtoOut getEntityGraphFlat( + @PathVariable @NotBlank String templateIdentifier, + @PathVariable @NotBlank String entityIdentifier, + @Parameter(description = PARAM_DEPTH_DESCRIPTION) + @RequestParam(defaultValue = "1") int depth) { + + EntityGraphNode graphNode = entityGraphService.getEntityGraph( + templateIdentifier, entityIdentifier, depth); + + return EntityGraphFlatDtoOutMapper.toFlatDto(graphNode); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphEdgeDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphEdgeDtoOut.java new file mode 100644 index 00000000..c61800dc --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphEdgeDtoOut.java @@ -0,0 +1,31 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity; + +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_EDGE_ID_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_EDGE_SOURCE_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_EDGE_TARGET_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_EDGE_TYPE_DESCRIPTION; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; + +/// Output DTO representing a directed relation edge in the flat entity graph. +/// +/// Encodes a single directional connection between two entity nodes, identified +/// by their composite-key-derived node IDs. +@JsonNaming(SnakeCaseStrategy.class) +public record EntityGraphEdgeDtoOut( + + @Schema(description = ENTITY_GRAPH_FLAT_EDGE_ID_DESCRIPTION) + String id, + + @Schema(description = ENTITY_GRAPH_FLAT_EDGE_SOURCE_DESCRIPTION) + String source, + + @Schema(description = ENTITY_GRAPH_FLAT_EDGE_TARGET_DESCRIPTION) + String target, + + @Schema(description = ENTITY_GRAPH_FLAT_EDGE_TYPE_DESCRIPTION) + String type +) {} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphFlatDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphFlatDtoOut.java new file mode 100644 index 00000000..aa43eb8a --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphFlatDtoOut.java @@ -0,0 +1,33 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity; + +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_EDGES_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_NODES_DESCRIPTION; + +import java.util.List; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; + +/// Top-level response DTO for the flat entity graph representation. +/// +/// Separates entities from their connections into two parallel collections, +/// following the de-facto standard expected by frontend visualization libraries +/// such as React Flow, Vis.js, and Cytoscape. This format avoids nesting and +/// any risk of infinite loops caused by circular relations. +@JsonNaming(SnakeCaseStrategy.class) +public record EntityGraphFlatDtoOut( + + @Schema(description = ENTITY_GRAPH_FLAT_NODES_DESCRIPTION) + List nodes, + + @Schema(description = ENTITY_GRAPH_FLAT_EDGES_DESCRIPTION) + List edges +) { + /// Defensive copies prevent external mutation of the returned collections. + public EntityGraphFlatDtoOut { + nodes = nodes != null ? List.copyOf(nodes) : List.of(); + edges = edges != null ? List.copyOf(edges) : List.of(); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java index 980fb262..43119c9b 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java @@ -18,6 +18,7 @@ @JsonNaming(SnakeCaseStrategy.class) public record EntityGraphNodeDtoOut( + String templateIdentifier, String identifier, String name, diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java new file mode 100644 index 00000000..569941e8 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java @@ -0,0 +1,31 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity; + +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_NODE_ID_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_NODE_IDENTIFIER_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_NODE_LABEL_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_NODE_TEMPLATE_DESCRIPTION; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; + +/// Output DTO representing a single node in the flat entity graph. +/// +/// Used by frontend visualization tools (React Flow, Vis.js, Cytoscape) that expect +/// entities and their relationships as separate, non-nested collections. +@JsonNaming(SnakeCaseStrategy.class) +public record EntityGraphNodeFlatDtoOut( + + @Schema(description = ENTITY_GRAPH_FLAT_NODE_ID_DESCRIPTION) + String id, + + @Schema(description = ENTITY_GRAPH_FLAT_NODE_LABEL_DESCRIPTION) + String label, + + @Schema(description = ENTITY_GRAPH_FLAT_NODE_TEMPLATE_DESCRIPTION) + String templateIdentifier, + + @Schema(description = ENTITY_GRAPH_FLAT_NODE_IDENTIFIER_DESCRIPTION) + String identifier +) {} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java index 8b3e6be3..582c9f51 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java @@ -29,7 +29,7 @@ public static EntityGraphNodeDtoOut toDto(EntityGraphNode node) { return null; } return new EntityGraphNodeDtoOut( - node.identifier(), node.name(), + node.templateIdentifier(), node.identifier(), node.name(), mapRelations(node.relations()), mapRelations(node.relationsAsTarget())); } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java new file mode 100644 index 00000000..d8874c28 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java @@ -0,0 +1,123 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.SequencedSet; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphRelation; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityGraphEdgeDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityGraphFlatDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityGraphNodeFlatDtoOut; + +/// Mapper for converting a recursive [EntityGraphNode] domain tree into the flat +/// nodes-and-edges representation expected by frontend visualization libraries +/// (React Flow, Vis.js, Cytoscape). +/// +/// **Design:** +/// - Traverses both `relations` (outbound) and `relationsAsTarget` (inbound) depth-first, +/// deduplicating nodes by their composite node ID (templateIdentifier:identifier). +/// - Outbound edges are emitted as `source → target`. +/// - Inbound edges (relationsAsTarget) are emitted as `source → currentNode`, preserving +/// the original direction of the relation. This is critical when the root entity has no +/// outbound relations and is only reachable as a relation target. +/// - A `SequencedSet` of visited node IDs prevents infinite loops in cyclic graphs. +/// - A `Set` of edge signatures (`source|target|label`) deduplicates edges that would +/// otherwise be emitted twice when both sides of a relation are traversed. +public final class EntityGraphFlatDtoOutMapper { + + private EntityGraphFlatDtoOutMapper() { + // Utility class — not instantiable + } + + /// Maps a domain graph node tree to a flat [EntityGraphFlatDtoOut]. + /// + /// @param root the root [EntityGraphNode] returned by the domain service + /// @return flat DTO with deduplicated nodes and directed edges + public static EntityGraphFlatDtoOut toFlatDto(EntityGraphNode root) { + if (root == null) { + return new EntityGraphFlatDtoOut(List.of(), List.of()); + } + + // Use a SequencedSet to deduplicate nodes while preserving insertion order + SequencedSet nodes = new LinkedHashSet<>(); + List edges = new ArrayList<>(); + // Tracks visited node IDs to prevent infinite loops in cyclic graphs + Set visitedNodeIds = new HashSet<>(); + // Tracks emitted edge signatures (source|target|label) to avoid duplicate edges + // when the same relation is encountered from both sides during traversal + Set emittedEdgeSignatures = new HashSet<>(); + var edgeCounter = new AtomicInteger(0); + + traverse(root, nodes, edges, visitedNodeIds, emittedEdgeSignatures, edgeCounter); + + return new EntityGraphFlatDtoOut(List.copyOf(nodes), List.copyOf(edges)); + } + + private static void traverse( + EntityGraphNode node, + SequencedSet nodes, + List edges, + Set visitedNodeIds, + Set emittedEdgeSignatures, + AtomicInteger edgeCounter) { + + var nodeId = nodeId(node.templateIdentifier(), node.identifier()); + + // Skip this node if already visited to prevent infinite loops in cyclic graphs + if (!visitedNodeIds.add(nodeId)) { + return; + } + + nodes.add(new EntityGraphNodeFlatDtoOut( + nodeId, node.name(), node.templateIdentifier(), node.identifier())); + + // Traverse outbound relations: emit edge from currentNode → target + for (EntityGraphRelation relation : node.relations()) { + for (EntityGraphNode target : relation.targets()) { + var targetId = nodeId(target.templateIdentifier(), target.identifier()); + addEdge(edges, emittedEdgeSignatures, edgeCounter, nodeId, targetId, relation.name()); + traverse(target, nodes, edges, visitedNodeIds, emittedEdgeSignatures, edgeCounter); + } + } + + // Traverse inbound relations: emit edge from source → currentNode. + // This is essential when the root entity has no outbound relations and is only + // reachable as a target. Without this, traversal would stop at the root with no edges. + for (EntityGraphRelation relation : node.relationsAsTarget()) { + for (EntityGraphNode source : relation.targets()) { + var sourceId = nodeId(source.templateIdentifier(), source.identifier()); + addEdge(edges, emittedEdgeSignatures, edgeCounter, sourceId, nodeId, relation.name()); + traverse(source, nodes, edges, visitedNodeIds, emittedEdgeSignatures, edgeCounter); + } + } + } + + /// Adds a directed edge only if it has not been emitted before, preventing duplicates + /// that arise when the same relation is encountered from both the source and the target + /// during depth-first traversal. + private static void addEdge( + List edges, + Set emittedEdgeSignatures, + AtomicInteger edgeCounter, + String sourceId, + String targetId, + String label) { + + var signature = sourceId + "|" + targetId + "|" + label; + if (emittedEdgeSignatures.add(signature)) { + edges.add(new EntityGraphEdgeDtoOut( + "e" + edgeCounter.incrementAndGet(), sourceId, targetId, label)); + } + } + + /// Builds the unique node identifier from the entity's composite key. + /// Format: "templateIdentifier:identifier" — mirrors EntityCompositeKey.toString(). + private static String nodeId(String templateIdentifier, String identifier) { + return templateIdentifier + ":" + identifier; + } +} diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java index 0b07bd02..5e389770 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java @@ -65,8 +65,6 @@ private EntityCompositeKey key(String templateIdentifier, String identifier) { return new EntityCompositeKey(templateIdentifier, identifier); } - // --- Tests --- - @Nested @DisplayName("getEntityGraph — root entity not found") class RootEntityNotFound { From 7e9c55625c13320b87afa0d6a8b514138138a443 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Wed, 20 May 2026 10:51:31 +0200 Subject: [PATCH 15/53] feat(core): add a entity graph service and endpoint --- .../api/configuration/SwaggerDescription.java | 14 +---- .../api/controller/EntityGraphController.java | 53 +++---------------- .../dto/out/entity/EntityGraphNodeDtoOut.java | 36 ------------- .../out/entity/EntityGraphRelationDtoOut.java | 31 ----------- .../entity/EntityGraphDtoOutMapper.java | 50 ----------------- 5 files changed, 9 insertions(+), 175 deletions(-) delete mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java delete mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java delete mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java 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 67013ba8..c3b229e3 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 @@ -149,20 +149,8 @@ public class SwaggerDescription { public static final String PARAM_SIZE_DESCRIPTION = "Number of items per page. Defaults to 20."; public static final String PARAM_SORT_DESCRIPTION = "Sorting criteria in the format: property(,asc|desc). Defaults to identifier,asc."; - // --- Entity Graph descriptions --- - public static final String ENDPOINT_GET_ENTITY_GRAPH_SUMMARY = "Get entity relationship graph"; - public static final String ENDPOINT_GET_ENTITY_GRAPH_DESCRIPTION = "Retrieves the entity relationship graph starting from the specified entity, resolving outbound relations recursively up to the requested depth."; - public static final String RESPONSE_ENTITY_GRAPH_SUCCESS = "Entity graph successfully retrieved"; + // --- Entity Graph (flat nodes & edges) descriptions --- public static final String PARAM_DEPTH_DESCRIPTION = "Maximum traversal depth for relationship resolution. Clamped between 1 and 10."; - public static final String ENTITY_GRAPH_NODE_DESCRIPTION = "A node in the entity relationship graph"; - public static final String ENTITY_GRAPH_SUMMARY_DESCRIPTION = "Summary information identifying the entity"; - public static final String ENTITY_GRAPH_RELATIONS_DESCRIPTION = "Resolved outbound relations with target entity nodes"; - public static final String ENTITY_GRAPH_RELATION_NAME_DESCRIPTION = "The relation name as defined in the entity template"; - public static final String ENTITY_GRAPH_RELATION_TARGET_TEMPLATE_DESCRIPTION = "The template identifier of target entities"; - public static final String ENTITY_GRAPH_RELATION_TARGETS_DESCRIPTION = "Resolved target entity graph nodes"; - public static final String ENTITY_GRAPH_RELATIONS_AS_TARGET_DESCRIPTION = "Incoming relations where this entity is the target"; - - // --- Entity Graph flat (nodes & edges) descriptions --- public static final String ENDPOINT_GET_ENTITY_GRAPH_FLAT_SUMMARY = "Get entity relationship graph as flat nodes and edges"; public static final String ENDPOINT_GET_ENTITY_GRAPH_FLAT_DESCRIPTION = "Retrieves the entity relationship graph as a flat nodes-and-edges structure, suitable for frontend visualization tools such as React Flow, Vis.js, and Cytoscape."; public static final String RESPONSE_ENTITY_GRAPH_FLAT_SUCCESS = "Flat entity graph successfully retrieved"; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java index c33a558a..872b8469 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java @@ -1,14 +1,11 @@ package com.decathlon.idp_core.infrastructure.adapters.api.controller; -import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITY_GRAPH_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITY_GRAPH_FLAT_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITY_GRAPH_FLAT_SUMMARY; -import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITY_GRAPH_SUMMARY; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.NOT_FOUND_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.OK_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_DEPTH_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_GRAPH_FLAT_SUCCESS; -import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_GRAPH_SUCCESS; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER; import static org.springframework.http.HttpStatus.OK; @@ -22,9 +19,7 @@ import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; import com.decathlon.idp_core.domain.service.entity_graph.EntityGraphService; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityGraphFlatDtoOut; -import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityGraphNodeDtoOut; import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse; -import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.EntityGraphDtoOutMapper; import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.EntityGraphFlatDtoOutMapper; import io.swagger.v3.oas.annotations.Operation; @@ -38,8 +33,9 @@ /// REST controller for entity relationship graph operations. /// -/// Provides endpoints to retrieve hierarchical relationship graphs starting from -/// a specified entity, enabling visualization of entity dependencies and connections. +/// Provides endpoints to retrieve flat (nodes and edges) relationship graphs +/// starting from a specified entity, suitable for frontend visualization tools +/// such as React Flow, Vis.js, and Cytoscape. @RestController @RequestMapping("/api/v1/entities") @RequiredArgsConstructor @@ -48,50 +44,17 @@ public class EntityGraphController { private final EntityGraphService entityGraphService; - /// Retrieves the entity relationship graph starting from the specified entity. - /// - /// Resolves outbound relations recursively up to the requested depth, - /// returning a tree structure with entity summary information at each node. - /// - /// @param templateIdentifier the template identifier of the root entity - /// @param entityIdentifier the business identifier of the root entity - /// @param depth the maximum traversal depth (default 1, clamped between 1 and 10) - /// @return the root graph node with resolved relations - @GetMapping("/{templateIdentifier}/{entityIdentifier}/graph") - @ResponseStatus(OK) - @Operation( - summary = ENDPOINT_GET_ENTITY_GRAPH_SUMMARY, - description = ENDPOINT_GET_ENTITY_GRAPH_DESCRIPTION, - responses = { - @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_GRAPH_SUCCESS, - content = @Content(schema = @Schema(implementation = EntityGraphNodeDtoOut.class))), - @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, - content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - } - ) - public EntityGraphNodeDtoOut getEntityGraph( - @PathVariable @NotBlank String templateIdentifier, - @PathVariable @NotBlank String entityIdentifier, - @Parameter(description = PARAM_DEPTH_DESCRIPTION) - @RequestParam(defaultValue = "1") int depth) { - - EntityGraphNode graphNode = entityGraphService.getEntityGraph( - templateIdentifier, entityIdentifier, depth); - - return EntityGraphDtoOutMapper.toDto(graphNode); - } - /// Retrieves the entity relationship graph as a flat nodes-and-edges structure. /// - /// Returns all entities as nodes and all directed relations as edges, following - /// the de-facto standard for frontend visualization tools such as React Flow, - /// Vis.js, and Cytoscape. Nodes are deduplicated; edges encode directionality. + /// Returns all entities as nodes and all directed relations as edges. Nodes are + /// deduplicated; edges encode directionality. Suitable for React Flow, Vis.js, + /// Cytoscape, and similar frontend graph visualization libraries. /// /// @param templateIdentifier the template identifier of the root entity /// @param entityIdentifier the business identifier of the root entity /// @param depth the maximum traversal depth (default 1, clamped between 1 and 10) /// @return flat DTO containing nodes and edges arrays - @GetMapping("/{templateIdentifier}/{entityIdentifier}/graph/flat") + @GetMapping("/{templateIdentifier}/{entityIdentifier}/graph") @ResponseStatus(OK) @Operation( summary = ENDPOINT_GET_ENTITY_GRAPH_FLAT_SUMMARY, @@ -103,7 +66,7 @@ public EntityGraphNodeDtoOut getEntityGraph( content = @Content(schema = @Schema(implementation = ErrorResponse.class))) } ) - public EntityGraphFlatDtoOut getEntityGraphFlat( + public EntityGraphFlatDtoOut getEntityGraph( @PathVariable @NotBlank String templateIdentifier, @PathVariable @NotBlank String entityIdentifier, @Parameter(description = PARAM_DEPTH_DESCRIPTION) diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java deleted file mode 100644 index 43119c9b..00000000 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity; - -import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATIONS_AS_TARGET_DESCRIPTION; -import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATIONS_DESCRIPTION; - -import java.util.List; -import java.util.Map; - -import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; -import com.fasterxml.jackson.databind.annotation.JsonNaming; - -import io.swagger.v3.oas.annotations.media.Schema; - -/// Output DTO representing a node in the entity relationship graph. -/// -/// Contains summary information about the entity and its resolved outbound relations -/// grouped by relation name, and incoming relations where this entity is the target. -@JsonNaming(SnakeCaseStrategy.class) -public record EntityGraphNodeDtoOut( - - String templateIdentifier, - String identifier, - String name, - - @Schema(description = ENTITY_GRAPH_RELATIONS_DESCRIPTION) - Map> relations, - - @Schema(description = ENTITY_GRAPH_RELATIONS_AS_TARGET_DESCRIPTION) - Map> relationsAsTarget -) { - /// Defensive copies prevent external mutation of the mutable Map collections - public EntityGraphNodeDtoOut { - relations = relations != null ? Map.copyOf(relations) : Map.of(); - relationsAsTarget = relationsAsTarget != null ? Map.copyOf(relationsAsTarget) : Map.of(); - } -} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java deleted file mode 100644 index df126293..00000000 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity; - -import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATION_NAME_DESCRIPTION; -import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATION_TARGETS_DESCRIPTION; -import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_RELATION_TARGET_TEMPLATE_DESCRIPTION; - -import java.util.List; - -import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; -import com.fasterxml.jackson.databind.annotation.JsonNaming; - -import io.swagger.v3.oas.annotations.media.Schema; - -/// Output DTO representing a single named relation in the entity graph. -@JsonNaming(SnakeCaseStrategy.class) -public record EntityGraphRelationDtoOut( - - @Schema(description = ENTITY_GRAPH_RELATION_NAME_DESCRIPTION) - String name, - - @Schema(description = ENTITY_GRAPH_RELATION_TARGET_TEMPLATE_DESCRIPTION) - String targetTemplateIdentifier, - - @Schema(description = ENTITY_GRAPH_RELATION_TARGETS_DESCRIPTION) - List targets -) { - /// Defensive copy prevents external mutation of the mutable List collection - public EntityGraphRelationDtoOut { - targets = targets != null ? List.copyOf(targets) : List.of(); - } -} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java deleted file mode 100644 index 582c9f51..00000000 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity; - -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; -import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphRelation; -import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityGraphNodeDtoOut; - -/// Mapper for converting domain [EntityGraphNode] to its API output DTO -/// representation. -/// -/// Uses Record Patterns for recursive tree mapping since MapStruct does not -/// handle recursive structures cleanly. -public final class EntityGraphDtoOutMapper { - - private EntityGraphDtoOutMapper() { - // Utility class - } - - /// Maps a domain graph node to its DTO representation. - /// - /// @param node the domain graph node - /// @return the output DTO - public static EntityGraphNodeDtoOut toDto(EntityGraphNode node) { - if (node == null) { - return null; - } - return new EntityGraphNodeDtoOut( - node.templateIdentifier(), node.identifier(), node.name(), - mapRelations(node.relations()), - mapRelations(node.relationsAsTarget())); - } - - private static Map> mapRelations(List relations) { - if (relations == null || relations.isEmpty()) { - return Map.of(); - } - return relations.stream() - .collect(Collectors.toMap( - EntityGraphRelation::name, - relation -> relation.targets().stream() - .map(EntityGraphDtoOutMapper::toDto) - .toList(), - (existing, replacement) -> existing, - LinkedHashMap::new)); - } -} From b68ba4d1a2709f7eeb73a260fc7c61890de6e75f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Wed, 20 May 2026 11:19:15 +0200 Subject: [PATCH 16/53] feat(entity-graph): add a entity graph service and endpoint add include data parameter for showing properties in node --- .../model/entity_graph/EntityGraphNode.java | 8 ++++- .../entity_graph/EntityGraphService.java | 32 +++++++++++++------ .../api/configuration/SwaggerDescription.java | 2 ++ .../api/controller/EntityGraphController.java | 8 +++-- .../out/entity/EntityGraphNodeFlatDtoOut.java | 16 ++++++++-- .../entity/EntityGraphFlatDtoOutMapper.java | 16 ++++++++-- .../entity_graph/EntityGraphServiceTest.java | 18 +++++------ 7 files changed, 73 insertions(+), 27 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java index 2a795f54..fff35643 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java @@ -2,6 +2,8 @@ import java.util.List; +import com.decathlon.idp_core.domain.model.entity.Property; + /// A node in the entity relationship graph, containing summary information /// and its resolved relations (recursively up to a configurable depth). /// @@ -11,17 +13,21 @@ /// - Providing a hierarchical view of entity connections /// /// @param templateIdentifier the template identifier this entity belongs to -/// @param summary the lightweight entity identification data +/// @param identifier the business identifier of the entity +/// @param name the human-readable name of the entity +/// @param properties the entity's property instances; empty when not requested /// @param relations the resolved outbound relations with their target graph nodes /// @param relationsAsTarget incoming relations where this entity is the target public record EntityGraphNode( String templateIdentifier, String identifier, String name, + List properties, List relations, List relationsAsTarget ) { public EntityGraphNode { + properties = properties != null ? List.copyOf(properties) : List.of(); relations = relations != null ? List.copyOf(relations) : List.of(); relationsAsTarget = relationsAsTarget != null ? List.copyOf(relationsAsTarget) : List.of(); } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java index 367e154e..e32f1aee 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java @@ -9,6 +9,7 @@ import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntityCompositeKey; +import com.decathlon.idp_core.domain.model.entity.Property; import com.decathlon.idp_core.domain.model.entity.Relation; import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphRelation; @@ -49,10 +50,13 @@ public class EntityGraphService { /// @param templateIdentifier the template identifier of the root entity /// @param entityIdentifier the business identifier of the root entity /// @param depth the maximum traversal depth (clamped to [1, MAX_DEPTH]) + /// @param includeProperties when true, each graph node carries the entity's full property list; + /// when false, properties are omitted to reduce response size /// @return the root graph node with resolved relations /// @throws EntityNotFoundException when no entity matches the given identifiers @Transactional(readOnly = true) - public EntityGraphNode getEntityGraph(String templateIdentifier, String entityIdentifier, int depth) { + public EntityGraphNode getEntityGraph(String templateIdentifier, String entityIdentifier, int depth, + boolean includeProperties) { int effectiveDepth = Math.clamp(depth, 1, MAX_DEPTH); // Verify root entity exists before fetching the graph @@ -67,21 +71,24 @@ public EntityGraphNode getEntityGraph(String templateIdentifier, String entityId EntityCompositeKey rootKey = new EntityCompositeKey(rootEntity.templateIdentifier(), rootEntity.identifier()); // Build the graph from pre-loaded entities (no more database calls) - return buildGraphNode(rootKey, entityMap, effectiveDepth); + return buildGraphNode(rootKey, entityMap, effectiveDepth, includeProperties); } /// Builds a graph node from a pre-loaded entity map (no database calls). /// Recursively resolves both outbound and inbound relations from the cached entities. private EntityGraphNode buildGraphNode(EntityCompositeKey key, Map entityMap, - int remainingDepth) { + int remainingDepth, + boolean includeProperties) { Entity entity = entityMap.get(key); if (entity == null) { - return new EntityGraphNode(key.templateIdentifier(), key.identifier(), key.identifier(), List.of(), List.of()); + return new EntityGraphNode(key.templateIdentifier(), key.identifier(), key.identifier(), + List.of(), List.of(), List.of()); } if (remainingDepth <= 0) { - return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), List.of(), List.of()); + return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), + List.of(), List.of(), List.of()); } // Resolve outbound relations from pre-loaded entities @@ -92,7 +99,7 @@ private EntityGraphNode buildGraphNode(EntityCompositeKey key, .map(targetId -> { // Relations only store identifier; look up by identifier across all entries EntityCompositeKey targetKey = findKeyByIdentifier(targetId, entityMap); - return buildGraphNode(targetKey, entityMap, remainingDepth - 1); + return buildGraphNode(targetKey, entityMap, remainingDepth - 1, includeProperties); }) .toList() )) @@ -100,9 +107,13 @@ private EntityGraphNode buildGraphNode(EntityCompositeKey key, // Resolve inbound relations from pre-loaded entities List inboundRelations = buildRelationsAsTargetFromMap( - entity.identifier(), entityMap, remainingDepth - 1); + entity.identifier(), entityMap, remainingDepth - 1, includeProperties); - return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), outboundRelations, inboundRelations); + // Include properties only when explicitly requested to keep responses lean + List properties = includeProperties ? entity.properties() : List.of(); + + return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), + properties, outboundRelations, inboundRelations); } /// Looks up a composite key from the map by identifier alone. @@ -118,7 +129,8 @@ private EntityCompositeKey findKeyByIdentifier(String identifier, Map buildRelationsAsTargetFromMap(String targetIdentifier, Map entityMap, - int remainingDepth) { + int remainingDepth, + boolean includeProperties) { Map> sourcesByRelationName = new java.util.HashMap<>(); for (Map.Entry entry : entityMap.entrySet()) { @@ -136,7 +148,7 @@ private List buildRelationsAsTargetFromMap(String targetIde .map(e -> new EntityGraphRelation( e.getKey(), e.getValue().stream() - .map(sourceKey -> buildGraphNode(sourceKey, entityMap, remainingDepth)) + .map(sourceKey -> buildGraphNode(sourceKey, entityMap, remainingDepth, includeProperties)) .toList() )) .toList(); 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 c3b229e3..23e72bde 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 @@ -164,4 +164,6 @@ public class SwaggerDescription { public static final String ENTITY_GRAPH_FLAT_EDGE_SOURCE_DESCRIPTION = "Node id of the source entity"; public static final String ENTITY_GRAPH_FLAT_EDGE_TARGET_DESCRIPTION = "Node id of the target entity"; public static final String ENTITY_GRAPH_FLAT_EDGE_TYPE_DESCRIPTION = "Relation name as defined in the entity template"; + public static final String ENTITY_GRAPH_FLAT_NODE_DATA_DESCRIPTION = "Entity property values keyed by property name; present only when include_data=true is requested"; + public static final String PARAM_INCLUDE_DATA_DESCRIPTION = "When true, each graph node includes a data object containing the entity's property values. Defaults to false."; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java index 872b8469..ee8f58a4 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java @@ -5,6 +5,7 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.NOT_FOUND_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.OK_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_DEPTH_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_INCLUDE_DATA_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_GRAPH_FLAT_SUCCESS; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER; import static org.springframework.http.HttpStatus.OK; @@ -53,6 +54,7 @@ public class EntityGraphController { /// @param templateIdentifier the template identifier of the root entity /// @param entityIdentifier the business identifier of the root entity /// @param depth the maximum traversal depth (default 1, clamped between 1 and 10) + /// @param includeData when true, each node includes a data object with entity property values /// @return flat DTO containing nodes and edges arrays @GetMapping("/{templateIdentifier}/{entityIdentifier}/graph") @ResponseStatus(OK) @@ -70,10 +72,12 @@ public EntityGraphFlatDtoOut getEntityGraph( @PathVariable @NotBlank String templateIdentifier, @PathVariable @NotBlank String entityIdentifier, @Parameter(description = PARAM_DEPTH_DESCRIPTION) - @RequestParam(defaultValue = "1") int depth) { + @RequestParam(defaultValue = "1") int depth, + @Parameter(description = PARAM_INCLUDE_DATA_DESCRIPTION) + @RequestParam(defaultValue = "false") boolean includeData) { EntityGraphNode graphNode = entityGraphService.getEntityGraph( - templateIdentifier, entityIdentifier, depth); + templateIdentifier, entityIdentifier, depth, includeData); return EntityGraphFlatDtoOutMapper.toFlatDto(graphNode); } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java index 569941e8..0ae498b4 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java @@ -1,10 +1,15 @@ package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity; -import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_NODE_ID_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_NODE_DATA_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_NODE_IDENTIFIER_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_NODE_ID_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_NODE_LABEL_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_NODE_TEMPLATE_DESCRIPTION; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; @@ -14,6 +19,9 @@ /// /// Used by frontend visualization tools (React Flow, Vis.js, Cytoscape) that expect /// entities and their relationships as separate, non-nested collections. +/// +/// The optional `data` field is populated only when `include_data=true` is requested, +/// containing property name-to-value pairs for the entity. @JsonNaming(SnakeCaseStrategy.class) public record EntityGraphNodeFlatDtoOut( @@ -27,5 +35,9 @@ public record EntityGraphNodeFlatDtoOut( String templateIdentifier, @Schema(description = ENTITY_GRAPH_FLAT_NODE_IDENTIFIER_DESCRIPTION) - String identifier + String identifier, + + @JsonInclude(Include.NON_EMPTY) + @Schema(description = ENTITY_GRAPH_FLAT_NODE_DATA_DESCRIPTION) + Map data ) {} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java index d8874c28..c7ca7a40 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java @@ -4,9 +4,11 @@ import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.SequencedSet; import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphRelation; @@ -38,8 +40,7 @@ private EntityGraphFlatDtoOutMapper() { /// /// @param root the root [EntityGraphNode] returned by the domain service /// @return flat DTO with deduplicated nodes and directed edges - public static EntityGraphFlatDtoOut toFlatDto(EntityGraphNode root) { - if (root == null) { + public static EntityGraphFlatDtoOut toFlatDto(EntityGraphNode root) { if (root == null) { return new EntityGraphFlatDtoOut(List.of(), List.of()); } @@ -74,7 +75,8 @@ private static void traverse( } nodes.add(new EntityGraphNodeFlatDtoOut( - nodeId, node.name(), node.templateIdentifier(), node.identifier())); + nodeId, node.name(), node.templateIdentifier(), node.identifier(), + toDataMap(node))); // Traverse outbound relations: emit edge from currentNode → target for (EntityGraphRelation relation : node.relations()) { @@ -120,4 +122,12 @@ private static void addEdge( private static String nodeId(String templateIdentifier, String identifier) { return templateIdentifier + ":" + identifier; } + + /// Converts a node's property list to a name→value map for the `data` field. + /// Returns an empty map when there are no properties; the DTO's @JsonInclude(NON_EMPTY) + /// annotation ensures an empty map is omitted from the JSON output. + private static Map toDataMap(EntityGraphNode node) { + return node.properties().stream() + .collect(Collectors.toMap(p -> p.name(), p -> p.value())); + } } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java index 5e389770..0d0efbf2 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java @@ -75,7 +75,7 @@ void shouldThrowWhenRootEntityNotFound() { when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "missing")) .thenReturn(Optional.empty()); - assertThatThrownBy(() -> entityGraphService.getEntityGraph(TEMPLATE, "missing", DEFAULT_DEPTH)) + assertThatThrownBy(() -> entityGraphService.getEntityGraph(TEMPLATE, "missing", DEFAULT_DEPTH, false)) .isInstanceOf(EntityNotFoundException.class); verify(entityRepositoryPort).findByTemplateIdentifierAndIdentifier(TEMPLATE, "missing"); @@ -97,7 +97,7 @@ void shouldReturnLeafNodeWhenNoRelations() { when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH)) .thenReturn(Map.of(key(TEMPLATE, "api"), root)); - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false); assertThat(result.identifier()).isEqualTo("api"); assertThat(result.name()).isEqualTo("API Service"); @@ -125,7 +125,7 @@ void shouldResolveOutboundRelations() { key(DB_TEMPLATE, "postgres"), db )); - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false); assertThat(result.relations()).hasSize(1); assertThat(result.relations().getFirst().name()).isEqualTo("uses"); @@ -145,7 +145,7 @@ void shouldReturnFallbackNodeWhenTargetNotInMap() { when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH)) .thenReturn(Map.of(key(TEMPLATE, "api"), api)); - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false); assertThat(result.relations()).hasSize(1); // Fallback node uses identifier as both id and name when entity is not in map @@ -172,7 +172,7 @@ void shouldResolveInboundRelations() { key(DB_TEMPLATE, "postgres"), db )); - EntityGraphNode result = entityGraphService.getEntityGraph(DB_TEMPLATE, "postgres", DEFAULT_DEPTH); + EntityGraphNode result = entityGraphService.getEntityGraph(DB_TEMPLATE, "postgres", DEFAULT_DEPTH, false); // postgres is targeted by api via "uses" assertThat(result.relationsAsTarget()).hasSize(1); @@ -195,7 +195,7 @@ void shouldClampDepthBelowOne() { when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", 1)) .thenReturn(Map.of(key(TEMPLATE, "api"), root)); - entityGraphService.getEntityGraph(TEMPLATE, "api", 0); + entityGraphService.getEntityGraph(TEMPLATE, "api", 0, false); verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "api", 1); } @@ -210,7 +210,7 @@ void shouldClampDepthAboveTen() { when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", 10)) .thenReturn(Map.of(key(TEMPLATE, "api"), root)); - entityGraphService.getEntityGraph(TEMPLATE, "api", 99); + entityGraphService.getEntityGraph(TEMPLATE, "api", 99, false); verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "api", 10); } @@ -240,7 +240,7 @@ void shouldReturnLeafNodeAtDepthBoundary() { key(INFRA_TEMPLATE, "server-1"), server )); - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); // postgres node is included but its child relations are empty (remaining depth = 0) var dbNode = result.relations().getFirst().targets().getFirst(); @@ -272,7 +272,7 @@ void shouldResolveMultipleNamedRelations() { key(CACHE_TEMPLATE, "redis"), cache )); - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false); assertThat(result.relations()).hasSize(2); var relationNames = result.relations().stream().map(r -> r.name()).toList(); From dd5536af7e8207a636d3295cd216a28158157ab9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Wed, 20 May 2026 11:34:08 +0200 Subject: [PATCH 17/53] feat(entity-graph): add a entity graph service and endpoint add include flag for getting properties or not in graph repository call --- .../port/EntityGraphRepositoryPort.java | 5 ++++- .../entity_graph/EntityGraphService.java | 9 ++++----- .../PostgresEntityGraphAdapter.java | 12 +++++++---- .../entity_graph/EntityGraphServiceTest.java | 20 +++++++++---------- 4 files changed, 26 insertions(+), 20 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java index e1ae73f9..c66ba2d5 100644 --- a/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java @@ -29,9 +29,12 @@ public interface EntityGraphRepositoryPort { /// @param templateIdentifier the template identifier of the root entity /// @param entityIdentifier the business identifier of the root entity within its template /// @param depth the maximum traversal depth (1-10) + /// @param includeProperties when true, entity properties are loaded along with relations; + /// when false, only relations are fetched for a leaner query /// @return map of [EntityCompositeKey] to [Entity] for O(1) lookup; empty if root not found Map findEntityGraph( String templateIdentifier, String entityIdentifier, - int depth); + int depth, + boolean includeProperties); } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java index e32f1aee..66ee2f9a 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java @@ -64,9 +64,10 @@ public EntityGraphNode getEntityGraph(String templateIdentifier, String entityId .findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); - // Optimized batch fetch: load all entities in the graph keyed by composite key + // Optimized batch fetch: load all entities in the graph keyed by composite key. + // Properties are fetched only when explicitly requested to avoid unnecessary I/O. Map entityMap = entityGraphRepositoryPort - .findEntityGraph(templateIdentifier, entityIdentifier, effectiveDepth); + .findEntityGraph(templateIdentifier, entityIdentifier, effectiveDepth, includeProperties); EntityCompositeKey rootKey = new EntityCompositeKey(rootEntity.templateIdentifier(), rootEntity.identifier()); @@ -111,10 +112,8 @@ private EntityGraphNode buildGraphNode(EntityCompositeKey key, // Include properties only when explicitly requested to keep responses lean List properties = includeProperties ? entity.properties() : List.of(); - return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), - properties, outboundRelations, inboundRelations); - } + properties, outboundRelations, inboundRelations); } /// Looks up a composite key from the map by identifier alone. /// Falls back to a synthetic key if no match is found (entity not in graph). diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java index 8527027f..deeb2b68 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java @@ -37,7 +37,8 @@ public class PostgresEntityGraphAdapter implements EntityGraphRepositoryPort { public Map findEntityGraph( String templateIdentifier, String entityIdentifier, - int depth) { + int depth, + boolean includeProperties) { // Step 1: collect all (identifier, template_identifier) pairs via recursive CTE List graphPairs = jpaEntityRepository.findEntityGraphIdentifiers( templateIdentifier, entityIdentifier, depth); @@ -52,11 +53,14 @@ public Map findEntityGraph( .distinct() .toList(); - // Step 3: batch-load entities with relations, then properties in separate queries - // to avoid Hibernate's MultipleBagFetchException + // Step 3: batch-load entities with relations, then optionally properties in a separate + // query. Properties are skipped when not requested to avoid the extra round-trip and + // keep payloads lean. The two-query split also avoids Hibernate's MultipleBagFetchException. List jpaEntities = jpaEntityRepository.findAllByIdentifierInWithRelations(identifiers); - jpaEntityRepository.findAllByIdentifierInWithProperties(identifiers); + if (includeProperties) { + jpaEntityRepository.findAllByIdentifierInWithProperties(identifiers); + } // Step 4: map to domain and key by composite key for O(1) lookup return jpaEntities.stream() diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java index 0d0efbf2..7dbaf9d0 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java @@ -94,7 +94,7 @@ void shouldReturnLeafNodeWhenNoRelations() { when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(root)); - when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH)) + when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false)) .thenReturn(Map.of(key(TEMPLATE, "api"), root)); EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false); @@ -119,7 +119,7 @@ void shouldResolveOutboundRelations() { when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(api)); - when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH)) + when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false)) .thenReturn(Map.of( key(TEMPLATE, "api"), api, key(DB_TEMPLATE, "postgres"), db @@ -142,7 +142,7 @@ void shouldReturnFallbackNodeWhenTargetNotInMap() { when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(api)); - when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH)) + when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false)) .thenReturn(Map.of(key(TEMPLATE, "api"), api)); EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false); @@ -166,7 +166,7 @@ void shouldResolveInboundRelations() { when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(DB_TEMPLATE, "postgres")) .thenReturn(Optional.of(db)); - when(entityGraphRepositoryPort.findEntityGraph(DB_TEMPLATE, "postgres", DEFAULT_DEPTH)) + when(entityGraphRepositoryPort.findEntityGraph(DB_TEMPLATE, "postgres", DEFAULT_DEPTH, false)) .thenReturn(Map.of( key(TEMPLATE, "api"), api, key(DB_TEMPLATE, "postgres"), db @@ -192,12 +192,12 @@ void shouldClampDepthBelowOne() { when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(root)); - when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", 1)) + when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", 1, false)) .thenReturn(Map.of(key(TEMPLATE, "api"), root)); entityGraphService.getEntityGraph(TEMPLATE, "api", 0, false); - verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "api", 1); + verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "api", 1, false); } @Test @@ -207,12 +207,12 @@ void shouldClampDepthAboveTen() { when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(root)); - when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", 10)) + when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", 10, false)) .thenReturn(Map.of(key(TEMPLATE, "api"), root)); entityGraphService.getEntityGraph(TEMPLATE, "api", 99, false); - verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "api", 10); + verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "api", 10, false); } } @@ -233,7 +233,7 @@ void shouldReturnLeafNodeAtDepthBoundary() { when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(api)); - when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", 1)) + when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", 1, false)) .thenReturn(Map.of( key(TEMPLATE, "api"), api, key(DB_TEMPLATE, "postgres"), db, @@ -265,7 +265,7 @@ void shouldResolveMultipleNamedRelations() { when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(api)); - when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH)) + when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false)) .thenReturn(Map.of( key(TEMPLATE, "api"), api, key(DB_TEMPLATE, "postgres"), db, From 3b8c995643b1a3ce02b96d05b99a3c6cb8df8f29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Wed, 20 May 2026 17:53:46 +0200 Subject: [PATCH 18/53] feat(entity-graph): add a entity graph service and endpoint --- .../idp_core/domain/model/entity/Entity.java | 6 + .../domain/model/entity/EntityGraphNode.java | 0 .../model/entity/EntityGraphRelation.java | 0 .../model/entity_template/EntityTemplate.java | 6 + .../port/EntityGraphRepositoryPort.java | 6 + .../entity_graph/EntityGraphService.java | 78 +++-- .../api/configuration/CorsProperties.java | 6 +- .../api/configuration/SwaggerDescription.java | 1 + .../api/controller/EntityGraphController.java | 20 +- .../dto/out/entity/EntityGraphNodeDtoOut.java | 0 .../out/entity/EntityGraphNodeFlatDtoOut.java | 8 +- .../out/entity/EntityGraphRelationDtoOut.java | 0 .../entity/EntityGraphDtoOutMapper.java | 0 .../entity/EntityGraphFlatDtoOutMapper.java | 53 ++- .../PostgresEntityGraphAdapter.java | 8 +- .../repository/JpaEntityRepository.java | 52 +++ .../entity_graph/EntityGraphServiceTest.java | 312 ++++++++++++------ .../api/controller/EntityControllerTest.java | 8 +- .../controller/EntityGraphControllerTest.java | 155 +++++++++ .../test/R__2_Insert_entities_test_data.sql | 50 ++- 20 files changed, 605 insertions(+), 164 deletions(-) create mode 100644 src/main/java/com/decathlon/idp_core/domain/model/entity/EntityGraphNode.java create mode 100644 src/main/java/com/decathlon/idp_core/domain/model/entity/EntityGraphRelation.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java create mode 100644 src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java index ab10abee..2292ecd4 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java @@ -36,4 +36,10 @@ public record Entity( List relations ) { + /// Compact constructor: defensively copies mutable lists to prevent external mutation + /// and guarantee immutability of the domain model (EI_EXPOSE_REP2 / EI_EXPOSE_REP). + public Entity { + properties = properties == null ? List.of() : List.copyOf(properties); + relations = relations == null ? List.of() : List.copyOf(relations); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityGraphNode.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityGraphNode.java new file mode 100644 index 00000000..e69de29b diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityGraphRelation.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityGraphRelation.java new file mode 100644 index 00000000..e69de29b diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity_template/EntityTemplate.java b/src/main/java/com/decathlon/idp_core/domain/model/entity_template/EntityTemplate.java index 9a0fb0b0..2d694f10 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity_template/EntityTemplate.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_template/EntityTemplate.java @@ -43,4 +43,10 @@ public record EntityTemplate( List relationsDefinitions ) { + /// Compact constructor: defensively copies mutable lists to prevent external mutation + /// and guarantee immutability of the domain model (EI_EXPOSE_REP2 / EI_EXPOSE_REP). + public EntityTemplate { + propertiesDefinitions = propertiesDefinitions == null ? List.of() : List.copyOf(propertiesDefinitions); + relationsDefinitions = relationsDefinitions == null ? List.of() : List.copyOf(relationsDefinitions); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java index c66ba2d5..82996a2a 100644 --- a/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java @@ -31,7 +31,13 @@ public interface EntityGraphRepositoryPort { /// @param depth the maximum traversal depth (1-10) /// @param includeProperties when true, entity properties are loaded along with relations; /// when false, only relations are fetched for a leaner query + /// @param relationNames when non-empty, only edges whose relation name is in this set are + /// traversed; when empty, all relation types are followed /// @return map of [EntityCompositeKey] to [Entity] for O(1) lookup; empty if root not found + /// Relation name filtering is intentionally NOT pushed into this port. + /// The CTE always traverses all relation types so that nodes reachable via + /// any path are loaded. Edge filtering is applied in the service layer so + /// that "filter owns" still returns B and C when A→(depends-on)→B→(owns)→C. Map findEntityGraph( String templateIdentifier, String entityIdentifier, diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java index 66ee2f9a..4d989ee0 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java @@ -1,7 +1,11 @@ package com.decathlon.idp_core.domain.service.entity_graph; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -32,7 +36,11 @@ /// **Design decisions:** /// - Uses depth-limited traversal to prevent unbounded recursion /// - Optimized with recursive CTE and batch loading to minimize database queries -/// - Does not detect cycles — relies on depth limit to terminate +/// - A per-request `visitedNodeIds` set prevents exponential recursion: without it, +/// inbound relation scanning would re-expand already-visited nodes at every depth +/// level, producing O(2^depth) calls even for small graphs (OOM at depth ≥ 10). +/// - The service always returns the full unfiltered graph tree. Relation name filtering +/// is a presentation concern applied by the mapper layer. @Service @RequiredArgsConstructor public class EntityGraphService { @@ -44,76 +52,81 @@ public class EntityGraphService { /// Builds the relationship graph for an entity starting from its composite key. /// - /// **Optimization:** Uses a recursive CTE to fetch all entities in the graph in 2 queries - /// (1 for composite key pairs, 1 for batch loading), regardless of depth. - /// /// @param templateIdentifier the template identifier of the root entity - /// @param entityIdentifier the business identifier of the root entity - /// @param depth the maximum traversal depth (clamped to [1, MAX_DEPTH]) - /// @param includeProperties when true, each graph node carries the entity's full property list; - /// when false, properties are omitted to reduce response size - /// @return the root graph node with resolved relations + /// @param entityIdentifier the business identifier of the root entity + /// @param depth the maximum traversal depth (clamped to [1, MAX_DEPTH]) + /// @param includeProperties when true, each graph node carries the entity's full property list + /// @return the root graph node with all resolved relations /// @throws EntityNotFoundException when no entity matches the given identifiers @Transactional(readOnly = true) public EntityGraphNode getEntityGraph(String templateIdentifier, String entityIdentifier, int depth, boolean includeProperties) { int effectiveDepth = Math.clamp(depth, 1, MAX_DEPTH); - // Verify root entity exists before fetching the graph Entity rootEntity = entityRepositoryPort .findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); - // Optimized batch fetch: load all entities in the graph keyed by composite key. - // Properties are fetched only when explicitly requested to avoid unnecessary I/O. Map entityMap = entityGraphRepositoryPort .findEntityGraph(templateIdentifier, entityIdentifier, effectiveDepth, includeProperties); EntityCompositeKey rootKey = new EntityCompositeKey(rootEntity.templateIdentifier(), rootEntity.identifier()); - // Build the graph from pre-loaded entities (no more database calls) - return buildGraphNode(rootKey, entityMap, effectiveDepth, includeProperties); + // One shared visited set per request — each node is fully expanded at most once, + // preventing O(2^depth) recursion from mutual outbound/inbound re-expansion. + Set visitedNodeIds = new HashSet<>(); + + return buildGraphNode(rootKey, entityMap, effectiveDepth, includeProperties, visitedNodeIds); } /// Builds a graph node from a pre-loaded entity map (no database calls). - /// Recursively resolves both outbound and inbound relations from the cached entities. + /// + /// [visitedNodeIds] tracks nodes that have already been fully built in this traversal. + /// When a node is encountered again (cycle or shared reference), a stub leaf is returned + /// immediately to cut the recursion — preventing the exponential blowup that arises from + /// inbound scanning re-expanding the same nodes at every depth level. private EntityGraphNode buildGraphNode(EntityCompositeKey key, Map entityMap, int remainingDepth, - boolean includeProperties) { + boolean includeProperties, + Set visitedNodeIds) { Entity entity = entityMap.get(key); if (entity == null) { return new EntityGraphNode(key.templateIdentifier(), key.identifier(), key.identifier(), List.of(), List.of(), List.of()); } + // Guard: return a stub leaf if this node was already fully built in another branch. + // This breaks both directed cycles (A→B→A) and shared references (A→B, C→B). + var nodeId = entity.templateIdentifier() + ":" + entity.identifier(); + if (!visitedNodeIds.add(nodeId)) { + return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), + List.of(), List.of(), List.of()); + } + if (remainingDepth <= 0) { return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), List.of(), List.of(), List.of()); } - // Resolve outbound relations from pre-loaded entities List outboundRelations = entity.relations().stream() .map(relation -> new EntityGraphRelation( relation.name(), relation.targetEntityIdentifiers().stream() - .map(targetId -> { - // Relations only store identifier; look up by identifier across all entries - EntityCompositeKey targetKey = findKeyByIdentifier(targetId, entityMap); - return buildGraphNode(targetKey, entityMap, remainingDepth - 1, includeProperties); - }) + .map(targetId -> buildGraphNode( + findKeyByIdentifier(targetId, entityMap), + entityMap, remainingDepth - 1, includeProperties, visitedNodeIds)) .toList() )) .toList(); - // Resolve inbound relations from pre-loaded entities List inboundRelations = buildRelationsAsTargetFromMap( - entity.identifier(), entityMap, remainingDepth - 1, includeProperties); + entity.identifier(), entityMap, remainingDepth - 1, includeProperties, visitedNodeIds); - // Include properties only when explicitly requested to keep responses lean List properties = includeProperties ? entity.properties() : List.of(); return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), - properties, outboundRelations, inboundRelations); } + properties, outboundRelations, inboundRelations); + } /// Looks up a composite key from the map by identifier alone. /// Falls back to a synthetic key if no match is found (entity not in graph). @@ -125,19 +138,21 @@ private EntityCompositeKey findKeyByIdentifier(String identifier, Map buildRelationsAsTargetFromMap(String targetIdentifier, Map entityMap, int remainingDepth, - boolean includeProperties) { - Map> sourcesByRelationName = new java.util.HashMap<>(); + boolean includeProperties, + Set visitedNodeIds) { + Map> sourcesByRelationName = new HashMap<>(); for (Map.Entry entry : entityMap.entrySet()) { Entity sourceEntity = entry.getValue(); for (Relation relation : sourceEntity.relations()) { if (relation.targetEntityIdentifiers().contains(targetIdentifier)) { sourcesByRelationName - .computeIfAbsent(relation.name(), k -> new java.util.ArrayList<>()) + .computeIfAbsent(relation.name(), k -> new ArrayList<>()) .add(entry.getKey()); } } @@ -147,7 +162,8 @@ private List buildRelationsAsTargetFromMap(String targetIde .map(e -> new EntityGraphRelation( e.getKey(), e.getValue().stream() - .map(sourceKey -> buildGraphNode(sourceKey, entityMap, remainingDepth, includeProperties)) + .map(sourceKey -> buildGraphNode(sourceKey, entityMap, remainingDepth, + includeProperties, visitedNodeIds)) .toList() )) .toList(); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java index 19124e58..ddd780af 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java @@ -9,9 +9,9 @@ public record CorsProperties( List allowedOrigins ) { + /// Compact constructor: normalises null to empty and defensively copies to prevent + /// external mutation of the configuration list (EI_EXPOSE_REP2 / EI_EXPOSE_REP). public CorsProperties { - if (allowedOrigins == null) { - allowedOrigins = List.of(); - } + allowedOrigins = allowedOrigins == null ? List.of() : List.copyOf(allowedOrigins); } } 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 23e72bde..19a06f41 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 @@ -166,4 +166,5 @@ public class SwaggerDescription { public static final String ENTITY_GRAPH_FLAT_EDGE_TYPE_DESCRIPTION = "Relation name as defined in the entity template"; public static final String ENTITY_GRAPH_FLAT_NODE_DATA_DESCRIPTION = "Entity property values keyed by property name; present only when include_data=true is requested"; public static final String PARAM_INCLUDE_DATA_DESCRIPTION = "When true, each graph node includes a data object containing the entity's property values. Defaults to false."; + public static final String PARAM_RELATIONS_DESCRIPTION = "When provided, only relations whose name matches one of the listed values are traversed and included. Omit to include all relations."; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java index ee8f58a4..c71b88ea 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java @@ -6,10 +6,14 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.OK_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_DEPTH_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_INCLUDE_DATA_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_RELATIONS_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_GRAPH_FLAT_SUCCESS; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER; import static org.springframework.http.HttpStatus.OK; +import java.util.List; +import java.util.Set; + import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; @@ -52,9 +56,10 @@ public class EntityGraphController { /// Cytoscape, and similar frontend graph visualization libraries. /// /// @param templateIdentifier the template identifier of the root entity - /// @param entityIdentifier the business identifier of the root entity - /// @param depth the maximum traversal depth (default 1, clamped between 1 and 10) - /// @param includeData when true, each node includes a data object with entity property values + /// @param entityIdentifier the business identifier of the root entity + /// @param depth the maximum traversal depth (default 1, clamped between 1 and 10) + /// @param includeData when true, each node includes a data object with entity property values + /// @param relations when provided, only relations with matching names are included /// @return flat DTO containing nodes and edges arrays @GetMapping("/{templateIdentifier}/{entityIdentifier}/graph") @ResponseStatus(OK) @@ -74,11 +79,16 @@ public EntityGraphFlatDtoOut getEntityGraph( @Parameter(description = PARAM_DEPTH_DESCRIPTION) @RequestParam(defaultValue = "1") int depth, @Parameter(description = PARAM_INCLUDE_DATA_DESCRIPTION) - @RequestParam(defaultValue = "false") boolean includeData) { + @RequestParam(defaultValue = "false") boolean includeData, + @Parameter(description = PARAM_RELATIONS_DESCRIPTION) + @RequestParam(required = false) List relations) { + + // Convert the nullable list to a Set for O(1) lookup; empty set means no filter + Set relationFilter = relations != null ? Set.copyOf(relations) : Set.of(); EntityGraphNode graphNode = entityGraphService.getEntityGraph( templateIdentifier, entityIdentifier, depth, includeData); - return EntityGraphFlatDtoOutMapper.toFlatDto(graphNode); + return EntityGraphFlatDtoOutMapper.toFlatDto(graphNode, relationFilter); } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java new file mode 100644 index 00000000..e69de29b diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java index 0ae498b4..c1fa208c 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java @@ -40,4 +40,10 @@ public record EntityGraphNodeFlatDtoOut( @JsonInclude(Include.NON_EMPTY) @Schema(description = ENTITY_GRAPH_FLAT_NODE_DATA_DESCRIPTION) Map data -) {} +) { + /// Compact constructor: defensively copies the data map to prevent external mutation + /// of the DTO after construction (EI_EXPOSE_REP2 / EI_EXPOSE_REP). + public EntityGraphNodeFlatDtoOut { + data = data == null ? Map.of() : Map.copyOf(data); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java new file mode 100644 index 00000000..e69de29b diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java new file mode 100644 index 00000000..e69de29b diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java index c7ca7a40..3bf9932f 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java @@ -38,9 +38,14 @@ private EntityGraphFlatDtoOutMapper() { /// Maps a domain graph node tree to a flat [EntityGraphFlatDtoOut]. /// - /// @param root the root [EntityGraphNode] returned by the domain service + /// @param root the root [EntityGraphNode] returned by the domain service + /// @param relationFilter when non-empty, only edges whose type is in this set are emitted, + /// and nodes not referenced by any remaining edge are pruned from the + /// result (except the root, which is always included); + /// an empty set means no filter — all edge types and nodes are emitted /// @return flat DTO with deduplicated nodes and directed edges - public static EntityGraphFlatDtoOut toFlatDto(EntityGraphNode root) { if (root == null) { + public static EntityGraphFlatDtoOut toFlatDto(EntityGraphNode root, Set relationFilter) { + if (root == null) { return new EntityGraphFlatDtoOut(List.of(), List.of()); } @@ -54,9 +59,30 @@ public static EntityGraphFlatDtoOut toFlatDto(EntityGraphNode root) { if Set emittedEdgeSignatures = new HashSet<>(); var edgeCounter = new AtomicInteger(0); - traverse(root, nodes, edges, visitedNodeIds, emittedEdgeSignatures, edgeCounter); + traverse(root, nodes, edges, visitedNodeIds, emittedEdgeSignatures, edgeCounter, relationFilter); + + // When a relation filter is active, prune nodes that are not connected to any + // remaining edge. The root is always kept. Without this step, nodes reachable via + // non-filtered edges (e.g. C via "depends-on" when filtering "monitors") would + // appear in the node list despite having no visible edges. + List finalNodes; + if (relationFilter.isEmpty()) { + finalNodes = List.copyOf(nodes); + } else { + // Collect all node IDs referenced by the filtered edges only. + // The root receives no special treatment: if it has no matching edges + // it is pruned just like any other disconnected node. + Set referencedNodeIds = new HashSet<>(); + for (var edge : edges) { + referencedNodeIds.add(edge.source()); + referencedNodeIds.add(edge.target()); + } + finalNodes = nodes.stream() + .filter(n -> referencedNodeIds.contains(n.id())) + .toList(); + } - return new EntityGraphFlatDtoOut(List.copyOf(nodes), List.copyOf(edges)); + return new EntityGraphFlatDtoOut(finalNodes, List.copyOf(edges)); } private static void traverse( @@ -65,7 +91,8 @@ private static void traverse( List edges, Set visitedNodeIds, Set emittedEdgeSignatures, - AtomicInteger edgeCounter) { + AtomicInteger edgeCounter, + Set relationFilter) { var nodeId = nodeId(node.templateIdentifier(), node.identifier()); @@ -78,12 +105,16 @@ private static void traverse( nodeId, node.name(), node.templateIdentifier(), node.identifier(), toDataMap(node))); - // Traverse outbound relations: emit edge from currentNode → target + // Traverse outbound relations: emit edge from currentNode → target only when the + // relation type matches the filter (or no filter is active). Nodes are always + // traversed so that deeper nodes remain reachable regardless of edge visibility. for (EntityGraphRelation relation : node.relations()) { for (EntityGraphNode target : relation.targets()) { var targetId = nodeId(target.templateIdentifier(), target.identifier()); - addEdge(edges, emittedEdgeSignatures, edgeCounter, nodeId, targetId, relation.name()); - traverse(target, nodes, edges, visitedNodeIds, emittedEdgeSignatures, edgeCounter); + if (relationFilter.isEmpty() || relationFilter.contains(relation.name())) { + addEdge(edges, emittedEdgeSignatures, edgeCounter, nodeId, targetId, relation.name()); + } + traverse(target, nodes, edges, visitedNodeIds, emittedEdgeSignatures, edgeCounter, relationFilter); } } @@ -93,8 +124,10 @@ private static void traverse( for (EntityGraphRelation relation : node.relationsAsTarget()) { for (EntityGraphNode source : relation.targets()) { var sourceId = nodeId(source.templateIdentifier(), source.identifier()); - addEdge(edges, emittedEdgeSignatures, edgeCounter, sourceId, nodeId, relation.name()); - traverse(source, nodes, edges, visitedNodeIds, emittedEdgeSignatures, edgeCounter); + if (relationFilter.isEmpty() || relationFilter.contains(relation.name())) { + addEdge(edges, emittedEdgeSignatures, edgeCounter, sourceId, nodeId, relation.name()); + } + traverse(source, nodes, edges, visitedNodeIds, emittedEdgeSignatures, edgeCounter, relationFilter); } } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java index deeb2b68..d48828c7 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java @@ -6,6 +6,7 @@ import java.util.stream.Collectors; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntityCompositeKey; @@ -34,12 +35,17 @@ public class PostgresEntityGraphAdapter implements EntityGraphRepositoryPort { private final EntityPersistenceMapper mapper; @Override + @Transactional(readOnly = true) public Map findEntityGraph( String templateIdentifier, String entityIdentifier, int depth, boolean includeProperties) { - // Step 1: collect all (identifier, template_identifier) pairs via recursive CTE + // Step 1: collect all (identifier, template_identifier) pairs via recursive CTE. + // The CTE always traverses ALL relation types to discover all reachable nodes. + // Relation name filtering is applied at the service level when building edges, + // so nodes reachable via any path are included even if the filter only matches + // edges at deeper levels (e.g. filtering "owns" still returns B→C when A→B→C). List graphPairs = jpaEntityRepository.findEntityGraphIdentifiers( templateIdentifier, entityIdentifier, depth); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java index fb94167f..8be0fa74 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java @@ -116,4 +116,56 @@ List findEntityGraphIdentifiers( @Param("templateIdentifier") String templateIdentifier, @Param("entityIdentifier") String entityIdentifier, @Param("depth") int depth); + + /// Variant of [findEntityGraphIdentifiers] that restricts traversal to the given relation names. + /// When the list is empty, all relation names are followed (no filter). + /// The filter is applied inside both the outbound and inbound recursive CTE steps so that only + /// entities reachable through the specified relations are returned, keeping the result set lean. + @Query(value = """ + WITH RECURSIVE + outbound_graph(identifier, template_identifier, depth) AS ( + SELECT e.identifier, e.template_identifier, 0 + FROM entity e + WHERE e.identifier = :entityIdentifier + AND e.template_identifier = :templateIdentifier + + UNION ALL + + SELECT e2.identifier, e2.template_identifier, og.depth + 1 + FROM outbound_graph og + JOIN entity e ON e.identifier = og.identifier AND e.template_identifier = og.template_identifier + JOIN entity_relations er ON er.entity_id = e.id + JOIN relation r ON r.id = er.relation_id + JOIN relation_target_entities rte ON rte.relation_id = r.id + JOIN entity e2 ON e2.identifier = rte.target_entity_identifier + WHERE og.depth < :depth + AND r.name IN :relationNames + ), + inbound_graph(identifier, template_identifier, depth) AS ( + SELECT e.identifier, e.template_identifier, 0 + FROM entity e + WHERE e.identifier = :entityIdentifier + AND e.template_identifier = :templateIdentifier + + UNION ALL + + SELECT e2.identifier, e2.template_identifier, ig.depth + 1 + FROM inbound_graph ig + JOIN entity e ON e.identifier = ig.identifier AND e.template_identifier = ig.template_identifier + JOIN relation_target_entities rte ON rte.target_entity_identifier = e.identifier + JOIN relation r ON r.id = rte.relation_id + JOIN entity_relations er ON er.relation_id = r.id + JOIN entity e2 ON e2.id = er.entity_id + WHERE ig.depth < :depth + AND r.name IN :relationNames + ) + SELECT DISTINCT identifier, template_identifier FROM outbound_graph + UNION + SELECT DISTINCT identifier, template_identifier FROM inbound_graph + """, nativeQuery = true) + List findEntityGraphIdentifiersFilteredByRelations( + @Param("templateIdentifier") String templateIdentifier, + @Param("entityIdentifier") String entityIdentifier, + @Param("depth") int depth, + @Param("relationNames") Collection relationNames); } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java index 7dbaf9d0..0f6b50f2 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java @@ -2,8 +2,11 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import java.util.List; @@ -24,6 +27,7 @@ import com.decathlon.idp_core.domain.model.entity.EntityCompositeKey; import com.decathlon.idp_core.domain.model.entity.Relation; import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphRelation; import com.decathlon.idp_core.domain.port.EntityGraphRepositoryPort; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; @@ -31,12 +35,6 @@ @DisplayName("EntityGraphService Tests") class EntityGraphServiceTest { - private static final String TEMPLATE = "web-service"; - private static final String DB_TEMPLATE = "database"; - private static final String CACHE_TEMPLATE = "cache"; - private static final String INFRA_TEMPLATE = "infrastructure"; - private static final int DEFAULT_DEPTH = 3; - @Mock private EntityRepositoryPort entityRepositoryPort; @@ -57,16 +55,26 @@ private Entity entityWithRelations(String templateIdentifier, String identifier, return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, List.of(), relations); } - private Relation relation(String name, String targetTemplateIdentifier, List targetIdentifiers) { - return new Relation(UUID.randomUUID(), name, targetTemplateIdentifier, targetIdentifiers); + private Relation relation(String name, String targetTemplateIdentifier, String... targetIds) { + return new Relation(UUID.randomUUID(), name, targetTemplateIdentifier, List.of(targetIds)); } private EntityCompositeKey key(String templateIdentifier, String identifier) { return new EntityCompositeKey(templateIdentifier, identifier); } + private static final String TEMPLATE = "web-service"; + + // --- Helper to stub both ports --- + + private void stubGraph(Map entityMap) { + when(entityGraphRepositoryPort.findEntityGraph(anyString(), anyString(), anyInt(), anyBoolean())) + .thenReturn(entityMap); + } + + // ======================== @Nested - @DisplayName("getEntityGraph — root entity not found") + @DisplayName("Root Entity Not Found") class RootEntityNotFound { @Test @@ -75,29 +83,28 @@ void shouldThrowWhenRootEntityNotFound() { when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "missing")) .thenReturn(Optional.empty()); - assertThatThrownBy(() -> entityGraphService.getEntityGraph(TEMPLATE, "missing", DEFAULT_DEPTH, false)) + assertThatThrownBy(() -> entityGraphService.getEntityGraph(TEMPLATE, "missing", 1, false)) .isInstanceOf(EntityNotFoundException.class); - verify(entityRepositoryPort).findByTemplateIdentifierAndIdentifier(TEMPLATE, "missing"); - verifyNoInteractions(entityGraphRepositoryPort); + verify(entityGraphRepositoryPort, never()) + .findEntityGraph(anyString(), anyString(), anyInt(), anyBoolean()); } } + // ======================== @Nested - @DisplayName("getEntityGraph — single root, no relations") + @DisplayName("Single Root — No Relations") class SingleRootNoRelations { @Test - @DisplayName("Should return a leaf node when entity has no relations") + @DisplayName("Should return leaf node when entity has no relations") void shouldReturnLeafNodeWhenNoRelations() { - var root = entity(TEMPLATE, "api", "API Service"); - + Entity api = entity(TEMPLATE, "api", "API Service"); when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) - .thenReturn(Optional.of(root)); - when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false)) - .thenReturn(Map.of(key(TEMPLATE, "api"), root)); + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api)); - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); assertThat(result.identifier()).isEqualTo("api"); assertThat(result.name()).isEqualTo("API Service"); @@ -106,94 +113,88 @@ void shouldReturnLeafNodeWhenNoRelations() { } } + // ======================== @Nested - @DisplayName("getEntityGraph — outbound relations") + @DisplayName("Outbound Relations") class OutboundRelations { @Test - @DisplayName("Should resolve outbound relations to graph nodes") + @DisplayName("Should resolve outbound relation targets at depth 1") void shouldResolveOutboundRelations() { - var db = entity(DB_TEMPLATE, "postgres", "Postgres DB"); - var api = entityWithRelations(TEMPLATE, "api", "API Service", - List.of(relation("uses", DB_TEMPLATE, List.of("postgres")))); + Entity api = entityWithRelations(TEMPLATE, "api", "API Service", + List.of(relation("uses-db", "database", "postgres"))); + Entity postgres = entity("database", "postgres", "Postgres DB"); when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(api)); - when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false)) - .thenReturn(Map.of( - key(TEMPLATE, "api"), api, - key(DB_TEMPLATE, "postgres"), db - )); + stubGraph(Map.of( + key(TEMPLATE, "api"), api, + key("database", "postgres"), postgres)); - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); assertThat(result.relations()).hasSize(1); - assertThat(result.relations().getFirst().name()).isEqualTo("uses"); - assertThat(result.relations().getFirst().targets()).hasSize(1); - assertThat(result.relations().getFirst().targets().getFirst().identifier()).isEqualTo("postgres"); + assertThat(result.relations().get(0).name()).isEqualTo("uses-db"); + assertThat(result.relations().get(0).targets()).hasSize(1); + assertThat(result.relations().get(0).targets().get(0).identifier()).isEqualTo("postgres"); } @Test - @DisplayName("Should create a fallback node when relation target is not in the graph map") + @DisplayName("Should return fallback node when target is not in the pre-loaded entity map") void shouldReturnFallbackNodeWhenTargetNotInMap() { - // Simulates a target entity outside the loaded depth — still produces a placeholder node - var api = entityWithRelations(TEMPLATE, "api", "API Service", - List.of(relation("uses", DB_TEMPLATE, List.of("unknown-db")))); + Entity api = entityWithRelations(TEMPLATE, "api", "API Service", + List.of(relation("uses-db", "database", "missing-db"))); when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(api)); - when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false)) - .thenReturn(Map.of(key(TEMPLATE, "api"), api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api)); - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); assertThat(result.relations()).hasSize(1); - // Fallback node uses identifier as both id and name when entity is not in map - assertThat(result.relations().getFirst().targets().getFirst().identifier()).isEqualTo("unknown-db"); + EntityGraphNode fallback = result.relations().get(0).targets().get(0); + assertThat(fallback.identifier()).isEqualTo("missing-db"); } } + // ======================== @Nested - @DisplayName("getEntityGraph — inbound relations") + @DisplayName("Inbound Relations (relationsAsTarget)") class InboundRelations { @Test - @DisplayName("Should resolve inbound relations for entities that are targeted by others") + @DisplayName("Should resolve inbound relations when another entity points to root") void shouldResolveInboundRelations() { - var db = entity(DB_TEMPLATE, "postgres", "Postgres DB"); - var api = entityWithRelations(TEMPLATE, "api", "API Service", - List.of(relation("uses", DB_TEMPLATE, List.of("postgres")))); + Entity api = entity(TEMPLATE, "api", "API Service"); + Entity consumer = entityWithRelations(TEMPLATE, "consumer", "Consumer", + List.of(relation("depends-on", TEMPLATE, "api"))); - when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(DB_TEMPLATE, "postgres")) - .thenReturn(Optional.of(db)); - when(entityGraphRepositoryPort.findEntityGraph(DB_TEMPLATE, "postgres", DEFAULT_DEPTH, false)) - .thenReturn(Map.of( - key(TEMPLATE, "api"), api, - key(DB_TEMPLATE, "postgres"), db - )); + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(Map.of( + key(TEMPLATE, "api"), api, + key(TEMPLATE, "consumer"), consumer)); - EntityGraphNode result = entityGraphService.getEntityGraph(DB_TEMPLATE, "postgres", DEFAULT_DEPTH, false); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); - // postgres is targeted by api via "uses" assertThat(result.relationsAsTarget()).hasSize(1); - assertThat(result.relationsAsTarget().getFirst().name()).isEqualTo("uses"); - assertThat(result.relationsAsTarget().getFirst().targets().getFirst().identifier()).isEqualTo("api"); + assertThat(result.relationsAsTarget().get(0).name()).isEqualTo("depends-on"); + assertThat(result.relationsAsTarget().get(0).targets().get(0).identifier()).isEqualTo("consumer"); } } + // ======================== @Nested - @DisplayName("getEntityGraph — depth clamping") + @DisplayName("Depth Clamping") class DepthClamping { @Test @DisplayName("Should clamp depth below 1 to 1") void shouldClampDepthBelowOne() { - var root = entity(TEMPLATE, "api", "API Service"); - + Entity api = entity(TEMPLATE, "api", "API Service"); when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) - .thenReturn(Optional.of(root)); - when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", 1, false)) - .thenReturn(Map.of(key(TEMPLATE, "api"), root)); + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api)); entityGraphService.getEntityGraph(TEMPLATE, "api", 0, false); @@ -201,14 +202,12 @@ void shouldClampDepthBelowOne() { } @Test - @DisplayName("Should clamp depth above 10 to 10") + @DisplayName("Should clamp depth above MAX_DEPTH to MAX_DEPTH") void shouldClampDepthAboveTen() { - var root = entity(TEMPLATE, "api", "API Service"); - + Entity api = entity(TEMPLATE, "api", "API Service"); when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) - .thenReturn(Optional.of(root)); - when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", 10, false)) - .thenReturn(Map.of(key(TEMPLATE, "api"), root)); + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api)); entityGraphService.getEntityGraph(TEMPLATE, "api", 99, false); @@ -216,67 +215,164 @@ void shouldClampDepthAboveTen() { } } + // ======================== @Nested - @DisplayName("getEntityGraph — depth limit stops recursion") + @DisplayName("Depth Limit — Leaf Nodes at Boundary") class DepthLimit { @Test - @DisplayName("Should return a leaf node for targets at the depth boundary") + @DisplayName("Should return target as leaf node when depth limit is reached") void shouldReturnLeafNodeAtDepthBoundary() { - // api --uses--> postgres --runs-on--> server-1 - // At depth=1: postgres node is resolved but its own relations are NOT expanded - var server = entity(INFRA_TEMPLATE, "server-1", "Server 1"); - var db = entityWithRelations(DB_TEMPLATE, "postgres", "Postgres DB", - List.of(relation("runs-on", INFRA_TEMPLATE, List.of("server-1")))); - var api = entityWithRelations(TEMPLATE, "api", "API Service", - List.of(relation("uses", DB_TEMPLATE, List.of("postgres")))); + Entity api = entityWithRelations(TEMPLATE, "api", "API Service", + List.of(relation("uses-db", "database", "postgres"))); + Entity postgres = entityWithRelations("database", "postgres", "Postgres DB", + List.of(relation("runs-on", "infra", "server-1"))); + Entity server = entity("infra", "server-1", "Server 1"); when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(api)); - when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", 1, false)) - .thenReturn(Map.of( - key(TEMPLATE, "api"), api, - key(DB_TEMPLATE, "postgres"), db, - key(INFRA_TEMPLATE, "server-1"), server - )); + stubGraph(Map.of( + key(TEMPLATE, "api"), api, + key("database", "postgres"), postgres, + key("infra", "server-1"), server)); EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); - // postgres node is included but its child relations are empty (remaining depth = 0) - var dbNode = result.relations().getFirst().targets().getFirst(); - assertThat(dbNode.identifier()).isEqualTo("postgres"); - assertThat(dbNode.relations()).isEmpty(); + EntityGraphNode postgresNode = result.relations().get(0).targets().get(0); + assertThat(postgresNode.identifier()).isEqualTo("postgres"); + // At depth=1, postgres is a leaf — no further relations resolved + assertThat(postgresNode.relations()).isEmpty(); + assertThat(postgresNode.relationsAsTarget()).isEmpty(); } } + // ======================== @Nested - @DisplayName("getEntityGraph — multiple outbound relations") + @DisplayName("Multiple Named Relations") class MultipleRelations { @Test - @DisplayName("Should resolve multiple named relation types correctly") + @DisplayName("Should resolve multiple distinct relation types") void shouldResolveMultipleNamedRelations() { - var db = entity(DB_TEMPLATE, "postgres", "Postgres DB"); - var cache = entity(CACHE_TEMPLATE, "redis", "Redis Cache"); - var api = entityWithRelations(TEMPLATE, "api", "API Service", List.of( - relation("uses-db", DB_TEMPLATE, List.of("postgres")), - relation("uses-cache", CACHE_TEMPLATE, List.of("redis")) - )); + Entity api = entityWithRelations(TEMPLATE, "api", "API Service", List.of( + relation("uses-db", "database", "postgres"), + relation("depends-on", TEMPLATE, "auth"))); + Entity postgres = entity("database", "postgres", "Postgres DB"); + Entity auth = entity(TEMPLATE, "auth", "Auth Service"); when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(api)); - when(entityGraphRepositoryPort.findEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false)) - .thenReturn(Map.of( - key(TEMPLATE, "api"), api, - key(DB_TEMPLATE, "postgres"), db, - key(CACHE_TEMPLATE, "redis"), cache - )); + stubGraph(Map.of( + key(TEMPLATE, "api"), api, + key("database", "postgres"), postgres, + key(TEMPLATE, "auth"), auth)); - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", DEFAULT_DEPTH, false); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); assertThat(result.relations()).hasSize(2); - var relationNames = result.relations().stream().map(r -> r.name()).toList(); - assertThat(relationNames).containsExactlyInAnyOrder("uses-db", "uses-cache"); + assertThat(result.relations().stream().map(EntityGraphRelation::name)) + .containsExactlyInAnyOrder("uses-db", "depends-on"); + } + } + + // ======================== + @Nested + @DisplayName("Full Graph Returned — Filtering Is a Mapper Concern") + class FullGraphReturned { + + @Test + @DisplayName("Should return all edges regardless of relation type (no filtering in service)") + void shouldReturnAllEdgesWithoutFiltering() { + // A --(depends-on)--> B --(owns)--> C + // The service must return both edges — the mapper will filter them. + Entity a = entityWithRelations(TEMPLATE, "a", "A", + List.of(relation("depends-on", TEMPLATE, "b"))); + Entity b = entityWithRelations(TEMPLATE, "b", "B", + List.of(relation("owns", TEMPLATE, "c"))); + Entity c = entity(TEMPLATE, "c", "C"); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "a")) + .thenReturn(Optional.of(a)); + stubGraph(Map.of( + key(TEMPLATE, "a"), a, + key(TEMPLATE, "b"), b, + key(TEMPLATE, "c"), c)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 2, false); + + // Root A has one outbound "depends-on" edge → B + assertThat(result.relations()).hasSize(1); + assertThat(result.relations().get(0).name()).isEqualTo("depends-on"); + + // B (at depth 1) has one outbound "owns" edge → C + EntityGraphNode nodeB = result.relations().get(0).targets().get(0); + assertThat(nodeB.identifier()).isEqualTo("b"); + assertThat(nodeB.relations()).hasSize(1); + assertThat(nodeB.relations().get(0).name()).isEqualTo("owns"); + assertThat(nodeB.relations().get(0).targets().get(0).identifier()).isEqualTo("c"); + + verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "a", 2, false); + } + } + + // ======================== + @Nested + @DisplayName("Visited Node Guard — OOM Prevention") + class VisitedNodeGuard { + + @Test + @DisplayName("Should complete at depth=10 without exponential recursion for a small graph") + void shouldNotExplodeAtMaxDepthWithSmallGraph() { + // A --(uses)--> B --(uses)--> C; B also has inbound from A and C has inbound from B. + // Without the visited-node guard this produces O(2^depth) calls at depth=10. + Entity a = entityWithRelations(TEMPLATE, "a", "A", + List.of(relation("uses", TEMPLATE, "b"))); + Entity b = entityWithRelations(TEMPLATE, "b", "B", + List.of(relation("uses", TEMPLATE, "c"))); + Entity c = entity(TEMPLATE, "c", "C"); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "a")) + .thenReturn(Optional.of(a)); + stubGraph(Map.of( + key(TEMPLATE, "a"), a, + key(TEMPLATE, "b"), b, + key(TEMPLATE, "c"), c)); + + // Must complete instantly — any OOM or StackOverflow here means the guard is missing. + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 10, false); + + assertThat(result.identifier()).isEqualTo("a"); + assertThat(result.relations()).hasSize(1); + } + + @Test + @DisplayName("Should return stub leaf for already-visited node instead of re-expanding it") + void shouldReturnStubLeafForRevisitedNode() { + // A --(uses)--> B; B also points back to A (cycle: A→B→A) + Entity a = entityWithRelations(TEMPLATE, "a", "A", + List.of(relation("uses", TEMPLATE, "b"))); + Entity b = entityWithRelations(TEMPLATE, "b", "B", + List.of(relation("uses", TEMPLATE, "a"))); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "a")) + .thenReturn(Optional.of(a)); + stubGraph(Map.of( + key(TEMPLATE, "a"), a, + key(TEMPLATE, "b"), b)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 5, false); + + // A → B is resolved + assertThat(result.relations()).hasSize(1); + EntityGraphNode nodeB = result.relations().get(0).targets().get(0); + assertThat(nodeB.identifier()).isEqualTo("b"); + + // B → A is a revisit: A was already marked visited, so it returns a stub leaf + // with no further outbound or inbound relations (no infinite loop). + EntityGraphNode stubA = nodeB.relations().get(0).targets().get(0); + assertThat(stubA.identifier()).isEqualTo("a"); + assertThat(stubA.relations()).isEmpty(); + assertThat(stubA.relationsAsTarget()).isEmpty(); } } } 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 ccee22dc..53f5b15a 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 @@ -48,8 +48,8 @@ void getEntities_paginated_200() throws Exception { .andExpect(status().isOk()) .andExpect(content().contentType(APPLICATION_JSON)) .andExpect(jsonPath("$.content").isArray()) - .andExpect(jsonPath("$.content.length()").value(2)) - .andExpect(jsonPath("$.page.total_elements").value(2)) + .andExpect(jsonPath("$.content.length()").value(5)) + .andExpect(jsonPath("$.page.total_elements").value(5)) .andExpect(jsonPath("$.page.total_pages").value(1)) .andExpect(jsonPath("$.page.size").value(15)) .andExpect(jsonPath("$.page.number").value(0)) @@ -102,8 +102,8 @@ void getEntities_invalid_pagination_200() throws Exception { .andExpect(status().isOk()) .andExpect(content().contentType(APPLICATION_JSON)) .andExpect(jsonPath("$.content").isArray()) - .andExpect(jsonPath("$.content.length()").value(2)) - .andExpect(jsonPath("$.page.total_elements").value(2)) + .andExpect(jsonPath("$.content.length()").value(5)) + .andExpect(jsonPath("$.page.total_elements").value(5)) .andExpect(jsonPath("$.page.total_pages").value(1)) .andExpect(jsonPath("$.page.size").value(20)) .andExpect(jsonPath("$.page.number").value(0)) diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java new file mode 100644 index 00000000..d2ef939a --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java @@ -0,0 +1,155 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.controller; + +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +import com.decathlon.idp_core.AbstractIntegrationTest; + +/// Integration tests for the EntityGraphController REST API endpoint. +/// +/// Tests are based on the three-node chain seeded in R__2_Insert_entities_test_data.sql: +/// +/// graph-svc-a --[uses]--> graph-svc-b --[uses]--> graph-svc-c +/// graph-svc-a --[monitors]--> graph-svc-b +/// +/// Key scenarios verified: +/// +/// - No filter: all nodes and edges are returned +/// - Filter "uses": full chain traversed (a→b→c), "monitors" edge excluded at every depth +/// - Filter "monitors": only a→b returned; c is unreachable via "monitors" edges +/// - 404 for unknown entity +/// - 401 without authentication +@DisplayName("GET /api/v1/entities/{templateIdentifier}/{entityIdentifier}/graph") +public class EntityGraphControllerTest extends AbstractIntegrationTest { + + private static final String GRAPH_PATH = "/api/v1/entities/{templateId}/{entityId}/graph"; + private static final String TEMPLATE = "web-service"; + private static final String ENTITY_A = "graph-svc-a"; + private static final String ENTITY_B = "graph-svc-b"; + private static final String ENTITY_C = "graph-svc-c"; + + @Autowired + private MockMvc mockMvc; + + @Nested + @DisplayName("Without relation filter") + class NoFilter { + + @Test + @WithMockUser + @DisplayName("Should return all nodes and edges when no filter is applied (depth=3)") + void shouldReturnAllNodesAndEdgesWithNoFilter() throws Exception { + mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A) + .param("depth", "3") + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(APPLICATION_JSON)) + // All three nodes must be present + .andExpect(jsonPath("$.nodes[*].identifier", + containsInAnyOrder(ENTITY_A, ENTITY_B, ENTITY_C))) + // Three edges: a-[uses]->b, a-[monitors]->b, b-[uses]->c + .andExpect(jsonPath("$.edges", hasSize(3))); + } + } + + @Nested + @DisplayName("With 'uses' relation filter") + class UsesFilter { + + @Test + @WithMockUser + @DisplayName("Should traverse full chain via 'uses' edges and exclude 'monitors' edge (depth=3)") + void shouldTraverseFullChainWithUsesFilter() throws Exception { + mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A) + .param("depth", "3") + .param("relations", "uses") + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(APPLICATION_JSON)) + // All three nodes are reachable via "uses" chain: a→b→c + .andExpect(jsonPath("$.nodes[*].identifier", + containsInAnyOrder(ENTITY_A, ENTITY_B, ENTITY_C))) + // Only the two "uses" edges: a-[uses]->b and b-[uses]->c + .andExpect(jsonPath("$.edges", hasSize(2))) + .andExpect(jsonPath("$.edges[*].type", + containsInAnyOrder("uses", "uses"))); + } + + @Test + @WithMockUser + @DisplayName("Should still reach graph-svc-c at depth 2 when filtering by 'uses'") + void shouldReachNodeCAtDepthTwoWithUsesFilter() throws Exception { + // This specifically verifies that the filter applies recursively: + // at depth=2, a→b (level 1) and b→c (level 2) must both be traversed. + mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A) + .param("depth", "2") + .param("relations", "uses") + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.nodes[*].identifier", + containsInAnyOrder(ENTITY_A, ENTITY_B, ENTITY_C))) + .andExpect(jsonPath("$.edges", hasSize(2))); + } + } + + @Nested + @DisplayName("With 'monitors' relation filter") + class MonitorsFilter { + + @Test + @WithMockUser + @DisplayName("Should return only graph-svc-a and graph-svc-b when filtering by 'monitors' (depth=3)") + void shouldReturnOnlyRootAndDirectTargetWithMonitorsFilter() throws Exception { + // "monitors" only exists at level 1 (a→b). Since b has no "monitors" edges, + // graph-svc-c must NOT appear in the result. + mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A) + .param("depth", "3") + .param("relations", "monitors") + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(APPLICATION_JSON)) + // Only a and b — c is unreachable via "monitors" + .andExpect(jsonPath("$.nodes", hasSize(2))) + .andExpect(jsonPath("$.nodes[*].identifier", + containsInAnyOrder(ENTITY_A, ENTITY_B))) + // One edge only: a-[monitors]->b + .andExpect(jsonPath("$.edges", hasSize(1))) + .andExpect(jsonPath("$.edges[0].type").value("monitors")); + } + } + + @Nested + @DisplayName("Error cases") + class ErrorCases { + + @Test + @WithMockUser + @DisplayName("Should return 404 when entity does not exist") + void shouldReturn404ForUnknownEntity() throws Exception { + mockMvc.perform(get(GRAPH_PATH, TEMPLATE, "non-existent-entity") + .accept(APPLICATION_JSON)) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("Should return 401 without authentication") + void shouldReturn401WithoutAuthentication() throws Exception { + mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A) + .accept(APPLICATION_JSON)) + .andExpect(status().isUnauthorized()); + } + } +} diff --git a/src/test/resources/db/test/R__2_Insert_entities_test_data.sql b/src/test/resources/db/test/R__2_Insert_entities_test_data.sql index 45e62ff0..d80b4d5d 100644 --- a/src/test/resources/db/test/R__2_Insert_entities_test_data.sql +++ b/src/test/resources/db/test/R__2_Insert_entities_test_data.sql @@ -1,5 +1,5 @@ -- Insert sample entities into idp_core.entity -INSERT INTO idp_core.entity (id, identifier, name, template_identifier) +INSERT INTO entity (id, identifier, name, template_identifier) VALUES ('550e8400-e29b-41d4-a716-446655440100', 'web-api-1', 'Web API 1', 'web-service'), ('550e8400-e29b-41d4-a716-446655440101', 'web-api-2', 'Web API 2', 'web-service'), @@ -16,3 +16,51 @@ VALUES ('550e8400-e29b-41d4-a716-446655440112', 'monitoring-service-4', 'Monitoring Service 4', 'monitoring-service'), ('550e8400-e29b-41d4-a716-446655440113', 'monitoring-service-5', 'Monitoring Service 5', 'monitoring-service'), ('550e8400-e29b-41d4-a716-446655440114', 'monitoring-service-6', 'Monitoring Service 6', 'monitoring-service'); + +-- ----------------------------------------------------------------------- +-- Graph test data: 3-level chain of entities connected via two relation +-- types ("uses" and "monitors") for integration testing of the graph API. +-- +-- Graph topology (depth-3 chain): +-- graph-svc-a --[uses]--> graph-svc-b --[uses]--> graph-svc-c +-- graph-svc-a --[monitors]--> graph-svc-b +-- +-- This setup allows us to verify: +-- 1. Graph traversal works at all depths (not just root level) +-- 2. Relation name filtering excludes the correct edges/nodes at every depth +-- 3. "uses" filter returns: a → b → c (2 edges, 3 nodes) +-- 4. "monitors" filter returns: a → b (1 edge, 2 nodes; c not reachable) +-- ----------------------------------------------------------------------- + +-- Entities (all use the 'web-service' template which exists in test data) +-- UUIDs use only valid hex characters (0-9, a-f) +INSERT INTO entity (id, identifier, name, template_identifier) +VALUES + ('aa000001-0000-0000-0000-000000000001', 'graph-svc-a', 'Graph Service A', 'web-service'), + ('aa000001-0000-0000-0000-000000000002', 'graph-svc-b', 'Graph Service B', 'web-service'), + ('aa000001-0000-0000-0000-000000000003', 'graph-svc-c', 'Graph Service C', 'web-service'); + +-- Relations owned by graph-svc-a: "uses" → b, "monitors" → b +INSERT INTO relation (id, name, target_template_identifier) +VALUES + ('bb000001-0000-0000-0000-000000000001', 'uses', 'web-service'), + ('bb000001-0000-0000-0000-000000000002', 'monitors', 'web-service'); + +-- Relation owned by graph-svc-b: "uses" → c +INSERT INTO relation (id, name, target_template_identifier) +VALUES + ('bb000002-0000-0000-0000-000000000001', 'uses', 'web-service'); + +-- Target entity identifiers for each relation +INSERT INTO relation_target_entities (relation_id, target_entity_identifier) +VALUES + ('bb000001-0000-0000-0000-000000000001', 'graph-svc-b'), -- a -[uses]-> b + ('bb000001-0000-0000-0000-000000000002', 'graph-svc-b'), -- a -[monitors]-> b + ('bb000002-0000-0000-0000-000000000001', 'graph-svc-c'); -- b -[uses]-> c + +-- Link relations to their owner entities +INSERT INTO entity_relations (entity_id, relation_id) +VALUES + ('aa000001-0000-0000-0000-000000000001', 'bb000001-0000-0000-0000-000000000001'), -- a owns "uses" relation + ('aa000001-0000-0000-0000-000000000001', 'bb000001-0000-0000-0000-000000000002'), -- a owns "monitors" relation + ('aa000001-0000-0000-0000-000000000002', 'bb000002-0000-0000-0000-000000000001'); -- b owns "uses" relation From 545078b76aefe053a5b242805192c56ab208e271 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Wed, 20 May 2026 18:31:38 +0200 Subject: [PATCH 19/53] feat(entity-graph): add a entity graph service and endpoint --- docs/src/static/swagger.yaml | 682 ++++++++++++++++++----------------- 1 file changed, 361 insertions(+), 321 deletions(-) diff --git a/docs/src/static/swagger.yaml b/docs/src/static/swagger.yaml index accb23c2..590d7718 100644 --- a/docs/src/static/swagger.yaml +++ b/docs/src/static/swagger.yaml @@ -6,140 +6,144 @@ info: servers: - url: http://localhost:8084 security: - - clientId: [] - - bearer: [] +- clientId: [] +- bearer: [] tags: - - name: Entities Management - description: Operations related to entity management - - name: Entities Templates Management - description: Operations related to entity template management +- name: Entity Graph + description: Entity relationship graph operations +- name: Entities Management + description: Operations related to entity management +- name: Entities Templates Management + description: Operations related to entity template management paths: - /api/v1/entity-templates/{identifier}: + "/api/v1/entity-templates/{identifier}": get: tags: - - Entities Templates Management + - Entities Templates Management summary: Get template by identifier description: Retrieve a specific template using its string identifier operationId: getTemplateByIdentifier parameters: - - name: identifier - in: path - required: true - schema: - type: string + - name: identifier + in: path + required: true + schema: + type: string responses: '200': description: Template found content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/EntityTemplateDtoOut' + "$ref": "#/components/schemas/EntityTemplateDtoOut" '404': description: Template not found with the provided identifier content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' + "$ref": "#/components/schemas/ErrorResponse" put: tags: - - Entities Templates Management + - Entities Templates Management summary: Update an existing template by template identifier - description: Update the details of an existing template identified by its unique string identifier + description: Update the details of an existing template identified by its unique + string identifier operationId: updateTemplate parameters: - - name: identifier - in: path - required: true - schema: - type: string + - name: identifier + in: path + required: true + schema: + type: string requestBody: content: application/json: schema: - $ref: '#/components/schemas/EntityTemplateUpdateDtoIn' + "$ref": "#/components/schemas/EntityTemplateUpdateDtoIn" required: true responses: '200': description: Template update successfully content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/EntityTemplateDtoOut' + "$ref": "#/components/schemas/EntityTemplateDtoOut" '404': description: Template not found with the provided identifier content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' + "$ref": "#/components/schemas/ErrorResponse" delete: tags: - - Entities Templates Management + - Entities Templates Management summary: Delete template by identifier description: Remove a template from the system using its unique identifier operationId: deleteTemplate parameters: - - name: identifier - in: path - required: true - schema: - type: string + - name: identifier + in: path + required: true + schema: + type: string responses: '204': description: Template deleted successfully '404': description: Template not found with the provided identifier content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' - /api/v1/entity-templates: + "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/entity-templates": get: tags: - - Entities Templates Management + - Entities Templates Management summary: Get paginated templates description: Retrieve a paginated list of templates with optional sorting operationId: getTemplatesPaginated parameters: - - name: page - in: query - description: Page number for pagination. Defaults to 0. - content: - '*/*': - schema: - type: integer - default: '0' - - name: size - in: query - description: Number of items per page. Defaults to 20. - content: - '*/*': - schema: - type: integer - default: '20' - - name: sort - in: query - description: 'Sorting criteria in the format: property(,asc|desc). Defaults to identifier,asc.' - content: - '*/*': - schema: - type: string - default: identifier,asc + - name: page + in: query + description: Page number for pagination. Defaults to 0. + content: + "*/*": + schema: + type: integer + default: '0' + - name: size + in: query + description: Number of items per page. Defaults to 20. + content: + "*/*": + schema: + type: integer + default: '20' + - name: sort + in: query + description: 'Sorting criteria in the format: property(,asc|desc). Defaults + to identifier,asc.' + content: + "*/*": + schema: + type: string + default: identifier,asc responses: '200': description: Paginated templates retrieved successfully content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/TemplatePageResponse' + "$ref": "#/components/schemas/TemplatePageResponse" '400': description: Invalid pagination parameters content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' + "$ref": "#/components/schemas/ErrorResponse" post: tags: - - Entities Templates Management + - Entities Templates Management summary: Create a new template description: Create a new template in the system with the provided information operationId: createTemplate @@ -147,193 +151,223 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/EntityTemplateCreateDtoIn' + "$ref": "#/components/schemas/EntityTemplateCreateDtoIn" required: true responses: '201': description: Template created successfully content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/EntityTemplateDtoOut' + "$ref": "#/components/schemas/EntityTemplateDtoOut" '400': description: Invalid template data provided content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' - /api/v1/entities/{templateIdentifier}: + "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/entities/{templateIdentifier}": get: tags: - - Entities Management + - Entities Management summary: Get entities by template identifier description: Retrieve a paginated list of entities with optional sorting operationId: getEntities parameters: - - name: page - in: query - description: Page number for pagination. Defaults to 0. - required: false - content: - '*/*': - schema: - type: integer - default: '0' - - name: size - in: query - description: Number of items per page. Defaults to 20. - required: false - content: - '*/*': - schema: - type: integer - default: '20' - - name: templateIdentifier - in: path - required: true - schema: - type: string - - name: sort - in: query - description: 'Sorting criteria in the format: property(,asc|desc). Defaults to identifier,asc.' - content: - '*/*': - schema: - type: string - default: identifier,asc + - name: page + in: query + description: Page number for pagination. Defaults to 0. + required: false + content: + "*/*": + schema: + type: integer + default: '0' + - name: size + in: query + description: Number of items per page. Defaults to 20. + required: false + content: + "*/*": + schema: + type: integer + default: '20' + - name: templateIdentifier + in: path + required: true + schema: + type: string + - name: sort + in: query + description: 'Sorting criteria in the format: property(,asc|desc). Defaults + to identifier,asc.' + content: + "*/*": + schema: + type: string + default: identifier,asc responses: '200': description: Paginated entities retrieved successfully content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/EntityPageResponse' + "$ref": "#/components/schemas/EntityPageResponse" '400': description: Invalid pagination parameters content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' + "$ref": "#/components/schemas/ErrorResponse" post: tags: - - Entities Management + - Entities Management summary: Create a new entity description: Create a new entity in the system with the provided information operationId: createEntity parameters: - - name: templateIdentifier - in: path - required: true - schema: - type: string - minLength: 1 + - name: templateIdentifier + in: path + required: true + schema: + type: string + minLength: 1 requestBody: content: application/json: schema: - $ref: '#/components/schemas/EntityDtoIn' + "$ref": "#/components/schemas/EntityDtoIn" required: true responses: '201': description: Entity created successfully content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/EntityDtoOut' + "$ref": "#/components/schemas/EntityDtoOut" '400': description: Invalid entity data provided content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' + "$ref": "#/components/schemas/ErrorResponse" '401': description: Unauthorized - Missing or invalid token '403': description: Insufficient rights - '409': - description: Entity already exists in this template - content: - '*/*': - schema: - $ref: '#/components/schemas/ErrorResponse' '404': description: Template not found with the provided identifier content: - '*/*': + "*/*": + schema: + "$ref": "#/components/schemas/ErrorResponse" + '409': + description: Entity already exists in this template + content: + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' + "$ref": "#/components/schemas/ErrorResponse" '500': description: Unexpected server-side failure content: - '*/*': + "*/*": + schema: + "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/entities/{templateIdentifier}/{entityIdentifier}/graph": + get: + tags: + - Entity Graph + summary: Get entity relationship graph as flat nodes and edges + description: Retrieves the entity relationship graph as a flat nodes-and-edges + structure, suitable for frontend visualization tools such as React Flow, Vis.js, + and Cytoscape. + operationId: getEntityGraph + parameters: + - name: templateIdentifier + in: path + required: true + schema: + type: string + minLength: 1 + - name: entityIdentifier + in: path + required: true + schema: + type: string + minLength: 1 + - name: depth + in: query + description: Maximum traversal depth for relationship resolution. Clamped + between 1 and 10. + required: false + schema: + type: integer + format: int32 + default: 1 + - name: includeData + in: query + description: When true, each graph node includes a data object containing + the entity's property values. Defaults to false. + required: false + schema: + type: boolean + default: false + - name: relations + in: query + description: When provided, only relations whose name matches one of the listed + values are traversed and included. Omit to include all relations. + required: false + schema: + type: array + items: + type: string + responses: + '200': + description: Flat entity graph successfully retrieved + content: + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' - /api/v1/entities/{templateIdentifier}/identifier/{entityIdentifier}: + "$ref": "#/components/schemas/EntityGraphFlatDtoOut" + '404': + description: Entity not found with the provided identifier + content: + "*/*": + schema: + "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/entities/{templateIdentifier}/identifier/{entityIdentifier}": get: tags: - - Entities Management + - Entities Management summary: Get entity by entity template and identifier - description: Retrieve a specific entity using its string identifier and its template identifier + description: Retrieve a specific entity using its string identifier and its + template identifier operationId: getEntity parameters: - - name: templateIdentifier - in: path - required: true - schema: - type: string - - name: entityIdentifier - in: path - required: true - schema: - type: string + - name: templateIdentifier + in: path + required: true + schema: + type: string + - name: entityIdentifier + in: path + required: true + schema: + type: string responses: '200': description: Entity found content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/EntityDtoOut' + "$ref": "#/components/schemas/EntityDtoOut" '404': description: Entity not found with the provided identifier content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' + "$ref": "#/components/schemas/ErrorResponse" components: schemas: - EntityTemplateCreateDtoIn: - type: object - description: Input DTO for creating an entity template - properties: - identifier: - type: string - description: Unique Entity Template identifier - example: service - minLength: 1 - name: - type: string - description: Entity Template name - example: Service - maxLength: 255 - minLength: 1 - pattern: "^[a-zA-Z0-9 _-]+$" - description: - type: string - description: Entity Template description - example: A comprehensive service template - properties_definitions: - type: array - description: List of property definitions for this template - items: - $ref: '#/components/schemas/PropertyDefinitionDtoIn' - relations_definitions: - type: array - description: List of relation definitions for this template - items: - $ref: '#/components/schemas/RelationDefinitionDtoIn' - required: - - identifier - - name EntityTemplateUpdateDtoIn: type: object description: Input DTO for updating an entity template @@ -353,14 +387,14 @@ components: type: array description: List of property definitions for this template items: - $ref: '#/components/schemas/PropertyDefinitionDtoIn' + "$ref": "#/components/schemas/PropertyDefinitionDtoIn" relations_definitions: type: array description: List of relation definitions for this template items: - $ref: '#/components/schemas/RelationDefinitionDtoIn' + "$ref": "#/components/schemas/RelationDefinitionDtoIn" required: - - name + - name PropertyDefinitionDtoIn: type: object description: Input DTO for creating or updating a property definition @@ -379,9 +413,9 @@ components: type: string description: Property data type enum: - - STRING - - NUMBER - - BOOLEAN + - STRING + - NUMBER + - BOOLEAN example: STRING required: type: boolean @@ -389,12 +423,12 @@ components: description: Whether this property is required example: true rules: - $ref: '#/components/schemas/PropertyRulesDtoIn' + "$ref": "#/components/schemas/PropertyRulesDtoIn" description: Property validation rules required: - - description - - name - - type + - description + - name + - type PropertyRulesDtoIn: type: object description: Input DTO for creating or updating a property definition @@ -403,21 +437,21 @@ components: type: string description: Property format validation enum: - - URL - - EMAIL + - URL + - EMAIL example: EMAIL enum_values: type: array description: Enumeration values for enum properties example: - - ACTIVE - - INACTIVE + - ACTIVE + - INACTIVE items: type: string regex: type: string description: Regular expression pattern for validation - example: ^[a-zA-Z0-9]+$ + example: "^[a-zA-Z0-9]+$" max_length: type: integer format: int32 @@ -463,8 +497,8 @@ components: description: Whether this relation can have multiple targets example: true required: - - name - - target_template_identifier + - name + - target_template_identifier EntityTemplateDtoOut: type: object description: Output for entity template @@ -485,12 +519,12 @@ components: type: array description: List of property definitions for this template items: - $ref: '#/components/schemas/PropertyDefinitionDtoOut' + "$ref": "#/components/schemas/PropertyDefinitionDtoOut" relations_definitions: type: array description: List of relation definitions for this template items: - $ref: '#/components/schemas/RelationDefinitionDtoOut' + "$ref": "#/components/schemas/RelationDefinitionDtoOut" PropertyDefinitionDtoOut: type: object description: Output DTO for property definition @@ -507,46 +541,41 @@ components: type: string description: Property data type enum: - - STRING - - NUMBER - - BOOLEAN + - STRING + - NUMBER + - BOOLEAN example: STRING required: type: boolean description: Whether this property is required example: true rules: - $ref: '#/components/schemas/PropertyRulesDtoOut' + "$ref": "#/components/schemas/PropertyRulesDtoOut" description: Property validation rules example: Property validation rules PropertyRulesDtoOut: type: object description: Output DTO for property validation rules properties: - id: - type: string - format: uuid - description: Unique identifier of the property rules - example: 123e4567-e89b-12d3-a456-426614174000 format: type: string description: Format of the property enum: - - URL - - EMAIL + - URL + - EMAIL example: STRING enum_values: type: array description: Allowed enum values for the property example: - - VALUE1 - - VALUE2 + - VALUE1 + - VALUE2 items: type: string regex: type: string description: Regular expression for property validation - example: ^[A-Za-z0-9]+$ + example: "^[A-Za-z0-9]+$" max_length: type: integer format: int32 @@ -592,78 +621,41 @@ components: properties: error: type: string - enum: - - 100 CONTINUE - - 101 SWITCHING_PROTOCOLS - - 102 PROCESSING - - 103 EARLY_HINTS - - 103 CHECKPOINT - - 200 OK - - 201 CREATED - - 202 ACCEPTED - - 203 NON_AUTHORITATIVE_INFORMATION - - 204 NO_CONTENT - - 205 RESET_CONTENT - - 206 PARTIAL_CONTENT - - 207 MULTI_STATUS - - 208 ALREADY_REPORTED - - 226 IM_USED - - 300 MULTIPLE_CHOICES - - 301 MOVED_PERMANENTLY - - 302 FOUND - - 302 MOVED_TEMPORARILY - - 303 SEE_OTHER - - 304 NOT_MODIFIED - - 305 USE_PROXY - - 307 TEMPORARY_REDIRECT - - 308 PERMANENT_REDIRECT - - 400 BAD_REQUEST - - 401 UNAUTHORIZED - - 402 PAYMENT_REQUIRED - - 403 FORBIDDEN - - 404 NOT_FOUND - - 405 METHOD_NOT_ALLOWED - - 406 NOT_ACCEPTABLE - - 407 PROXY_AUTHENTICATION_REQUIRED - - 408 REQUEST_TIMEOUT - - 409 CONFLICT - - 410 GONE - - 411 LENGTH_REQUIRED - - 412 PRECONDITION_FAILED - - 413 PAYLOAD_TOO_LARGE - - 413 REQUEST_ENTITY_TOO_LARGE - - 414 URI_TOO_LONG - - 414 REQUEST_URI_TOO_LONG - - 415 UNSUPPORTED_MEDIA_TYPE - - 416 REQUESTED_RANGE_NOT_SATISFIABLE - - 417 EXPECTATION_FAILED - - 418 I_AM_A_TEAPOT - - 419 INSUFFICIENT_SPACE_ON_RESOURCE - - 420 METHOD_FAILURE - - 421 DESTINATION_LOCKED - - 422 UNPROCESSABLE_ENTITY - - 423 LOCKED - - 424 FAILED_DEPENDENCY - - 425 TOO_EARLY - - 426 UPGRADE_REQUIRED - - 428 PRECONDITION_REQUIRED - - 429 TOO_MANY_REQUESTS - - 431 REQUEST_HEADER_FIELDS_TOO_LARGE - - 451 UNAVAILABLE_FOR_LEGAL_REASONS - - 500 INTERNAL_SERVER_ERROR - - 501 NOT_IMPLEMENTED - - 502 BAD_GATEWAY - - 503 SERVICE_UNAVAILABLE - - 504 GATEWAY_TIMEOUT - - 505 HTTP_VERSION_NOT_SUPPORTED - - 506 VARIANT_ALSO_NEGOTIATES - - 507 INSUFFICIENT_STORAGE - - 508 LOOP_DETECTED - - 509 BANDWIDTH_LIMIT_EXCEEDED - - 510 NOT_EXTENDED - - 511 NETWORK_AUTHENTICATION_REQUIRED errorDescription: type: string + EntityTemplateCreateDtoIn: + type: object + description: Input DTO for creating an entity template + properties: + identifier: + type: string + description: Unique Entity Template identifier + example: service + minLength: 1 + name: + type: string + description: Unique Entity Template name + example: Service + maxLength: 255 + minLength: 0 + pattern: "^[a-zA-Z0-9 _-]+$" + description: + type: string + description: Entity Template description + example: A comprehensive service template + properties_definitions: + type: array + description: List of property definitions for this template + items: + "$ref": "#/components/schemas/PropertyDefinitionDtoIn" + relations_definitions: + type: array + description: List of relation definitions for this template + items: + "$ref": "#/components/schemas/RelationDefinitionDtoIn" + required: + - identifier + - name EntityDtoIn: type: object description: Input DTO for creating or updating an entity @@ -689,10 +681,10 @@ components: type: array description: List of relations for this entity items: - $ref: '#/components/schemas/RelationDtoIn' + "$ref": "#/components/schemas/RelationDtoIn" required: - - identifier - - name + - identifier + - name RelationDtoIn: type: object description: Input DTO for an entity relation instance @@ -706,13 +698,13 @@ components: type: array description: List of target entity identifiers for this relation example: - - web-api-1 - - web-api-2 + - web-api-1 + - web-api-2 items: type: string required: - - name - - target_entity_identifiers + - name + - target_entity_identifiers EntityDtoOut: type: object properties: @@ -730,13 +722,13 @@ components: additionalProperties: type: array items: - $ref: '#/components/schemas/EntitySummaryDto' + "$ref": "#/components/schemas/EntitySummaryDto" relations_as_target: type: object additionalProperties: type: array items: - $ref: '#/components/schemas/EntitySummaryDto' + "$ref": "#/components/schemas/EntitySummaryDto" EntitySummaryDto: type: object properties: @@ -747,13 +739,6 @@ components: PageableObject: type: object properties: - offset: - type: integer - format: int64 - sort: - $ref: '#/components/schemas/SortObject' - unpaged: - type: boolean paged: type: boolean pageNumber: @@ -762,15 +747,22 @@ components: pageSize: type: integer format: int32 + sort: + "$ref": "#/components/schemas/SortObject" + unpaged: + type: boolean + offset: + type: integer + format: int64 SortObject: type: object properties: - empty: - type: boolean sorted: type: boolean unsorted: type: boolean + empty: + type: boolean TemplatePageResponse: type: object description: Paginated response containing Template objects @@ -778,9 +770,9 @@ components: content: type: array items: - $ref: '#/components/schemas/EntityTemplateDtoOut' + "$ref": "#/components/schemas/EntityTemplateDtoOut" pageable: - $ref: '#/components/schemas/PageableObject' + "$ref": "#/components/schemas/PageableObject" totalElements: type: integer format: int64 @@ -789,17 +781,17 @@ components: format: int32 last: type: boolean - size: - type: integer - format: int32 - number: + sort: + "$ref": "#/components/schemas/SortObject" + numberOfElements: type: integer format: int32 - sort: - $ref: '#/components/schemas/SortObject' first: type: boolean - numberOfElements: + size: + type: integer + format: int32 + number: type: integer format: int32 empty: @@ -811,9 +803,9 @@ components: content: type: array items: - $ref: '#/components/schemas/EntityDtoOut' + "$ref": "#/components/schemas/EntityDtoOut" pageable: - $ref: '#/components/schemas/PageableObject' + "$ref": "#/components/schemas/PageableObject" totalElements: type: integer format: int64 @@ -822,21 +814,69 @@ components: format: int32 last: type: boolean - size: - type: integer - format: int32 - number: + sort: + "$ref": "#/components/schemas/SortObject" + numberOfElements: type: integer format: int32 - sort: - $ref: '#/components/schemas/SortObject' first: type: boolean - numberOfElements: + size: + type: integer + format: int32 + number: type: integer format: int32 empty: type: boolean + EntityGraphEdgeDtoOut: + type: object + properties: + id: + type: string + description: Unique edge identifier + source: + type: string + description: Node id of the source entity + target: + type: string + description: Node id of the target entity + type: + type: string + description: Relation name as defined in the entity template + EntityGraphFlatDtoOut: + type: object + properties: + nodes: + type: array + description: All entity nodes in the graph + items: + "$ref": "#/components/schemas/EntityGraphNodeFlatDtoOut" + edges: + type: array + description: All directed relation edges in the graph + items: + "$ref": "#/components/schemas/EntityGraphEdgeDtoOut" + EntityGraphNodeFlatDtoOut: + type: object + properties: + id: + type: string + description: Unique node identifier composed of templateIdentifier:identifier + label: + type: string + description: Human-readable entity name + template_identifier: + type: string + description: Template identifier this entity belongs to + identifier: + type: string + description: Business identifier of the entity within its template + data: + type: object + additionalProperties: {} + description: Entity property values keyed by property name; present only + when include_data=true is requested securitySchemes: clientId: type: oauth2 From b89ec6d43ecc3e2c72e25ea26443b038bc0046f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Fri, 22 May 2026 16:44:01 +0200 Subject: [PATCH 20/53] feat(core): add a entity graph service and endpoint --- .../api/configuration/CorsProperties.java | 13 +-- .../api/configuration/SwaggerDescription.java | 1 + .../api/controller/EntityGraphController.java | 13 ++- .../entity/EntityGraphFlatDtoOutMapper.java | 106 +++++++++-------- .../controller/EntityGraphControllerTest.java | 68 +++++++++++ .../db/test/R__1_Insert_test_data.sql | 109 +++++++++++++++++- .../test/R__2_Insert_entities_test_data.sql | 66 ----------- 7 files changed, 251 insertions(+), 125 deletions(-) delete mode 100644 src/test/resources/db/test/R__2_Insert_entities_test_data.sql diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java index 841b7a9b..d71c3287 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java @@ -10,14 +10,11 @@ public record CorsProperties( List allowedOrigins, List allowedOriginPatterns ) { - /// Compact constructor: normalises null to empty and defensively copies to prevent - /// external mutation of the configuration list (EI_EXPOSE_REP2 / EI_EXPOSE_REP). + /// Compact constructor: normalises null to empty and defensively copies every list + /// to prevent external mutation of the internal state (EI_EXPOSE_REP / EI_EXPOSE_REP2). + /// List.copyOf() also rejects null elements, enforcing a clean configuration contract. public CorsProperties { - if (allowedOriginPatterns == null) { - allowedOriginPatterns = List.of(); - } - if (allowedOrigins == null) { - allowedOrigins = List.of(); - } + allowedOrigins = allowedOrigins == null ? List.of() : List.copyOf(allowedOrigins); + allowedOriginPatterns = allowedOriginPatterns == null ? List.of() : List.copyOf(allowedOriginPatterns); } } 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 19a06f41..feb6d600 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 @@ -167,4 +167,5 @@ public class SwaggerDescription { public static final String ENTITY_GRAPH_FLAT_NODE_DATA_DESCRIPTION = "Entity property values keyed by property name; present only when include_data=true is requested"; public static final String PARAM_INCLUDE_DATA_DESCRIPTION = "When true, each graph node includes a data object containing the entity's property values. Defaults to false."; public static final String PARAM_RELATIONS_DESCRIPTION = "When provided, only relations whose name matches one of the listed values are traversed and included. Omit to include all relations."; + public static final String PARAM_PROPERTIES_DESCRIPTION = "When provided, each node's data object is restricted to the listed property names. Requires include_data=true to have any effect. Omit to include all properties."; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java index c71b88ea..7ea3de4a 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java @@ -6,6 +6,7 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.OK_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_DEPTH_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_INCLUDE_DATA_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_PROPERTIES_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_RELATIONS_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_GRAPH_FLAT_SUCCESS; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER; @@ -60,6 +61,7 @@ public class EntityGraphController { /// @param depth the maximum traversal depth (default 1, clamped between 1 and 10) /// @param includeData when true, each node includes a data object with entity property values /// @param relations when provided, only relations with matching names are included + /// @param properties when provided, each node's data object is restricted to the listed property names /// @return flat DTO containing nodes and edges arrays @GetMapping("/{templateIdentifier}/{entityIdentifier}/graph") @ResponseStatus(OK) @@ -79,16 +81,19 @@ public EntityGraphFlatDtoOut getEntityGraph( @Parameter(description = PARAM_DEPTH_DESCRIPTION) @RequestParam(defaultValue = "1") int depth, @Parameter(description = PARAM_INCLUDE_DATA_DESCRIPTION) - @RequestParam(defaultValue = "false") boolean includeData, + @RequestParam(name = "include_data", defaultValue = "false") boolean includeData, @Parameter(description = PARAM_RELATIONS_DESCRIPTION) - @RequestParam(required = false) List relations) { + @RequestParam(required = false) List relations, + @Parameter(description = PARAM_PROPERTIES_DESCRIPTION) + @RequestParam(required = false) List properties) { - // Convert the nullable list to a Set for O(1) lookup; empty set means no filter + // Convert the nullable lists to Sets for O(1) lookup; empty set means no filter Set relationFilter = relations != null ? Set.copyOf(relations) : Set.of(); + Set propertyFilter = properties != null ? Set.copyOf(properties) : Set.of(); EntityGraphNode graphNode = entityGraphService.getEntityGraph( templateIdentifier, entityIdentifier, depth, includeData); - return EntityGraphFlatDtoOutMapper.toFlatDto(graphNode, relationFilter); + return EntityGraphFlatDtoOutMapper.toFlatDto(graphNode, relationFilter, propertyFilter); } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java index 3bf9932f..8b90f8bc 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java @@ -36,74 +36,80 @@ private EntityGraphFlatDtoOutMapper() { // Utility class — not instantiable } + /// Groups mutable traversal accumulators to stay within the method-parameter limit + /// and keep the traversal signature readable. + private record TraversalState( + SequencedSet nodes, + List edges, + Set visitedNodeIds, + Set emittedEdgeSignatures, + AtomicInteger edgeCounter) { + } + /// Maps a domain graph node tree to a flat [EntityGraphFlatDtoOut]. /// - /// @param root the root [EntityGraphNode] returned by the domain service - /// @param relationFilter when non-empty, only edges whose type is in this set are emitted, - /// and nodes not referenced by any remaining edge are pruned from the - /// result (except the root, which is always included); - /// an empty set means no filter — all edge types and nodes are emitted + /// @param root the root [EntityGraphNode] returned by the domain service + /// @param relationFilter when non-empty, only edges whose type is in this set are emitted, + /// and nodes not referenced by any remaining edge are pruned; + /// an empty set means no filter — all edge types and nodes are emitted + /// @param propertyFilter when non-empty, only properties whose name is in this set appear + /// in each node's `data` field; + /// an empty set means no filter — all properties are included /// @return flat DTO with deduplicated nodes and directed edges - public static EntityGraphFlatDtoOut toFlatDto(EntityGraphNode root, Set relationFilter) { + public static EntityGraphFlatDtoOut toFlatDto(EntityGraphNode root, Set relationFilter, + Set propertyFilter) { if (root == null) { return new EntityGraphFlatDtoOut(List.of(), List.of()); } - // Use a SequencedSet to deduplicate nodes while preserving insertion order - SequencedSet nodes = new LinkedHashSet<>(); - List edges = new ArrayList<>(); - // Tracks visited node IDs to prevent infinite loops in cyclic graphs - Set visitedNodeIds = new HashSet<>(); - // Tracks emitted edge signatures (source|target|label) to avoid duplicate edges - // when the same relation is encountered from both sides during traversal - Set emittedEdgeSignatures = new HashSet<>(); - var edgeCounter = new AtomicInteger(0); + var state = new TraversalState( + new LinkedHashSet<>(), // nodes — insertion-ordered, deduplicated + new ArrayList<>(), // edges + new HashSet<>(), // visitedNodeIds — prevents infinite loops in cyclic graphs + new HashSet<>(), // emittedEdgeSignatures — prevents duplicate edges + new AtomicInteger(0)); // edgeCounter - traverse(root, nodes, edges, visitedNodeIds, emittedEdgeSignatures, edgeCounter, relationFilter); + traverse(root, state, relationFilter, propertyFilter); // When a relation filter is active, prune nodes that are not connected to any - // remaining edge. The root is always kept. Without this step, nodes reachable via - // non-filtered edges (e.g. C via "depends-on" when filtering "monitors") would + // remaining edge. Without this step, nodes reachable via non-filtered edges would // appear in the node list despite having no visible edges. List finalNodes; if (relationFilter.isEmpty()) { - finalNodes = List.copyOf(nodes); + finalNodes = List.copyOf(state.nodes()); } else { // Collect all node IDs referenced by the filtered edges only. // The root receives no special treatment: if it has no matching edges // it is pruned just like any other disconnected node. Set referencedNodeIds = new HashSet<>(); - for (var edge : edges) { + for (var edge : state.edges()) { referencedNodeIds.add(edge.source()); referencedNodeIds.add(edge.target()); } - finalNodes = nodes.stream() + finalNodes = state.nodes().stream() .filter(n -> referencedNodeIds.contains(n.id())) .toList(); } - return new EntityGraphFlatDtoOut(finalNodes, List.copyOf(edges)); + return new EntityGraphFlatDtoOut(finalNodes, List.copyOf(state.edges())); } private static void traverse( EntityGraphNode node, - SequencedSet nodes, - List edges, - Set visitedNodeIds, - Set emittedEdgeSignatures, - AtomicInteger edgeCounter, - Set relationFilter) { + TraversalState state, + Set relationFilter, + Set propertyFilter) { var nodeId = nodeId(node.templateIdentifier(), node.identifier()); // Skip this node if already visited to prevent infinite loops in cyclic graphs - if (!visitedNodeIds.add(nodeId)) { + if (!state.visitedNodeIds().add(nodeId)) { return; } - nodes.add(new EntityGraphNodeFlatDtoOut( + state.nodes().add(new EntityGraphNodeFlatDtoOut( nodeId, node.name(), node.templateIdentifier(), node.identifier(), - toDataMap(node))); + toDataMap(node, propertyFilter))); // Traverse outbound relations: emit edge from currentNode → target only when the // relation type matches the filter (or no filter is active). Nodes are always @@ -112,9 +118,9 @@ private static void traverse( for (EntityGraphNode target : relation.targets()) { var targetId = nodeId(target.templateIdentifier(), target.identifier()); if (relationFilter.isEmpty() || relationFilter.contains(relation.name())) { - addEdge(edges, emittedEdgeSignatures, edgeCounter, nodeId, targetId, relation.name()); + addEdge(state, nodeId, targetId, relation.name()); } - traverse(target, nodes, edges, visitedNodeIds, emittedEdgeSignatures, edgeCounter, relationFilter); + traverse(target, state, relationFilter, propertyFilter); } } @@ -125,9 +131,9 @@ private static void traverse( for (EntityGraphNode source : relation.targets()) { var sourceId = nodeId(source.templateIdentifier(), source.identifier()); if (relationFilter.isEmpty() || relationFilter.contains(relation.name())) { - addEdge(edges, emittedEdgeSignatures, edgeCounter, sourceId, nodeId, relation.name()); + addEdge(state, sourceId, nodeId, relation.name()); } - traverse(source, nodes, edges, visitedNodeIds, emittedEdgeSignatures, edgeCounter, relationFilter); + traverse(source, state, relationFilter, propertyFilter); } } } @@ -136,17 +142,15 @@ private static void traverse( /// that arise when the same relation is encountered from both the source and the target /// during depth-first traversal. private static void addEdge( - List edges, - Set emittedEdgeSignatures, - AtomicInteger edgeCounter, + TraversalState state, String sourceId, String targetId, String label) { var signature = sourceId + "|" + targetId + "|" + label; - if (emittedEdgeSignatures.add(signature)) { - edges.add(new EntityGraphEdgeDtoOut( - "e" + edgeCounter.incrementAndGet(), sourceId, targetId, label)); + if (state.emittedEdgeSignatures().add(signature)) { + state.edges().add(new EntityGraphEdgeDtoOut( + "e" + state.edgeCounter().incrementAndGet(), sourceId, targetId, label)); } } @@ -157,10 +161,20 @@ private static String nodeId(String templateIdentifier, String identifier) { } /// Converts a node's property list to a name→value map for the `data` field. - /// Returns an empty map when there are no properties; the DTO's @JsonInclude(NON_EMPTY) - /// annotation ensures an empty map is omitted from the JSON output. - private static Map toDataMap(EntityGraphNode node) { - return node.properties().stream() - .collect(Collectors.toMap(p -> p.name(), p -> p.value())); + /// + /// When [propertyFilter] is non-empty, only entries whose name is contained in the + /// filter are included. Returns an empty map when there are no matching properties; + /// the DTO's @JsonInclude(NON_EMPTY) annotation ensures an empty map is omitted from + /// the JSON output. + /// + /// @param node the graph node whose properties are converted + /// @param propertyFilter when non-empty, restricts which properties appear in the map; + /// an empty set means all properties are included + private static Map toDataMap(EntityGraphNode node, Set propertyFilter) { + var stream = node.properties().stream(); + if (!propertyFilter.isEmpty()) { + stream = stream.filter(p -> propertyFilter.contains(p.name())); + } + return stream.collect(Collectors.toMap(p -> p.name(), p -> p.value())); } } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java index d2ef939a..ca30cf21 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java @@ -152,4 +152,72 @@ void shouldReturn401WithoutAuthentication() throws Exception { .andExpect(status().isUnauthorized()); } } + + @Nested + @DisplayName("With 'properties' filter (include_data=true)") + class PropertyFilter { + + @Test + @WithMockUser + @DisplayName("Should include only requested property in each node's data when one property is requested") + void shouldIncludeOnlyRequestedProperty() throws Exception { + mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A) + .param("depth", "3") + .param("include_data", "true") + .param("properties", "tier") + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(APPLICATION_JSON)) + // All three nodes are still returned + .andExpect(jsonPath("$.nodes[*].identifier", + containsInAnyOrder(ENTITY_A, ENTITY_B, ENTITY_C))) + // Each node's data must contain "tier" … + .andExpect(jsonPath("$.nodes[0].data.tier").exists()) + // … but must NOT contain "version" + .andExpect(jsonPath("$.nodes[0].data.version").doesNotExist()); + } + + @Test + @WithMockUser + @DisplayName("Should include multiple requested properties in each node's data") + void shouldIncludeMultipleRequestedProperties() throws Exception { + mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A) + .param("depth", "3") + .param("include_data", "true") + .param("properties", "tier") + .param("properties", "version") + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.nodes[0].data.tier").exists()) + .andExpect(jsonPath("$.nodes[0].data.version").exists()); + } + + @Test + @WithMockUser + @DisplayName("Should return empty data when requested property does not exist on entity") + void shouldReturnEmptyDataForUnknownProperty() throws Exception { + mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A) + .param("depth", "3") + .param("include_data", "true") + .param("properties", "non-existent-prop") + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + // data field is omitted from JSON when empty (@JsonInclude NON_EMPTY) + .andExpect(jsonPath("$.nodes[0].data").doesNotExist()); + } + + @Test + @WithMockUser + @DisplayName("Should include all properties when no property filter is supplied") + void shouldIncludeAllPropertiesWithoutFilter() throws Exception { + mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A) + .param("depth", "3") + .param("include_data", "true") + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.nodes[0].data.tier").exists()) + .andExpect(jsonPath("$.nodes[0].data.version").exists()); + } + } } diff --git a/src/test/resources/db/test/R__1_Insert_test_data.sql b/src/test/resources/db/test/R__1_Insert_test_data.sql index bab7cb5a..32255896 100644 --- a/src/test/resources/db/test/R__1_Insert_test_data.sql +++ b/src/test/resources/db/test/R__1_Insert_test_data.sql @@ -1,6 +1,13 @@ -- Sample data for IDP Core domain models - Enhanced with 10 templates --- Clear existing data (for repeatable migrations) +-- Clear existing data (for repeatable migrations). +-- Deletion order respects FK constraints: child tables first, then parents. +DELETE FROM entity_properties; +DELETE FROM entity_relations; +DELETE FROM relation_target_entities; +DELETE FROM relation; +DELETE FROM entity; +DELETE FROM property; DELETE FROM entity_template_relations_definitions; DELETE FROM entity_template_properties_definitions; DELETE FROM entity_template; @@ -278,3 +285,103 @@ INSERT INTO entity_template_relations_definitions (entity_template_id, relations ('550e8400-e29b-41d4-a716-446655440079', '550e8400-e29b-41d4-a716-446655440053'), -- database ('550e8400-e29b-41d4-a716-446655440079', '550e8400-e29b-41d4-a716-446655440057'), -- networks ('550e8400-e29b-41d4-a716-446655440079', '550e8400-e29b-41d4-a716-446655440064'); -- external_apis + +-- ----------------------------------------------------------------------- +-- Sample entity instances +-- ----------------------------------------------------------------------- + +INSERT INTO entity (id, identifier, name, template_identifier) +VALUES + ('550e8400-e29b-41d4-a716-446655440100', 'web-api-1', 'Web API 1', 'web-service'), + ('550e8400-e29b-41d4-a716-446655440101', 'web-api-2', 'Web API 2', 'web-service'), + ('550e8400-e29b-41d4-a716-446655440102', 'microservice-1', 'Microservice 1', 'microservice'), + ('550e8400-e29b-41d4-a716-446655440103', 'batch-job-1', 'Batch Job 1', 'batch-job'), + ('550e8400-e29b-41d4-a716-446655440104', 'frontend-app-1', 'Frontend App 1', 'frontend-app'), + ('550e8400-e29b-41d4-a716-446655440105', 'worker-service-1', 'Worker Service 1', 'worker-service'), + ('550e8400-e29b-41d4-a716-446655440106', 'api-gateway-1', 'API Gateway 1', 'api-gateway'), + ('550e8400-e29b-41d4-a716-446655440107', 'database-service-1', 'Database Service 1', 'database-service'), + ('550e8400-e29b-41d4-a716-446655440108', 'cache-service-1', 'Cache Service 1', 'cache-service'), + ('550e8400-e29b-41d4-a716-446655440109', 'monitoring-service-1', 'Monitoring Service 1', 'monitoring-service'), + ('550e8400-e29b-41d4-a716-446655440110', 'monitoring-service-2', 'Monitoring Service 2', 'monitoring-service'), + ('550e8400-e29b-41d4-a716-446655440111', 'monitoring-service-3', 'Monitoring Service 3', 'monitoring-service'), + ('550e8400-e29b-41d4-a716-446655440112', 'monitoring-service-4', 'Monitoring Service 4', 'monitoring-service'), + ('550e8400-e29b-41d4-a716-446655440113', 'monitoring-service-5', 'Monitoring Service 5', 'monitoring-service'), + ('550e8400-e29b-41d4-a716-446655440114', 'monitoring-service-6', 'Monitoring Service 6', 'monitoring-service'); + +-- ----------------------------------------------------------------------- +-- Graph test data: 3-level chain of entities connected via two relation +-- types ("uses" and "monitors") for integration testing of the graph API. +-- +-- Graph topology (depth-3 chain): +-- graph-svc-a --[uses]--> graph-svc-b --[uses]--> graph-svc-c +-- graph-svc-a --[monitors]--> graph-svc-b +-- +-- This setup allows us to verify: +-- 1. Graph traversal works at all depths (not just root level) +-- 2. Relation name filtering excludes the correct edges/nodes at every depth +-- 3. "uses" filter returns: a → b → c (2 edges, 3 nodes) +-- 4. "monitors" filter returns: a → b (1 edge, 2 nodes; c not reachable) +-- ----------------------------------------------------------------------- + +INSERT INTO entity (id, identifier, name, template_identifier) +VALUES + ('aa000001-0000-0000-0000-000000000001', 'graph-svc-a', 'Graph Service A', 'web-service'), + ('aa000001-0000-0000-0000-000000000002', 'graph-svc-b', 'Graph Service B', 'web-service'), + ('aa000001-0000-0000-0000-000000000003', 'graph-svc-c', 'Graph Service C', 'web-service'); + +-- Relations owned by graph-svc-a: "uses" → b, "monitors" → b +INSERT INTO relation (id, name, target_template_identifier) +VALUES + ('bb000001-0000-0000-0000-000000000001', 'uses', 'web-service'), + ('bb000001-0000-0000-0000-000000000002', 'monitors', 'web-service'); + +-- Relation owned by graph-svc-b: "uses" → c +INSERT INTO relation (id, name, target_template_identifier) +VALUES + ('bb000002-0000-0000-0000-000000000001', 'uses', 'web-service'); + +-- Target entity identifiers for each relation +INSERT INTO relation_target_entities (relation_id, target_entity_identifier) +VALUES + ('bb000001-0000-0000-0000-000000000001', 'graph-svc-b'), -- a -[uses]-> b + ('bb000001-0000-0000-0000-000000000002', 'graph-svc-b'), -- a -[monitors]-> b + ('bb000002-0000-0000-0000-000000000001', 'graph-svc-c'); -- b -[uses]-> c + +-- Link relations to their owner entities +INSERT INTO entity_relations (entity_id, relation_id) +VALUES + ('aa000001-0000-0000-0000-000000000001', 'bb000001-0000-0000-0000-000000000001'), -- a owns "uses" relation + ('aa000001-0000-0000-0000-000000000001', 'bb000001-0000-0000-0000-000000000002'), -- a owns "monitors" relation + ('aa000001-0000-0000-0000-000000000002', 'bb000002-0000-0000-0000-000000000001'); -- b owns "uses" relation + +-- ----------------------------------------------------------------------- +-- Property data for graph test entities (used by the property-filter tests). +-- +-- Each graph entity gets two properties: "tier" and "version". +-- This lets us verify: +-- 1. No filter → both properties appear in node data +-- 2. Filter "tier" → only tier present, version absent +-- 3. Filter "tier"+"version" → both present +-- 4. Filter "non-existent" → data field omitted entirely (NON_EMPTY) +-- ----------------------------------------------------------------------- + +INSERT INTO property (id, name, value) +VALUES + -- graph-svc-a + ('cc000001-0000-0000-0000-000000000001', 'tier', 'gold'), + ('cc000001-0000-0000-0000-000000000002', 'version', '1.0.0'), + -- graph-svc-b + ('cc000001-0000-0000-0000-000000000003', 'tier', 'silver'), + ('cc000001-0000-0000-0000-000000000004', 'version', '2.0.0'), + -- graph-svc-c + ('cc000001-0000-0000-0000-000000000005', 'tier', 'bronze'), + ('cc000001-0000-0000-0000-000000000006', 'version', '3.0.0'); + +INSERT INTO entity_properties (entity_id, property_id) +VALUES + ('aa000001-0000-0000-0000-000000000001', 'cc000001-0000-0000-0000-000000000001'), -- a.tier + ('aa000001-0000-0000-0000-000000000001', 'cc000001-0000-0000-0000-000000000002'), -- a.version + ('aa000001-0000-0000-0000-000000000002', 'cc000001-0000-0000-0000-000000000003'), -- b.tier + ('aa000001-0000-0000-0000-000000000002', 'cc000001-0000-0000-0000-000000000004'), -- b.version + ('aa000001-0000-0000-0000-000000000003', 'cc000001-0000-0000-0000-000000000005'), -- c.tier + ('aa000001-0000-0000-0000-000000000003', 'cc000001-0000-0000-0000-000000000006'); -- c.version \ No newline at end of file diff --git a/src/test/resources/db/test/R__2_Insert_entities_test_data.sql b/src/test/resources/db/test/R__2_Insert_entities_test_data.sql deleted file mode 100644 index d80b4d5d..00000000 --- a/src/test/resources/db/test/R__2_Insert_entities_test_data.sql +++ /dev/null @@ -1,66 +0,0 @@ --- Insert sample entities into idp_core.entity -INSERT INTO entity (id, identifier, name, template_identifier) -VALUES - ('550e8400-e29b-41d4-a716-446655440100', 'web-api-1', 'Web API 1', 'web-service'), - ('550e8400-e29b-41d4-a716-446655440101', 'web-api-2', 'Web API 2', 'web-service'), - ('550e8400-e29b-41d4-a716-446655440102', 'microservice-1', 'Microservice 1', 'microservice'), - ('550e8400-e29b-41d4-a716-446655440103', 'batch-job-1', 'Batch Job 1', 'batch-job'), - ('550e8400-e29b-41d4-a716-446655440104', 'frontend-app-1', 'Frontend App 1', 'frontend-app'), - ('550e8400-e29b-41d4-a716-446655440105', 'worker-service-1', 'Worker Service 1', 'worker-service'), - ('550e8400-e29b-41d4-a716-446655440106', 'api-gateway-1', 'API Gateway 1', 'api-gateway'), - ('550e8400-e29b-41d4-a716-446655440107', 'database-service-1', 'Database Service 1', 'database-service'), - ('550e8400-e29b-41d4-a716-446655440108', 'cache-service-1', 'Cache Service 1', 'cache-service'), - ('550e8400-e29b-41d4-a716-446655440109', 'monitoring-service-1', 'Monitoring Service 1', 'monitoring-service'), - ('550e8400-e29b-41d4-a716-446655440110', 'monitoring-service-2', 'Monitoring Service 2', 'monitoring-service'), - ('550e8400-e29b-41d4-a716-446655440111', 'monitoring-service-3', 'Monitoring Service 3', 'monitoring-service'), - ('550e8400-e29b-41d4-a716-446655440112', 'monitoring-service-4', 'Monitoring Service 4', 'monitoring-service'), - ('550e8400-e29b-41d4-a716-446655440113', 'monitoring-service-5', 'Monitoring Service 5', 'monitoring-service'), - ('550e8400-e29b-41d4-a716-446655440114', 'monitoring-service-6', 'Monitoring Service 6', 'monitoring-service'); - --- ----------------------------------------------------------------------- --- Graph test data: 3-level chain of entities connected via two relation --- types ("uses" and "monitors") for integration testing of the graph API. --- --- Graph topology (depth-3 chain): --- graph-svc-a --[uses]--> graph-svc-b --[uses]--> graph-svc-c --- graph-svc-a --[monitors]--> graph-svc-b --- --- This setup allows us to verify: --- 1. Graph traversal works at all depths (not just root level) --- 2. Relation name filtering excludes the correct edges/nodes at every depth --- 3. "uses" filter returns: a → b → c (2 edges, 3 nodes) --- 4. "monitors" filter returns: a → b (1 edge, 2 nodes; c not reachable) --- ----------------------------------------------------------------------- - --- Entities (all use the 'web-service' template which exists in test data) --- UUIDs use only valid hex characters (0-9, a-f) -INSERT INTO entity (id, identifier, name, template_identifier) -VALUES - ('aa000001-0000-0000-0000-000000000001', 'graph-svc-a', 'Graph Service A', 'web-service'), - ('aa000001-0000-0000-0000-000000000002', 'graph-svc-b', 'Graph Service B', 'web-service'), - ('aa000001-0000-0000-0000-000000000003', 'graph-svc-c', 'Graph Service C', 'web-service'); - --- Relations owned by graph-svc-a: "uses" → b, "monitors" → b -INSERT INTO relation (id, name, target_template_identifier) -VALUES - ('bb000001-0000-0000-0000-000000000001', 'uses', 'web-service'), - ('bb000001-0000-0000-0000-000000000002', 'monitors', 'web-service'); - --- Relation owned by graph-svc-b: "uses" → c -INSERT INTO relation (id, name, target_template_identifier) -VALUES - ('bb000002-0000-0000-0000-000000000001', 'uses', 'web-service'); - --- Target entity identifiers for each relation -INSERT INTO relation_target_entities (relation_id, target_entity_identifier) -VALUES - ('bb000001-0000-0000-0000-000000000001', 'graph-svc-b'), -- a -[uses]-> b - ('bb000001-0000-0000-0000-000000000002', 'graph-svc-b'), -- a -[monitors]-> b - ('bb000002-0000-0000-0000-000000000001', 'graph-svc-c'); -- b -[uses]-> c - --- Link relations to their owner entities -INSERT INTO entity_relations (entity_id, relation_id) -VALUES - ('aa000001-0000-0000-0000-000000000001', 'bb000001-0000-0000-0000-000000000001'), -- a owns "uses" relation - ('aa000001-0000-0000-0000-000000000001', 'bb000001-0000-0000-0000-000000000002'), -- a owns "monitors" relation - ('aa000001-0000-0000-0000-000000000002', 'bb000002-0000-0000-0000-000000000001'); -- b owns "uses" relation From 63072ef211039e0bc8989e8e4682575d0ba0c004 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Thu, 28 May 2026 16:34:54 +0200 Subject: [PATCH 21/53] feat(core): add a entity graph service and endpoint --- .../domain/service/entity_graph/EntityGraphService.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java index 4d989ee0..af23ff72 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java @@ -98,15 +98,20 @@ private EntityGraphNode buildGraphNode(EntityCompositeKey key, // Guard: return a stub leaf if this node was already fully built in another branch. // This breaks both directed cycles (A→B→A) and shared references (A→B, C→B). + // Properties are still included so data is not silently dropped for shared nodes. var nodeId = entity.templateIdentifier() + ":" + entity.identifier(); if (!visitedNodeIds.add(nodeId)) { + List stubProperties = includeProperties ? entity.properties() : List.of(); return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), - List.of(), List.of(), List.of()); + stubProperties, List.of(), List.of()); } + // Depth exhausted — return a leaf with no relations but still carry properties + // so the deepest reachable entities expose their data when include_data=true. if (remainingDepth <= 0) { + List leafProperties = includeProperties ? entity.properties() : List.of(); return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), - List.of(), List.of(), List.of()); + leafProperties, List.of(), List.of()); } List outboundRelations = entity.relations().stream() From ce8afc9e9efa322cf0dd638615f8f4e0280a702d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Thu, 28 May 2026 18:00:59 +0200 Subject: [PATCH 22/53] feat(core): add a entity graph service and endpoint --- .pre-commit-config.yaml | 27 +- .spotless/eclipse-formatter.xml | 10 + pom.xml | 823 ++++--- .../idp_core/IdpCoreApplication.java | 6 +- .../domain/constant/ValidationMessages.java | 128 +- .../domain/constant/ValidationRegex.java | 2 +- .../entity/EntityAlreadyExistsException.java | 14 +- .../entity/EntityNotFoundException.java | 22 +- .../entity/EntityValidationException.java | 29 +- .../EntityTemplateAlreadyExistsException.java | 27 +- ...mplateIdentifierCannotChangeException.java | 10 +- ...ityTemplateNameAlreadyExistsException.java | 6 +- .../EntityTemplateNotFoundException.java | 95 +- ...pertyDefinitionRulesConflictException.java | 18 +- .../PropertyNameAlreadyExistsException.java | 16 +- .../PropertyTypeChangeException.java | 17 +- .../RelationCannotTargetItselfException.java | 15 +- .../RelationNameAlreadyExistsException.java | 16 +- ...RelationTargetTemplateChangeException.java | 18 +- .../TargetTemplateNotFoundException.java | 14 +- ...pertyDefinitionRulesConflictException.java | 18 +- .../idp_core/domain/model/entity/Entity.java | 41 +- .../model/entity/EntityCompositeKey.java | 50 +- .../domain/model/entity/EntitySummary.java | 3 +- .../domain/model/entity/Property.java | 13 +- .../domain/model/entity/Relation.java | 45 +- .../model/entity/RelationAsTargetSummary.java | 9 +- .../model/entity_graph/EntityGraphNode.java | 21 +- .../entity_graph/EntityGraphRelation.java | 11 +- .../model/entity_template/EntityTemplate.java | 36 +- .../entity_template/PropertyDefinition.java | 19 +- .../model/entity_template/PropertyRules.java | 27 +- .../entity_template/RelationDefinition.java | 12 +- .../domain/model/enums/PropertyFormat.java | 3 +- .../domain/model/enums/PropertyType.java | 4 +- .../port/EntityGraphRepositoryPort.java | 48 +- .../domain/port/EntityRepositoryPort.java | 21 +- .../port/EntityTemplateRepositoryPort.java | 14 +- .../domain/port/RelationRepositoryPort.java | 4 +- .../domain/service/RelationService.java | 29 +- .../domain/service/entity/EntityService.java | 142 +- .../entity/EntityValidationService.java | 114 +- .../domain/service/entity/Violations.java | 44 +- .../entity_graph/EntityGraphService.java | 237 +- .../EntityTemplateService.java | 473 ++-- .../EntityTemplateValidationService.java | 304 +-- .../PropertyDefinitionValidationService.java | 497 ++-- .../PropertyRegexValidationService.java | 553 ++--- .../RelationDefinitionValidationService.java | 156 +- .../property/PropertyValidationService.java | 146 +- .../api/configuration/CorsProperties.java | 24 +- .../api/configuration/JwtConfiguration.java | 15 +- .../configuration/SecurityConfiguration.java | 62 +- .../SpringDataWebConfiguration.java | 5 +- .../configuration/SwaggerConfiguration.java | 94 +- .../api/configuration/SwaggerDescription.java | 294 ++- .../api/configuration/WebConfiguration.java | 8 +- .../api/controller/EntityController.java | 173 +- .../api/controller/EntityGraphController.java | 83 +- .../controller/EntityTemplateController.java | 177 +- .../adapters/api/dto/in/EntityDtoIn.java | 73 +- .../api/dto/in/EntityTemplateCreateDtoIn.java | 26 +- .../in/EntityTemplateDtoInCommonFields.java | 38 +- .../api/dto/in/EntityTemplateUpdateDtoIn.java | 22 +- .../api/dto/in/PropertyDefinitionDtoIn.java | 37 +- .../api/dto/in/PropertyRulesDtoIn.java | 28 +- .../api/dto/in/RelationDefinitionDtoIn.java | 27 +- .../api/dto/out/entity/EntityDtoOut.java | 12 +- .../dto/out/entity/EntityGraphEdgeDtoOut.java | 14 +- .../dto/out/entity/EntityGraphFlatDtoOut.java | 17 +- .../out/entity/EntityGraphNodeFlatDtoOut.java | 36 +- .../api/dto/out/entity/EntitySummaryDto.java | 4 +- .../entity/RelationAsTargetSummaryDtoOut.java | 9 +- .../entity_template/EntityTemplateDtoOut.java | 29 +- .../PropertyDefinitionDtoOut.java | 25 +- .../entity_template/PropertyRulesDtoOut.java | 28 +- .../RelationDefinitionDtoOut.java | 16 +- .../api/handler/ApiExceptionHandler.java | 608 ++--- .../api/mapper/entity/EntityDtoInMapper.java | 60 +- .../api/mapper/entity/EntityDtoOutMapper.java | 505 ++-- .../entity/EntityGraphFlatDtoOutMapper.java | 261 +-- .../entity_template/EntityTemplateMapper.java | 359 ++- .../persistence/PostgresEntityAdapter.java | 88 +- .../PostgresEntityGraphAdapter.java | 75 +- .../PostgresEntityTemplateAdapter.java | 320 ++- .../persistence/PostgresRelationAdapter.java | 13 +- .../mapper/EntityPersistenceMapper.java | 12 +- .../EntityTemplatePersistenceMapper.java | 21 +- .../model/entity/EntityJpaEntity.java | 40 +- .../model/entity/PropertyJpaEntity.java | 15 +- .../model/entity/PropertyRulesJpaEntity.java | 27 +- .../model/entity/RelationJpaEntity.java | 25 +- .../EntityTemplateJpaEntity.java | 89 +- .../PropertyDefinitionJpaEntity.java | 31 +- .../RelationDefinitionJpaEntity.java | 21 +- .../repository/JpaEntityRepository.java | 303 +-- .../JpaEntityTemplateRepository.java | 28 +- .../repository/JpaRelationRepository.java | 22 +- .../idp_core/AbstractIntegrationTest.java | 325 ++- .../idp_core/TestSecurityConfiguration.java | 5 +- .../service/entity/EntityServiceTest.java | 245 +- .../entity/EntityValidationServiceTest.java | 321 ++- .../entity_graph/EntityGraphServiceTest.java | 600 +++-- .../EntityTemplateServiceTest.java | 456 ++-- ...opertyDefinitionValidationServiceTest.java | 1882 ++++++--------- .../PropertyRegexValidationServiceTest.java | 110 +- ...lationDefinitionValidationServiceTest.java | 580 +++-- .../PropertyValidationServiceTest.java | 483 ++-- .../api/controller/EntityControllerTest.java | 426 ++-- .../controller/EntityGraphControllerTest.java | 333 ++- .../EntityTemplateControllerTest.java | 2055 ++++++++--------- .../api/controller/HealthControllerTest.java | 11 +- .../api/handler/ApiExceptionHandlerTest.java | 910 ++++---- .../api/mapper/EntityTemplateMapperTest.java | 1042 ++++----- 114 files changed, 8881 insertions(+), 9609 deletions(-) create mode 100644 .spotless/eclipse-formatter.xml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 202be12e..815a5e21 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,7 +3,7 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - - id: trailing-whitespace # Trims trailing whitespace + - id: trailing-whitespace # Trims trailing whitespace exclude: | (?x)^( .gitmodules| @@ -12,23 +12,23 @@ repos: .*\.drawio.*| .*\.snap )$ - - id: check-yaml # Validates YAML files + - id: check-yaml # Validates YAML files args: - --allow-multiple-documents - - id: check-json # Validates JSON files - - id: check-case-conflict # Checks for files that would conflict in case-insensitive filesystems - - id: check-merge-conflict # Checks for files that contain merge conflict strings - - id: detect-private-key # Check for the existence of private keys - - id: check-executables-have-shebangs # Checks that executables have shebangs + - id: check-json # Validates JSON files + - id: check-case-conflict # Checks for files that would conflict in case-insensitive filesystems + - id: check-merge-conflict # Checks for files that contain merge conflict strings + - id: detect-private-key # Check for the existence of private keys + - id: check-executables-have-shebangs # Checks that executables have shebangs exclude: | (?x)^( .*\.java )$ - - id: end-of-file-fixer # Makes sure files end in a newline and only a newline + - id: end-of-file-fixer # Makes sure files end in a newline and only a newline - repo: https://github.com/adrienverge/yamllint rev: v1.37.1 hooks: - - id: yamllint # Lints YAML files + - id: yamllint # Lints YAML files - repo: https://github.com/igorshubovych/markdownlint-cli rev: v0.47.0 hooks: @@ -46,3 +46,12 @@ repos: args: [sync] - id: vale args: [--output=line, --minAlertLevel=error] + - repo: local + hooks: + - id: spotless-check + name: Spotless code formatting check + entry: ./mvnw spotless:check + language: system + pass_filenames: false + files: \.java$ + stages: [commit] diff --git a/.spotless/eclipse-formatter.xml b/.spotless/eclipse-formatter.xml new file mode 100644 index 00000000..75b546e6 --- /dev/null +++ b/.spotless/eclipse-formatter.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/pom.xml b/pom.xml index 1844818c..d6b59c08 100644 --- a/pom.xml +++ b/pom.xml @@ -1,388 +1,447 @@ - - - 4.0.0 - - - org.springframework.boot - spring-boot-starter-parent - 4.0.5 - - - - com.decathlon - idp-core - 0.0.1-SNAPSHOT - idp-core - IDP core component - - - 25 - 2.2.48 - 1.18.44 - 5.15.0 - 1.21.4 - 3.15.0 - 0.8.14 - 1.5.5.Final - - - - - - - io.swagger.core.v3 - swagger-core-jakarta - ${jakarta.version} - - - io.swagger.core.v3 - swagger-annotations-jakarta - ${jakarta.version} - - - io.swagger.core.v3 - swagger-models-jakarta - ${jakarta.version} - - - org.apache.tomcat.embed - tomcat-embed-core - - - org.apache.tomcat.embed - tomcat-embed-websocket - - - org.apache.tomcat.embed - tomcat-embed-el - - - - + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.0.5 + + + + com.decathlon + idp-core + 0.0.1-SNAPSHOT + idp-core + IDP core component + + + 25 + 2.2.48 + 1.18.44 + 5.15.0 + 1.21.4 + 3.15.0 + 0.8.14 + 1.5.5.Final + + + + - - - org.springframework.boot - spring-boot-starter-web - - - - org.springframework.boot - spring-boot-starter-webflux - - - - org.springframework.boot - spring-boot-starter-validation - - - - org.springframework.boot - spring-boot-starter-oauth2-authorization-server - - - - org.projectlombok - lombok - ${lombok.version} - provided - - - - - org.mapstruct - mapstruct - ${mapstruct.version} - - - org.mapstruct - mapstruct-processor - ${mapstruct.version} - provided - - - - org.springframework.boot - spring-boot-starter-data-jpa - - - - - org.springframework.boot - spring-boot-devtools - runtime - true - - - - - org.springframework.boot - spring-boot-starter-flyway - - - org.flywaydb - flyway-database-postgresql - - - - - org.postgresql - postgresql - runtime - - - - - com.h2database - h2 - runtime - - - - - org.springframework.boot - spring-boot-starter-test - test - - - - org.mock-server - mockserver-client-java - ${mockserver.version} - test - - - - org.mock-server - mockserver-netty - ${mockserver.version} - test - - - - org.springframework.boot - spring-boot-starter-webmvc-test - test - - - - org.testcontainers - testcontainers - ${testcontainers.version} - test - - - - org.testcontainers - postgresql - ${testcontainers.version} - test - - - - org.testcontainers - junit-jupiter - ${testcontainers.version} - test - - - - org.springframework.boot - spring-boot-starter-security-test - test - - - - - org.springdoc - springdoc-openapi-starter-webmvc-ui - 3.0.3 - - - - - io.swagger.parser.v3 - swagger-parser - 2.1.40 - test - - - io.swagger.core.v3 - swagger-core - - - io.swagger - swagger-core - - - - - - org.apache.commons - commons-lang3 - 3.20.0 - - - - org.springframework.boot - spring-boot-starter-actuator - - + + io.swagger.core.v3 + swagger-core-jakarta + ${jakarta.version} + + + io.swagger.core.v3 + swagger-annotations-jakarta + ${jakarta.version} + + + io.swagger.core.v3 + swagger-models-jakarta + ${jakarta.version} + + + org.apache.tomcat.embed + tomcat-embed-core + + + org.apache.tomcat.embed + tomcat-embed-websocket + + + org.apache.tomcat.embed + tomcat-embed-el + - - - - - org.springframework.boot - spring-boot-maven-plugin - - com.decathlon.idp_core.IdpCoreApplication - - true - - - - org.projectlombok - lombok - - - - - - - org.sonarsource.scanner.maven - sonar-maven-plugin - 3.9.1.2184 - - - - org.flywaydb - flyway-maven-plugin - 9.12.0 - - - - org.apache.maven.plugins - maven-compiler-plugin - ${maven-compiler.version} - - 25 - - - org.projectlombok - lombok - ${lombok.version} - - - org.mapstruct - mapstruct-processor - ${mapstruct.version} - - - - org.projectlombok - lombok-mapstruct-binding - 0.2.0 - - - - - - - org.jacoco - jacoco-maven-plugin - ${jacoco-maven.version} - - - prepare-agent - - prepare-agent - - - - report - - report - - - - XML - - - - - - - - com.github.spotbugs - spotbugs-maven-plugin - 4.9.8.2 - - true - ${project.build.directory} - false - - - - verify - - check - - - - - - - com.github.codemonstur - maven-check-license - 1.2.0 - - - validate - - check - - - - - true - passOnMatch - - name:equal:MIT - name:equal:MIT License - name:equal:The MIT License - name:equal:MIT-0 - name:equal:Apache-2.0 - name:equal:Apache License 2.0 - name:equal:The Apache License, Version 2.0 - name:equal:The Apache Software License, Version 2.0 - name:equal:Apache License Version 2.0 - name:equal:Apache License, Version 2.0 - name:regex:Apache(\s|-)(Software )?(License |License, + + + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-webflux + + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springframework.boot + spring-boot-starter-oauth2-authorization-server + + + + org.projectlombok + lombok + ${lombok.version} + provided + + + + + org.mapstruct + mapstruct + ${mapstruct.version} + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + provided + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + + + org.springframework.boot + spring-boot-starter-flyway + + + org.flywaydb + flyway-database-postgresql + + + + + org.postgresql + postgresql + runtime + + + + + com.h2database + h2 + runtime + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.mock-server + mockserver-client-java + ${mockserver.version} + test + + + + org.mock-server + mockserver-netty + ${mockserver.version} + test + + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + + org.testcontainers + testcontainers + ${testcontainers.version} + test + + + + org.testcontainers + postgresql + ${testcontainers.version} + test + + + + org.testcontainers + junit-jupiter + ${testcontainers.version} + test + + + + org.springframework.boot + spring-boot-starter-security-test + test + + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 3.0.3 + + + + + io.swagger.parser.v3 + swagger-parser + 2.1.40 + test + + + io.swagger.core.v3 + swagger-core + + + io.swagger + swagger-core + + + + + + org.apache.commons + commons-lang3 + 3.20.0 + + + + org.springframework.boot + spring-boot-starter-actuator + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + com.decathlon.idp_core.IdpCoreApplication + + true + + + + org.projectlombok + lombok + + + + + + + org.sonarsource.scanner.maven + sonar-maven-plugin + 3.9.1.2184 + + + + org.flywaydb + flyway-maven-plugin + 9.12.0 + + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven-compiler.version} + + 25 + + + org.projectlombok + lombok + ${lombok.version} + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + + + + org.projectlombok + lombok-mapstruct-binding + 0.2.0 + + + + + + + org.jacoco + jacoco-maven-plugin + ${jacoco-maven.version} + + + prepare-agent + + prepare-agent + + + + report + + report + + + + XML + + + + + + + + com.github.spotbugs + spotbugs-maven-plugin + 4.9.8.2 + + true + ${project.build.directory} + false + + + + + check + + verify + + + + + + com.github.codemonstur + maven-check-license + 1.2.0 + + true + passOnMatch + + name:equal:MIT + name:equal:MIT License + name:equal:The MIT License + name:equal:MIT-0 + name:equal:Apache-2.0 + name:equal:Apache License 2.0 + name:equal:The Apache License, Version 2.0 + name:equal:The Apache Software License, Version 2.0 + name:equal:Apache License Version 2.0 + name:equal:Apache License, Version 2.0 + name:regex:Apache(\s|-)(Software )?(License |License, )?(Version|version )?2\.0 - name:equal:EPL 2.0 - name:equal:Eclipse Public License - v 2.0 - name:equal:Eclipse Distribution License - v 1.0 - name:equal:Eclipse Distribution License v. 1.0 - name:equal:EDL 1.0 - name:equal:BSD-3-Clause - name:equal:BSD-2-Clause - name:equal:MPL 2.0 - name:equal:EPL 1.0 - name:equal:Public Domain, per Creative Commons CC0 - - - org.hibernate.orm:hibernate-core:6.6.29.Final - ch.qos.logback:logback-classic:1.5.25 - ch.qos.logback:logback-core:1.5.25 - - - - - - + name:equal:EPL 2.0 + name:equal:Eclipse Public License - v 2.0 + name:equal:Eclipse Distribution License - v 1.0 + name:equal:Eclipse Distribution License v. 1.0 + name:equal:EDL 1.0 + name:equal:BSD-3-Clause + name:equal:BSD-2-Clause + name:equal:MPL 2.0 + name:equal:EPL 1.0 + name:equal:Public Domain, per Creative Commons CC0 + + + org.hibernate.orm:hibernate-core:6.6.29.Final + ch.qos.logback:logback-classic:1.5.25 + ch.qos.logback:logback-core:1.5.25 + + + + + + check + + validate + + + + + com.diffplug.spotless + spotless-maven-plugin + 2.30.0 + + + + + + 4.21.0 + ${project.basedir}/.spotless/eclipse-formatter.xml + + + + + java,javax,jakarta,org,com + + + + + src/main/java/**/*.java + src/test/java/**/*.java + + + + + + + false + + + + + + + + src/main/resources/**/*.yml + src/main/resources/**/*.yaml + src/test/resources/**/*.yml + src/test/resources/**/*.yaml + + + + + true + 2 + + + + + + + check-formatting + + check + + verify + + + + + + diff --git a/src/main/java/com/decathlon/idp_core/IdpCoreApplication.java b/src/main/java/com/decathlon/idp_core/IdpCoreApplication.java index 27eeea9d..3c5bb4df 100644 --- a/src/main/java/com/decathlon/idp_core/IdpCoreApplication.java +++ b/src/main/java/com/decathlon/idp_core/IdpCoreApplication.java @@ -6,8 +6,8 @@ @SpringBootApplication public class IdpCoreApplication { - public static void main(String[] args) { - SpringApplication.run(IdpCoreApplication.class, args); - } + public static void main(String[] args) { + SpringApplication.run(IdpCoreApplication.class, args); + } } 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 659e912a..cbdf4662 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 @@ -6,78 +6,74 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public class ValidationMessages { - // Entity Template validation messages - public static final String TEMPLATE_ALREADY_EXISTS = "An Entity Template already exists with the same identifier"; - public static final String TEMPLATE_IDENTIFIER_NOT_FOUND = "Target template with identifier '%s' does not exist."; - public static final String TEMPLATE_IDENTIFIER_MANDATORY = "Entity Template identifier is mandatory and cannot be blank"; - public static final String TEMPLATE_IDENTIFIER_CANNOT_CHANGE = "Entity Template identifier cannot be changed. Current identifier: "; - public static final String TEMPLATE_NAME_ALREADY_EXISTS = "The entity template name %s already exists"; - public static final String TEMPLATE_NAME_MANDATORY = "Entity template name is mandatory and cannot be blank"; - public static final String TEMPLATE_NAME_MAX_SIZE = "Entity template name must not exceed 255 characters"; - public static final String TEMPLATE_NAME_FORMAT = "Entity template name must only use alphanumeric characters, spaces, hyphens or underscores"; + // Entity Template validation messages + public static final String TEMPLATE_ALREADY_EXISTS = "An Entity Template already exists with the same identifier"; + public static final String TEMPLATE_IDENTIFIER_NOT_FOUND = "Target template with identifier '%s' does not exist."; + public static final String TEMPLATE_IDENTIFIER_MANDATORY = "Entity Template identifier is mandatory and cannot be blank"; + public static final String TEMPLATE_IDENTIFIER_CANNOT_CHANGE = "Entity Template identifier cannot be changed. Current identifier: "; + public static final String TEMPLATE_NAME_ALREADY_EXISTS = "The entity template name %s already exists"; + public static final String TEMPLATE_NAME_MANDATORY = "Entity template name is mandatory and cannot be blank"; + public static final String TEMPLATE_NAME_MAX_SIZE = "Entity template name must not exceed 255 characters"; + public static final String TEMPLATE_NAME_FORMAT = "Entity template name must only use alphanumeric characters, spaces, hyphens or underscores"; - // Property Definition validation messages - public static final String PROPERTY_NAME_MANDATORY = "Property name is mandatory and cannot be blank"; - public static final String PROPERTY_NAME_ALREADY_EXISTS = "Property name '%s' already exists within the template. Property names must be unique."; - public static final String PROPERTY_DESCRIPTION_MANDATORY = "Property description is mandatory and cannot be blank"; - public static final String PROPERTY_TYPE_MANDATORY = "Property type is mandatory"; - public static final String PROPERTY_VALUE_MANDATORY = "Property value is mandatory and cannot be blank"; - public static final String PROPERTY_REQUIRED_MISSING = "Property '%s' is required by template '%s'"; - public static final String PROPERTY_TYPE_MISMATCH = "Property '%s' must be of type %s"; - public static final String PROPERTY_MIN_LENGTH_VIOLATION = "Property '%s' length must be greater than or equal to %d"; - public static final String PROPERTY_MAX_LENGTH_VIOLATION = "Property '%s' length must be lower than or equal to %d"; - public static final String PROPERTY_MIN_VALUE_VIOLATION = "Property '%s' value must be greater than or equal to %d"; - public static final String PROPERTY_MAX_VALUE_VIOLATION = "Property '%s' value must be lower than or equal to %d"; - public static final String PROPERTY_REGEX_VIOLATION = "Property '%s' does not match expected format"; - public static final String PROPERTY_ENUM_VIOLATION = "Property '%s' must be one of %s"; - public static final String PROPERTY_FORMAT_VIOLATION = "Property '%s' does not match required format %s"; - public static final String PROPERTY_TYPE_CANNOT_CHANGE = "Cannot change type of property '%s' from %s to %s. Property types cannot be modified after creation. Please delete and recreate the property instead."; + // Property Definition validation messages + public static final String PROPERTY_NAME_MANDATORY = "Property name is mandatory and cannot be blank"; + public static final String PROPERTY_NAME_ALREADY_EXISTS = "Property name '%s' already exists within the template. Property names must be unique."; + public static final String PROPERTY_DESCRIPTION_MANDATORY = "Property description is mandatory and cannot be blank"; + public static final String PROPERTY_TYPE_MANDATORY = "Property type is mandatory"; + public static final String PROPERTY_VALUE_MANDATORY = "Property value is mandatory and cannot be blank"; + public static final String PROPERTY_REQUIRED_MISSING = "Property '%s' is required by template '%s'"; + public static final String PROPERTY_TYPE_MISMATCH = "Property '%s' must be of type %s"; + public static final String PROPERTY_MIN_LENGTH_VIOLATION = "Property '%s' length must be greater than or equal to %d"; + public static final String PROPERTY_MAX_LENGTH_VIOLATION = "Property '%s' length must be lower than or equal to %d"; + public static final String PROPERTY_MIN_VALUE_VIOLATION = "Property '%s' value must be greater than or equal to %d"; + public static final String PROPERTY_MAX_VALUE_VIOLATION = "Property '%s' value must be lower than or equal to %d"; + public static final String PROPERTY_REGEX_VIOLATION = "Property '%s' does not match expected format"; + public static final String PROPERTY_ENUM_VIOLATION = "Property '%s' must be one of %s"; + public static final String PROPERTY_FORMAT_VIOLATION = "Property '%s' does not match required format %s"; + public static final String PROPERTY_TYPE_CANNOT_CHANGE = "Cannot change type of property '%s' from %s to %s. Property types cannot be modified after creation. Please delete and recreate the property instead."; - // Relation Definition validation messages - public static final String RELATION_NAME_MANDATORY = "Relation name is mandatory and cannot be blank"; - public static final String RELATION_TARGET_IDENTIFIER_MANDATORY = "Target template identifier is mandatory and cannot be blank"; - public static final String RELATION_NAME_MANDATORY_SIMPLE = "Relation name is mandatory"; - public static final String RELATION_NAME_ALREADY_EXISTS = "Relation name '%s' already exists within the template. Relation names must be unique."; - public static final String RELATION_TARGET_IDENTIFIER_MANDATORY_SIMPLE = "Relation target identifier is mandatory"; - public static final String RELATION_TARGET_IDENTIFIERS_NOT_NULL = "Target entity identifiers cannot be null"; - public static final String RELATION_TARGET_TEMPLATE_CANNOT_CHANGE = "Cannot change target template of relation '%s' from '%s' to '%s'. Target template cannot be modified after creation. Please delete and recreate the relation instead."; - public static final String RELATION_CANNOT_TARGET_ITSELF = "Relation '%s' cannot reference its own template '%s' as the target."; + // Relation Definition validation messages + public static final String RELATION_NAME_MANDATORY = "Relation name is mandatory and cannot be blank"; + public static final String RELATION_TARGET_IDENTIFIER_MANDATORY = "Target template identifier is mandatory and cannot be blank"; + public static final String RELATION_NAME_MANDATORY_SIMPLE = "Relation name is mandatory"; + public static final String RELATION_NAME_ALREADY_EXISTS = "Relation name '%s' already exists within the template. Relation names must be unique."; + public static final String RELATION_TARGET_IDENTIFIER_MANDATORY_SIMPLE = "Relation target identifier is mandatory"; + public static final String RELATION_TARGET_IDENTIFIERS_NOT_NULL = "Target entity identifiers cannot be null"; + public static final String RELATION_TARGET_TEMPLATE_CANNOT_CHANGE = "Cannot change target template of relation '%s' from '%s' to '%s'. Target template cannot be modified after creation. Please delete and recreate the relation instead."; + public static final String RELATION_CANNOT_TARGET_ITSELF = "Relation '%s' cannot reference its own template '%s' as the target."; - // Property Rules validation messages - templates and specific constraints - public static final String PROPERTY_RULES_RULE_NOT_ALLOWED_FOR_TYPE = "{rule} rule is not allowed for {type} property type"; - public static final String PROPERTY_RULES_MIN_MAX_CONSTRAINT_VIOLATED = "min_{constraint} must be less than or equal to max_{constraint}"; - public static final String PROPERTY_RULES_MIN_LENGTH_NON_NEGATIVE = "min_length must be greater than or equal to 0"; - public static final String PROPERTY_RULES_MAX_LENGTH_POSITIVE = "max_length must be greater than 0"; - public static final String PROPERTY_RULES_BOOLEAN_NOT_ALLOWED = "Boolean properties do not accept any rules"; - public static final String PROPERTY_RULES_NUMERIC_RULE_NOT_ALLOWED = "Numeric rule {rule} is not allowed for STRING properties"; + // Property Rules validation messages - templates and specific constraints + public static final String PROPERTY_RULES_RULE_NOT_ALLOWED_FOR_TYPE = "{rule} rule is not allowed for {type} property type"; + public static final String PROPERTY_RULES_MIN_MAX_CONSTRAINT_VIOLATED = "min_{constraint} must be less than or equal to max_{constraint}"; + public static final String PROPERTY_RULES_MIN_LENGTH_NON_NEGATIVE = "min_length must be greater than or equal to 0"; + public static final String PROPERTY_RULES_MAX_LENGTH_POSITIVE = "max_length must be greater than 0"; + public static final String PROPERTY_RULES_BOOLEAN_NOT_ALLOWED = "Boolean properties do not accept any rules"; + public static final String PROPERTY_RULES_NUMERIC_RULE_NOT_ALLOWED = "Numeric rule {rule} is not allowed for STRING properties"; - // Entity input validation messages - public static final String ENTITY_NAME_MANDATORY = "Entity name is mandatory and cannot be blank"; - public static final String ENTITY_IDENTIFIER_MANDATORY = "Entity identifier is mandatory and cannot be blank"; + // Entity input validation messages + public static final String ENTITY_NAME_MANDATORY = "Entity name is mandatory and cannot be blank"; + public static final String ENTITY_IDENTIFIER_MANDATORY = "Entity identifier is mandatory and cannot be blank"; - // Entity creation validation messages - public static final String ENTITY_NOT_FOUND = "Entity not found with template identifier %s and entity identifier '%s'"; - public static final String ENTITY_ALREADY_EXISTS = "Entity with name '%s' already exists for template '%s'"; - public static final String ENTITY_VALIDATION_FAILED = "Entity validation failed: "; - public static final String PROPERTY_RULES_MUTUALLY_EXCLUSIVE = "{rule1} and {rule2} are mutually exclusive for STRING properties"; + // Entity creation validation messages + public static final String ENTITY_NOT_FOUND = "Entity not found with template identifier %s and entity identifier '%s'"; + public static final String ENTITY_ALREADY_EXISTS = "Entity with name '%s' already exists for template '%s'"; + public static final String ENTITY_VALIDATION_FAILED = "Entity validation failed: "; + public static final String PROPERTY_RULES_MUTUALLY_EXCLUSIVE = "{rule1} and {rule2} are mutually exclusive for STRING properties"; - // Helper method to construct rules incompatibility message - public static String rulesAreIncompatible(String rule1, String rule2) { - return PROPERTY_RULES_MUTUALLY_EXCLUSIVE - .replace("{rule1}", rule1) - .replace("{rule2}", rule2); - } + // Helper method to construct rules incompatibility message + public static String rulesAreIncompatible(String rule1, String rule2) { + return PROPERTY_RULES_MUTUALLY_EXCLUSIVE.replace("{rule1}", rule1).replace("{rule2}", rule2); + } - // Helper method to construct rule-not-allowed message - public static String ruleNotAllowed(String rule, String propertyType) { - return PROPERTY_RULES_RULE_NOT_ALLOWED_FOR_TYPE - .replace("{rule}", rule) - .replace("{type}", propertyType); - } + // Helper method to construct rule-not-allowed message + public static String ruleNotAllowed(String rule, String propertyType) { + return PROPERTY_RULES_RULE_NOT_ALLOWED_FOR_TYPE.replace("{rule}", rule).replace("{type}", + propertyType); + } - // Helper method to construct min/max constraint violation message - public static String minMaxConstraintViolated(String constraint) { - return PROPERTY_RULES_MIN_MAX_CONSTRAINT_VIOLATED - .replace("{constraint}", constraint); - } + // Helper method to construct min/max constraint violation message + public static String minMaxConstraintViolated(String constraint) { + return PROPERTY_RULES_MIN_MAX_CONSTRAINT_VIOLATED.replace("{constraint}", constraint); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationRegex.java b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationRegex.java index 42047e8a..0ea02980 100644 --- a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationRegex.java +++ b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationRegex.java @@ -6,6 +6,6 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public class ValidationRegex { - public static final String ENTITY_TEMPLATE_NAME_REGEX = "^[a-zA-Z0-9 _-]+$"; + public static final String ENTITY_TEMPLATE_NAME_REGEX = "^[a-zA-Z0-9 _-]+$"; } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityAlreadyExistsException.java index 82437486..fb139cfb 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityAlreadyExistsException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityAlreadyExistsException.java @@ -16,11 +16,11 @@ /// - Maintains template-entity relationship integrity public class EntityAlreadyExistsException extends RuntimeException { - /// Constructs a new exception with template and entity identifiers. - /// - /// @param templateIdentifier the identifier of the template - /// @param entityName the duplicate entity name - public EntityAlreadyExistsException(String templateIdentifier, String entityName) { - super(String.format(ENTITY_ALREADY_EXISTS, entityName, templateIdentifier)); - } + /// Constructs a new exception with template and entity identifiers. + /// + /// @param templateIdentifier the identifier of the template + /// @param entityName the duplicate entity name + public EntityAlreadyExistsException(String templateIdentifier, String entityName) { + super(String.format(ENTITY_ALREADY_EXISTS, entityName, templateIdentifier)); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityNotFoundException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityNotFoundException.java index 42c60f67..cea5f8eb 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityNotFoundException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityNotFoundException.java @@ -16,15 +16,17 @@ /// - Maintains template-entity relationship integrity public class EntityNotFoundException extends RuntimeException { - /// Constructs a new exception with template and entity identifiers. - /// - /// **Why this exists:** Provides standardized error message format that includes - /// both template and entity context for clear debugging and API error responses. - /// - /// @param templateIdentifier the identifier of the template - /// @param entityIdentifier the identifier of the entity - public EntityNotFoundException(String templateIdentifier, String entityIdentifier) { - super(String.format(ENTITY_NOT_FOUND, templateIdentifier, entityIdentifier)); - } + /// Constructs a new exception with template and entity identifiers. + /// + /// **Why this exists:** Provides standardized error message format that + /// includes + /// both template and entity context for clear debugging and API error + /// responses. + /// + /// @param templateIdentifier the identifier of the template + /// @param entityIdentifier the identifier of the entity + public EntityNotFoundException(String templateIdentifier, String entityIdentifier) { + super(String.format(ENTITY_NOT_FOUND, templateIdentifier, entityIdentifier)); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityValidationException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityValidationException.java index 42756f0e..00381203 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityValidationException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityValidationException.java @@ -23,21 +23,20 @@ @Getter public class EntityValidationException extends RuntimeException { - /** - * -- GETTER -- - * Returns the list of individual validation violation messages. - * /// - * /// - * @return immutable list of violation messages - */ - private final List violations; + /** + * -- GETTER -- Returns the list of individual validation violation messages. + * /// /// + * + * @return immutable list of violation messages + */ + private final List violations; - /// Constructs a new exception with a list of validation violation messages. - /// - /// @param violations the list of validation error messages - public EntityValidationException(List violations) { - super(ENTITY_VALIDATION_FAILED + String.join("; ", violations)); - this.violations = List.copyOf(violations); - } + /// Constructs a new exception with a list of validation violation messages. + /// + /// @param violations the list of validation error messages + public EntityValidationException(List violations) { + super(ENTITY_VALIDATION_FAILED + String.join("; ", violations)); + this.violations = List.copyOf(violations); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateAlreadyExistsException.java index c389889b..2cceb681 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateAlreadyExistsException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateAlreadyExistsException.java @@ -23,16 +23,19 @@ /// - Contains specific identifier that caused the conflict for debugging public class EntityTemplateAlreadyExistsException extends RuntimeException { - /// Constructs a new exception with the specific identifier that already exists. - /// - /// **Why this constructor exists:** - /// - Formats exception message to include the duplicate identifier for clear debugging - /// - Provides consistent error messaging across the application - /// - Enables API consumers to understand which specific identifier caused the conflict - /// - /// @param identifier the identifier that already exists in the system, must not be null - /// @throws IllegalArgumentException if identifier is null - public EntityTemplateAlreadyExistsException(String identifier) { - super(String.format(TEMPLATE_ALREADY_EXISTS + ":%s", identifier)); - } + /// Constructs a new exception with the specific identifier that already exists. + /// + /// **Why this constructor exists:** + /// - Formats exception message to include the duplicate identifier for clear + /// debugging + /// - Provides consistent error messaging across the application + /// - Enables API consumers to understand which specific identifier caused the + /// conflict + /// + /// @param identifier the identifier that already exists in the system, must not + /// be null + /// @throws IllegalArgumentException if identifier is null + public EntityTemplateAlreadyExistsException(String identifier) { + super(String.format(TEMPLATE_ALREADY_EXISTS + ":%s", identifier)); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateIdentifierCannotChangeException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateIdentifierCannotChangeException.java index 3d0a1491..b6bb0020 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateIdentifierCannotChangeException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateIdentifierCannotChangeException.java @@ -1,11 +1,11 @@ package com.decathlon.idp_core.domain.exception.entity_template; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.TEMPLATE_IDENTIFIER_CANNOT_CHANGE; + import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.TEMPLATE_IDENTIFIER_CANNOT_CHANGE; - /// Exception thrown when attempting to change an [EntityTemplate] identifier after creation. /// /// **Why this exception exists:** @@ -19,7 +19,7 @@ /// - Contains the identifier that was attempted to be changed for debugging public class EntityTemplateIdentifierCannotChangeException extends RuntimeException { - public EntityTemplateIdentifierCannotChangeException(String identifier) { - super(TEMPLATE_IDENTIFIER_CANNOT_CHANGE + identifier); - } + public EntityTemplateIdentifierCannotChangeException(String identifier) { + super(TEMPLATE_IDENTIFIER_CANNOT_CHANGE + identifier); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateNameAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateNameAlreadyExistsException.java index 12e34571..9732ab26 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateNameAlreadyExistsException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateNameAlreadyExistsException.java @@ -19,7 +19,7 @@ /// - Contains specific name that caused the conflict for debugging public class EntityTemplateNameAlreadyExistsException extends RuntimeException { - public EntityTemplateNameAlreadyExistsException(String name) { - super(String.format(TEMPLATE_NAME_ALREADY_EXISTS, name)); - } + public EntityTemplateNameAlreadyExistsException(String name) { + super(String.format(TEMPLATE_NAME_ALREADY_EXISTS, name)); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateNotFoundException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateNotFoundException.java index c765a4f4..4fe2c822 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateNotFoundException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateNotFoundException.java @@ -21,54 +21,57 @@ /// - Template management operations public class EntityTemplateNotFoundException extends RuntimeException { - /// Default constructor for generic template not found scenarios. - /// - /// **Why this exists:** Provides a fallback when specific template details - /// are not available but the business rule violation still needs to be reported. - public EntityTemplateNotFoundException() { - super("Template not found"); - } + /// Default constructor for generic template not found scenarios. + /// + /// **Why this exists:** Provides a fallback when specific template details + /// are not available but the business rule violation still needs to be + /// reported. + public EntityTemplateNotFoundException() { + super("Template not found"); + } - /// Constructs a new exception with a custom error message. - /// - /// **Why this exists:** Allows for specific error messages that provide more - /// context about the search criteria or operation that failed. - /// - /// @param message the detail message explaining what was not found - public EntityTemplateNotFoundException(String message) { - super(message); - } + /// Constructs a new exception with a custom error message. + /// + /// **Why this exists:** Allows for specific error messages that provide more + /// context about the search criteria or operation that failed. + /// + /// @param message the detail message explaining what was not found + public EntityTemplateNotFoundException(String message) { + super(message); + } - /// Constructs a new exception for a specific UUID-based lookup. - /// - /// **Why this exists:** Provides standardized error message format when - /// searching for a template by its primary key identifier. - /// - /// @param id the UUID of the template that was not found - public EntityTemplateNotFoundException(UUID id) { - super("Template not found with ID: " + id); - } + /// Constructs a new exception for a specific UUID-based lookup. + /// + /// **Why this exists:** Provides standardized error message format when + /// searching for a template by its primary key identifier. + /// + /// @param id the UUID of the template that was not found + public EntityTemplateNotFoundException(UUID id) { + super("Template not found with ID: " + id); + } - /// Constructs a new exception for field-based searches. - /// - /// **Why this exists:** Commonly used for business identifier searches where - /// the field name (for example, "identifier") and its value are known, providing - /// clear context about what search criteria failed. - /// - /// @param fieldName the name of the field used in the search (for example, "identifier") - /// @param value the value that was searched for but not found - public EntityTemplateNotFoundException(String fieldName, String value) { - super("Template not found with " + fieldName + ": " + value); - } + /// Constructs a new exception for field-based searches. + /// + /// **Why this exists:** Commonly used for business identifier searches where + /// the field name (for example, "identifier") and its value are known, + /// providing + /// clear context about what search criteria failed. + /// + /// @param fieldName the name of the field used in the search (for example, + /// "identifier") + /// @param value the value that was searched for but not found + public EntityTemplateNotFoundException(String fieldName, String value) { + super("Template not found with " + fieldName + ": " + value); + } - /// Constructs a new exception with a custom message and underlying cause. - /// - /// **Why this exists:** Used when the exception wraps another exception or - /// when additional context about the underlying cause is needed for debugging. - /// - /// @param message the detail message explaining what was not found - /// @param cause the underlying cause of this exception - public EntityTemplateNotFoundException(String message, Throwable cause) { - super(message, cause); - } + /// Constructs a new exception with a custom message and underlying cause. + /// + /// **Why this exists:** Used when the exception wraps another exception or + /// when additional context about the underlying cause is needed for debugging. + /// + /// @param message the detail message explaining what was not found + /// @param cause the underlying cause of this exception + public EntityTemplateNotFoundException(String message, Throwable cause) { + super(message, cause); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyDefinitionRulesConflictException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyDefinitionRulesConflictException.java index 650637df..2ce1db43 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyDefinitionRulesConflictException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyDefinitionRulesConflictException.java @@ -13,13 +13,13 @@ /// - Property template updates introducing rule conflicts public class PropertyDefinitionRulesConflictException extends RuntimeException { - /// Constructs a new exception for rule type conflict. - /// - /// @param propertyName the name of the property with invalid rules - /// @param propertyType the data type of the property - /// @param violationMessage detailed explanation of what rule is invalid - public PropertyDefinitionRulesConflictException(String propertyName, PropertyType propertyType, String violationMessage) { - super("Property '" + propertyName + "' of type " + propertyType + - ": " + violationMessage); - } + /// Constructs a new exception for rule type conflict. + /// + /// @param propertyName the name of the property with invalid rules + /// @param propertyType the data type of the property + /// @param violationMessage detailed explanation of what rule is invalid + public PropertyDefinitionRulesConflictException(String propertyName, PropertyType propertyType, + String violationMessage) { + super("Property '" + propertyName + "' of type " + propertyType + ": " + violationMessage); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyNameAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyNameAlreadyExistsException.java index 23999269..54ccc36a 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyNameAlreadyExistsException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyNameAlreadyExistsException.java @@ -1,9 +1,9 @@ package com.decathlon.idp_core.domain.exception.entity_template; -import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; - import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_NAME_ALREADY_EXISTS; +import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; + /// Exception thrown when attempting to create or update an [EntityTemplate] with duplicate property names. /// /// This exception represents a business rule violation where unique constraints on property @@ -15,10 +15,10 @@ /// - Mapped to HTTP 400 Bad Request by [ApiExceptionHandler] public class PropertyNameAlreadyExistsException extends RuntimeException { - /// Constructs a new exception with the duplicate property name. - /// - /// @param propertyName the property name that appears more than once - public PropertyNameAlreadyExistsException(String propertyName) { - super(String.format(PROPERTY_NAME_ALREADY_EXISTS, propertyName)); - } + /// Constructs a new exception with the duplicate property name. + /// + /// @param propertyName the property name that appears more than once + public PropertyNameAlreadyExistsException(String propertyName) { + super(String.format(PROPERTY_NAME_ALREADY_EXISTS, propertyName)); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyTypeChangeException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyTypeChangeException.java index b5432729..44d8d2a7 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyTypeChangeException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyTypeChangeException.java @@ -15,12 +15,13 @@ /// - Mapped to HTTP 400 Bad Request by [ApiExceptionHandler] public class PropertyTypeChangeException extends RuntimeException { - /// Constructs a new exception for a type conversion. - /// - /// @param propertyName the name of the property whose type is being changed - /// @param fromType the current property type - /// @param toType the requested new property type - public PropertyTypeChangeException(String propertyName, PropertyType fromType, PropertyType toType) { - super(String.format(PROPERTY_TYPE_CANNOT_CHANGE, propertyName, fromType, toType)); - } + /// Constructs a new exception for a type conversion. + /// + /// @param propertyName the name of the property whose type is being changed + /// @param fromType the current property type + /// @param toType the requested new property type + public PropertyTypeChangeException(String propertyName, PropertyType fromType, + PropertyType toType) { + super(String.format(PROPERTY_TYPE_CANNOT_CHANGE, propertyName, fromType, toType)); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/RelationCannotTargetItselfException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/RelationCannotTargetItselfException.java index 0143ca4d..978a136a 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/RelationCannotTargetItselfException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/RelationCannotTargetItselfException.java @@ -11,11 +11,12 @@ /// - Mapped to HTTP 400 Bad Request by [ApiExceptionHandler] public class RelationCannotTargetItselfException extends RuntimeException { - /// Constructs a new exception for a self-referential relation attempt. - /// - /// @param relationName the name of the relation pointing to its own template - /// @param templateIdentifier the identifier of the template that is both owner and target - public RelationCannotTargetItselfException(String relationName, String templateIdentifier) { - super(String.format(RELATION_CANNOT_TARGET_ITSELF, relationName, templateIdentifier)); - } + /// Constructs a new exception for a self-referential relation attempt. + /// + /// @param relationName the name of the relation pointing to its own template + /// @param templateIdentifier the identifier of the template that is both owner + /// and target + public RelationCannotTargetItselfException(String relationName, String templateIdentifier) { + super(String.format(RELATION_CANNOT_TARGET_ITSELF, relationName, templateIdentifier)); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/RelationNameAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/RelationNameAlreadyExistsException.java index 76cf4a6e..97d5f99a 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/RelationNameAlreadyExistsException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/RelationNameAlreadyExistsException.java @@ -1,9 +1,9 @@ package com.decathlon.idp_core.domain.exception.entity_template; -import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; - import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_NAME_ALREADY_EXISTS; +import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; + /// Exception thrown when attempting to create or update an [EntityTemplate] with duplicate relation names. /// /// This exception represents a business rule violation where unique constraints on relation @@ -15,10 +15,10 @@ /// - Mapped to HTTP 400 Bad Request by [ApiExceptionHandler] public class RelationNameAlreadyExistsException extends RuntimeException { - /// Constructs a new exception with the duplicate relation name. - /// - /// @param relationName the relation name that appears more than once - public RelationNameAlreadyExistsException(String relationName) { - super(String.format(RELATION_NAME_ALREADY_EXISTS, relationName)); - } + /// Constructs a new exception with the duplicate relation name. + /// + /// @param relationName the relation name that appears more than once + public RelationNameAlreadyExistsException(String relationName) { + super(String.format(RELATION_NAME_ALREADY_EXISTS, relationName)); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/RelationTargetTemplateChangeException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/RelationTargetTemplateChangeException.java index bd194f21..36a6c5e4 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/RelationTargetTemplateChangeException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/RelationTargetTemplateChangeException.java @@ -14,12 +14,14 @@ /// - Mapped to HTTP 400 Bad Request by [ApiExceptionHandler] public class RelationTargetTemplateChangeException extends RuntimeException { - /// Constructs a new exception for a target template change attempt. - /// - /// @param relationName the name of the relation whose target is being changed - /// @param fromTarget the current target template identifier - /// @param toTarget the requested new target template identifier - public RelationTargetTemplateChangeException(String relationName, String fromTarget, String toTarget) { - super(String.format(RELATION_TARGET_TEMPLATE_CANNOT_CHANGE, relationName, fromTarget, toTarget)); - } + /// Constructs a new exception for a target template change attempt. + /// + /// @param relationName the name of the relation whose target is being changed + /// @param fromTarget the current target template identifier + /// @param toTarget the requested new target template identifier + public RelationTargetTemplateChangeException(String relationName, String fromTarget, + String toTarget) { + super( + String.format(RELATION_TARGET_TEMPLATE_CANNOT_CHANGE, relationName, fromTarget, toTarget)); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/TargetTemplateNotFoundException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/TargetTemplateNotFoundException.java index df60b3c6..bc82fd66 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/TargetTemplateNotFoundException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/TargetTemplateNotFoundException.java @@ -13,10 +13,12 @@ /// - Mapped to HTTP 400 Bad Request by [ApiExceptionHandler] public class TargetTemplateNotFoundException extends RuntimeException { - /// Constructs a new exception with the target template identifier that was not found. - /// - /// @param targetTemplateIdentifier the identifier of the target template that doesn't exist - public TargetTemplateNotFoundException(String targetTemplateIdentifier) { - super(String.format(TEMPLATE_IDENTIFIER_NOT_FOUND, targetTemplateIdentifier)); - } + /// Constructs a new exception with the target template identifier that was not + /// found. + /// + /// @param targetTemplateIdentifier the identifier of the target template that + /// doesn't exist + public TargetTemplateNotFoundException(String targetTemplateIdentifier) { + super(String.format(TEMPLATE_IDENTIFIER_NOT_FOUND, targetTemplateIdentifier)); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java b/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java index 3ce489ed..737c7c84 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java @@ -13,13 +13,13 @@ /// - Property template updates introducing rule conflicts public class PropertyDefinitionRulesConflictException extends RuntimeException { - /// Constructs a new exception for rule type conflict. - /// - /// @param propertyName the name of the property with invalid rules - /// @param propertyType the data type of the property - /// @param violationMessage detailed explanation of what rule is invalid - public PropertyDefinitionRulesConflictException(String propertyName, PropertyType propertyType, String violationMessage) { - super("Property '" + propertyName + "' of type " + propertyType + - ": " + violationMessage); - } + /// Constructs a new exception for rule type conflict. + /// + /// @param propertyName the name of the property with invalid rules + /// @param propertyType the data type of the property + /// @param violationMessage detailed explanation of what rule is invalid + public PropertyDefinitionRulesConflictException(String propertyName, PropertyType propertyType, + String violationMessage) { + super("Property '" + propertyName + "' of type " + propertyType + ": " + violationMessage); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java index 2292ecd4..648df7a1 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java @@ -7,10 +7,10 @@ import java.util.List; import java.util.UUID; -import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; - import jakarta.validation.constraints.NotBlank; +import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; + /// Domain entity representing a concrete instance of an [EntityTemplate]. /// /// Business invariants: @@ -22,24 +22,21 @@ /// Ubiquitous language: An Entity is a materialized instance of a template schema, /// containing actual values that comply with the template's structure and rules. -public record Entity( - UUID id, - - @NotBlank(message = TEMPLATE_IDENTIFIER_MANDATORY) - String templateIdentifier, - @NotBlank(message = ENTITY_NAME_MANDATORY) - String name, - @NotBlank(message = ENTITY_IDENTIFIER_MANDATORY) - String identifier, - - List properties, - - List relations -) { - /// Compact constructor: defensively copies mutable lists to prevent external mutation - /// and guarantee immutability of the domain model (EI_EXPOSE_REP2 / EI_EXPOSE_REP). - public Entity { - properties = properties == null ? List.of() : List.copyOf(properties); - relations = relations == null ? List.of() : List.copyOf(relations); - } +public record Entity(UUID id, + + @NotBlank(message = TEMPLATE_IDENTIFIER_MANDATORY) String templateIdentifier, + @NotBlank(message = ENTITY_NAME_MANDATORY) String name, + @NotBlank(message = ENTITY_IDENTIFIER_MANDATORY) String identifier, + + List properties, + + List relations) { + /// Compact constructor: defensively copies mutable lists to prevent external + /// mutation + /// and guarantee immutability of the domain model (EI_EXPOSE_REP2 / + /// EI_EXPOSE_REP). + public Entity { + properties = properties == null ? List.of() : List.copyOf(properties); + relations = relations == null ? List.of() : List.copyOf(relations); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityCompositeKey.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityCompositeKey.java index 30a0f994..db38bde6 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityCompositeKey.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityCompositeKey.java @@ -3,34 +3,36 @@ import java.util.Objects; /** - * Composite key for uniquely identifying an entity across templates. - * Since the same identifier can exist in different templates, we need both fields. + * Composite key for uniquely identifying an entity across templates. Since the + * same identifier can exist in different templates, we need both fields. */ public record EntityCompositeKey(String templateIdentifier, String identifier) { - public static EntityCompositeKey fromString(String compositeKey) { - String[] parts = compositeKey.split(":", 2); - if (parts.length != 2) { - throw new IllegalArgumentException("Invalid composite key format: " + compositeKey); - } - return new EntityCompositeKey(parts[0], parts[1]); + public static EntityCompositeKey fromString(String compositeKey) { + String[] parts = compositeKey.split(":", 2); + if (parts.length != 2) { + throw new IllegalArgumentException("Invalid composite key format: " + compositeKey); } + return new EntityCompositeKey(parts[0], parts[1]); + } - @Override - public String toString() { - return templateIdentifier + ":" + identifier; - } + @Override + public String toString() { + return templateIdentifier + ":" + identifier; + } - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - EntityCompositeKey that = (EntityCompositeKey) o; - return Objects.equals(templateIdentifier, that.templateIdentifier) && - Objects.equals(identifier, that.identifier); - } + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + EntityCompositeKey that = (EntityCompositeKey) o; + return Objects.equals(templateIdentifier, that.templateIdentifier) + && Objects.equals(identifier, that.identifier); + } - @Override - public int hashCode() { - return Objects.hash(templateIdentifier, identifier); - } + @Override + public int hashCode() { + return Objects.hash(templateIdentifier, identifier); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/EntitySummary.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/EntitySummary.java index d4b3569f..353afb4a 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/EntitySummary.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/EntitySummary.java @@ -14,4 +14,5 @@ /// - Relationship target references /// - Performance-optimized read operations where full entity data isn't required @Builder -public record EntitySummary(String identifier, String name, String templateIdentifier) {} +public record EntitySummary(String identifier, String name, String templateIdentifier) { +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java index 5e7281ed..85cafccd 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java @@ -4,12 +4,12 @@ import java.util.UUID; +import jakarta.validation.constraints.NotBlank; + import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; import com.decathlon.idp_core.domain.model.entity_template.PropertyRules; import com.decathlon.idp_core.domain.model.enums.PropertyType; -import jakarta.validation.constraints.NotBlank; - /// A concrete property instance belonging to an [Entity]. /// /// Represents actual business data values that conform to the constraints defined @@ -23,12 +23,9 @@ /// - Property values must be typed according to the template's [PropertyType] definition /// (carried as [Object] so the original JSON type — String, Number, Boolean — is preserved /// for strict type-mismatch detection at validation time). -public record Property( - UUID id, +public record Property(UUID id, - @NotBlank(message = PROPERTY_NAME_MANDATORY) - String name, + @NotBlank(message = PROPERTY_NAME_MANDATORY) String name, - String value -) { + String value) { } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/Relation.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/Relation.java index e9ae654f..f5c9a2a9 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/Relation.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/Relation.java @@ -7,12 +7,12 @@ import java.util.List; import java.util.UUID; -import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; -import com.decathlon.idp_core.domain.model.entity_template.RelationDefinition; - import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; +import com.decathlon.idp_core.domain.model.entity_template.RelationDefinition; + /// A concrete relationship instance connecting entities in the business domain. /// /// Represents actual business connections between entities that conform to the @@ -25,25 +25,22 @@ /// - Required relations cannot have empty target lists /// - Multiple targets allowed only when template's `toMany` is true /// - Target template identifiers must reference valid [EntityTemplate] identifiers -public record Relation( - UUID id, - - @NotBlank(message = RELATION_NAME_MANDATORY_SIMPLE) - String name, - - @NotBlank(message = RELATION_TARGET_IDENTIFIER_MANDATORY_SIMPLE) - String targetTemplateIdentifier, - - @NotNull(message = RELATION_TARGET_IDENTIFIERS_NOT_NULL) - List targetEntityIdentifiers -) { - /// Ensures immutable defensive copying of target entity identifiers. - /// - /// **Why this exists:** Prevents external mutation of relationship targets after - /// construction, maintaining referential integrity in the business object graph. - public Relation { - targetEntityIdentifiers = targetEntityIdentifiers != null - ? List.copyOf(targetEntityIdentifiers) - : List.of(); - } +public record Relation(UUID id, + + @NotBlank(message = RELATION_NAME_MANDATORY_SIMPLE) String name, + + @NotBlank(message = RELATION_TARGET_IDENTIFIER_MANDATORY_SIMPLE) String targetTemplateIdentifier, + + @NotNull(message = RELATION_TARGET_IDENTIFIERS_NOT_NULL) List targetEntityIdentifiers) { + /// Ensures immutable defensive copying of target entity identifiers. + /// + /// **Why this exists:** Prevents external mutation of relationship targets + /// after + /// construction, maintaining referential integrity in the business object + /// graph. + public Relation { + targetEntityIdentifiers = targetEntityIdentifiers != null + ? List.copyOf(targetEntityIdentifiers) + : List.of(); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/RelationAsTargetSummary.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/RelationAsTargetSummary.java index a1a5ea6a..b38af3ed 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/RelationAsTargetSummary.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/RelationAsTargetSummary.java @@ -11,9 +11,6 @@ /// - Dependency impact analysis before entity deletion /// - Bidirectional relationship navigation /// - Audit trails for relationship changes -public record RelationAsTargetSummary( - String targetEntityIdentifier, - String relationName, - String sourceEntityIdentifier, - String sourceEntityName -) {} +public record RelationAsTargetSummary(String targetEntityIdentifier, String relationName, + String sourceEntityIdentifier, String sourceEntityName) { +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java index fff35643..8b3266b3 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java @@ -18,17 +18,12 @@ /// @param properties the entity's property instances; empty when not requested /// @param relations the resolved outbound relations with their target graph nodes /// @param relationsAsTarget incoming relations where this entity is the target -public record EntityGraphNode( - String templateIdentifier, - String identifier, - String name, - List properties, - List relations, - List relationsAsTarget -) { - public EntityGraphNode { - properties = properties != null ? List.copyOf(properties) : List.of(); - relations = relations != null ? List.copyOf(relations) : List.of(); - relationsAsTarget = relationsAsTarget != null ? List.copyOf(relationsAsTarget) : List.of(); - } +public record EntityGraphNode(String templateIdentifier, String identifier, String name, + List properties, List relations, + List relationsAsTarget) { + public EntityGraphNode { + properties = properties != null ? List.copyOf(properties) : List.of(); + relations = relations != null ? List.copyOf(relations) : List.of(); + relationsAsTarget = relationsAsTarget != null ? List.copyOf(relationsAsTarget) : List.of(); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphRelation.java b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphRelation.java index d770639d..e9b25fee 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphRelation.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphRelation.java @@ -11,11 +11,8 @@ /// @param name the relation name as defined in the entity template /// @param targetTemplateIdentifier the template identifier of the target entities /// @param targets the resolved target entity graph nodes (recursively populated up to depth) -public record EntityGraphRelation( - String name, - List targets -) { - public EntityGraphRelation { - targets = targets != null ? List.copyOf(targets) : List.of(); - } +public record EntityGraphRelation(String name, List targets) { + public EntityGraphRelation { + targets = targets != null ? List.copyOf(targets) : List.of(); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity_template/EntityTemplate.java b/src/main/java/com/decathlon/idp_core/domain/model/entity_template/EntityTemplate.java index 2d694f10..9dc59ce6 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity_template/EntityTemplate.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_template/EntityTemplate.java @@ -26,27 +26,27 @@ /// - Relation names must be unique within the template (if any) /// - All property definitions must have valid types and constraints /// - Relations must reference valid target template identifiers -public record EntityTemplate( - UUID id, +public record EntityTemplate(UUID id, - @NotBlank(message = TEMPLATE_IDENTIFIER_MANDATORY) - String identifier, + @NotBlank(message = TEMPLATE_IDENTIFIER_MANDATORY) String identifier, - @Size(max = 255, message = TEMPLATE_NAME_MAX_SIZE) - @NotBlank(message = TEMPLATE_NAME_MANDATORY) - @Pattern(regexp = ENTITY_TEMPLATE_NAME_REGEX, message = TEMPLATE_NAME_FORMAT) - String name, + @Size(max = 255, message = TEMPLATE_NAME_MAX_SIZE) @NotBlank(message = TEMPLATE_NAME_MANDATORY) @Pattern(regexp = ENTITY_TEMPLATE_NAME_REGEX, message = TEMPLATE_NAME_FORMAT) String name, - String description, + String description, - List propertiesDefinitions, + List propertiesDefinitions, - List relationsDefinitions -) { - /// Compact constructor: defensively copies mutable lists to prevent external mutation - /// and guarantee immutability of the domain model (EI_EXPOSE_REP2 / EI_EXPOSE_REP). - public EntityTemplate { - propertiesDefinitions = propertiesDefinitions == null ? List.of() : List.copyOf(propertiesDefinitions); - relationsDefinitions = relationsDefinitions == null ? List.of() : List.copyOf(relationsDefinitions); - } + List relationsDefinitions) { + /// Compact constructor: defensively copies mutable lists to prevent external + /// mutation + /// and guarantee immutability of the domain model (EI_EXPOSE_REP2 / + /// EI_EXPOSE_REP). + public EntityTemplate { + propertiesDefinitions = propertiesDefinitions == null + ? List.of() + : List.copyOf(propertiesDefinitions); + relationsDefinitions = relationsDefinitions == null + ? List.of() + : List.copyOf(relationsDefinitions); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity_template/PropertyDefinition.java b/src/main/java/com/decathlon/idp_core/domain/model/entity_template/PropertyDefinition.java index 5167811d..f96b1084 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity_template/PropertyDefinition.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_template/PropertyDefinition.java @@ -6,11 +6,11 @@ import java.util.UUID; -import com.decathlon.idp_core.domain.model.enums.PropertyType; - import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import com.decathlon.idp_core.domain.model.enums.PropertyType; + /// Defines the structure and constraints for a property within an [EntityTemplate]. /// /// Part of the domain's ubiquitous language where each property represents a business @@ -22,20 +22,15 @@ /// - Required properties cannot be null/empty when creating entities /// - Validation rules in [PropertyRules] are enforced for all property values /// - Property descriptions support business documentation and user guidance -public record PropertyDefinition( - UUID id, +public record PropertyDefinition(UUID id, - @NotBlank(message = PROPERTY_NAME_MANDATORY) - String name, + @NotBlank(message = PROPERTY_NAME_MANDATORY) String name, - @NotBlank(message = PROPERTY_DESCRIPTION_MANDATORY) - String description, + @NotBlank(message = PROPERTY_DESCRIPTION_MANDATORY) String description, - @NotNull(message = PROPERTY_TYPE_MANDATORY) - PropertyType type, + @NotNull(message = PROPERTY_TYPE_MANDATORY) PropertyType type, boolean required, - PropertyRules rules -) { + PropertyRules rules) { } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity_template/PropertyRules.java b/src/main/java/com/decathlon/idp_core/domain/model/entity_template/PropertyRules.java index cd4a30ea..ffc7df10 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity_template/PropertyRules.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_template/PropertyRules.java @@ -17,21 +17,14 @@ /// - Numeric constraints: `minValue` ≤ actual value ≤ `maxValue` /// - Enumeration constraints: values must be in `enumValues` list when specified /// - Regular expression patterns provide additional validation when `regex` is defined -public record PropertyRules( - UUID id, - PropertyFormat format, - List enumValues, - String regex, - Integer maxLength, - Integer minLength, - Integer maxValue, - Integer minValue -) { - /// Ensures immutable defensive copying of enumeration values. - /// - /// **Why this exists:** Prevents external mutation of enum constraints after construction, - /// maintaining business rule integrity throughout the entity lifecycle. - public PropertyRules { - enumValues = enumValues != null ? List.copyOf(enumValues) : null; - } +public record PropertyRules(UUID id, PropertyFormat format, List enumValues, String regex, + Integer maxLength, Integer minLength, Integer maxValue, Integer minValue) { + /// Ensures immutable defensive copying of enumeration values. + /// + /// **Why this exists:** Prevents external mutation of enum constraints after + /// construction, + /// maintaining business rule integrity throughout the entity lifecycle. + public PropertyRules { + enumValues = enumValues != null ? List.copyOf(enumValues) : null; + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity_template/RelationDefinition.java b/src/main/java/com/decathlon/idp_core/domain/model/entity_template/RelationDefinition.java index 2a736a94..3f02fa63 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity_template/RelationDefinition.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_template/RelationDefinition.java @@ -19,17 +19,13 @@ /// - Required relations cannot be null when creating entities /// - `toMany` relationships allow multiple target connections (one-to-many/many-to-many) /// - `!toMany` relationships enforce single target connections (one-to-one/many-to-one) -public record RelationDefinition( - UUID id, +public record RelationDefinition(UUID id, - @NotBlank(message = RELATION_NAME_MANDATORY_SIMPLE) - String name, + @NotBlank(message = RELATION_NAME_MANDATORY_SIMPLE) String name, - @NotBlank(message = RELATION_TARGET_IDENTIFIER_MANDATORY_SIMPLE) - String targetTemplateIdentifier, + @NotBlank(message = RELATION_TARGET_IDENTIFIER_MANDATORY_SIMPLE) String targetTemplateIdentifier, boolean required, - boolean toMany -) { + boolean toMany) { } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/enums/PropertyFormat.java b/src/main/java/com/decathlon/idp_core/domain/model/enums/PropertyFormat.java index c40981b9..3022c88d 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/enums/PropertyFormat.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/enums/PropertyFormat.java @@ -13,6 +13,5 @@ /// - Provides consistent validation across the domain /// - Supports integration with external systems requiring specific formats public enum PropertyFormat { - URL, - EMAIL + URL, EMAIL } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/enums/PropertyType.java b/src/main/java/com/decathlon/idp_core/domain/model/enums/PropertyType.java index d4e7530c..0e12913c 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/enums/PropertyType.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/enums/PropertyType.java @@ -13,7 +13,5 @@ /// - Provides consistent data representation across persistence and APIs /// - Supports validation rule application based on data type public enum PropertyType { - STRING, - NUMBER, - BOOLEAN + STRING, NUMBER, BOOLEAN } diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java index 82996a2a..b081a33c 100644 --- a/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java @@ -21,26 +21,30 @@ /// as this port performs no write operations. public interface EntityGraphRepositoryPort { - /// Fetches all entities in the relationship graph rooted at the given composite key. - /// - /// Uses a recursive CTE to traverse both outbound and inbound relations up to the - /// specified depth, then batch-loads all entities in a minimal number of queries. - /// - /// @param templateIdentifier the template identifier of the root entity - /// @param entityIdentifier the business identifier of the root entity within its template - /// @param depth the maximum traversal depth (1-10) - /// @param includeProperties when true, entity properties are loaded along with relations; - /// when false, only relations are fetched for a leaner query - /// @param relationNames when non-empty, only edges whose relation name is in this set are - /// traversed; when empty, all relation types are followed - /// @return map of [EntityCompositeKey] to [Entity] for O(1) lookup; empty if root not found - /// Relation name filtering is intentionally NOT pushed into this port. - /// The CTE always traverses all relation types so that nodes reachable via - /// any path are loaded. Edge filtering is applied in the service layer so - /// that "filter owns" still returns B and C when A→(depends-on)→B→(owns)→C. - Map findEntityGraph( - String templateIdentifier, - String entityIdentifier, - int depth, - boolean includeProperties); + /// Fetches all entities in the relationship graph rooted at the given composite + /// key. + /// + /// Uses a recursive CTE to traverse both outbound and inbound relations up to + /// the + /// specified depth, then batch-loads all entities in a minimal number of + /// queries. + /// + /// @param templateIdentifier the template identifier of the root entity + /// @param entityIdentifier the business identifier of the root entity within + /// its template + /// @param depth the maximum traversal depth (1-10) + /// @param includeProperties when true, entity properties are loaded along with + /// relations; + /// when false, only relations are fetched for a leaner query + /// @param relationNames when non-empty, only edges whose relation name is in + /// this set are + /// traversed; when empty, all relation types are followed + /// @return map of [EntityCompositeKey] to [Entity] for O(1) lookup; empty if + /// root not found + /// Relation name filtering is intentionally NOT pushed into this port. + /// The CTE always traverses all relation types so that nodes reachable via + /// any path are loaded. Edge filtering is applied in the service layer so + /// that "filter owns" still returns B and C when A→(depends-on)→B→(owns)→C. + Map findEntityGraph(String templateIdentifier, + String entityIdentifier, int depth, boolean includeProperties); } 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 7ba98f50..05a005f0 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 @@ -27,21 +27,24 @@ /// appropriately for the underlying persistence technology. public interface EntityRepositoryPort { - Entity save(Entity entity); + Entity save(Entity entity); - Optional findById(UUID id); + Optional findById(UUID id); - Optional findByTemplateIdentifierAndIdentifier(String templateIdentifier, String identifier); + Optional findByTemplateIdentifierAndIdentifier(String templateIdentifier, + String identifier); - Optional findByTemplateIdentifierAndName(String templateIdentifier, String entityName); + Optional findByTemplateIdentifierAndName(String templateIdentifier, String entityName); - Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable); + Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable); - List findByIdentifierIn(List identifiers); + List findByIdentifierIn(List identifiers); - List findByRelationIdIn(List relationIds); + List findByRelationIdIn(List relationIds); - void deletePropertiesByTemplateIdentifierAndPropertyName(String templateIdentifier, Collection propertyNames); + void deletePropertiesByTemplateIdentifierAndPropertyName(String templateIdentifier, + Collection propertyNames); - void deleteRelationsByTemplateIdentifierAndRelationName(String templateIdentifier, Collection relationNames); + void deleteRelationsByTemplateIdentifierAndRelationName(String templateIdentifier, + Collection relationNames); } diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityTemplateRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityTemplateRepositoryPort.java index 6213370b..5f4f9106 100644 --- a/src/main/java/com/decathlon/idp_core/domain/port/EntityTemplateRepositoryPort.java +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityTemplateRepositoryPort.java @@ -22,17 +22,17 @@ /// and handle referential integrity with existing entities. public interface EntityTemplateRepositoryPort { - Optional findByIdentifier(String templateIdentifier); + Optional findByIdentifier(String templateIdentifier); - Optional findById(UUID id); + Optional findById(UUID id); - Page findAll(Pageable pageable); + Page findAll(Pageable pageable); - boolean existsByIdentifier(String identifier); + boolean existsByIdentifier(String identifier); - boolean existsByName(String name); + boolean existsByName(String name); - EntityTemplate save(EntityTemplate entityTemplate); + EntityTemplate save(EntityTemplate entityTemplate); - void deleteByIdentifier(String identifier); + void deleteByIdentifier(String identifier); } diff --git a/src/main/java/com/decathlon/idp_core/domain/port/RelationRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/RelationRepositoryPort.java index 9216092a..09cad71d 100644 --- a/src/main/java/com/decathlon/idp_core/domain/port/RelationRepositoryPort.java +++ b/src/main/java/com/decathlon/idp_core/domain/port/RelationRepositoryPort.java @@ -16,6 +16,6 @@ /// and bidirectional navigation through the entity relationship graph. public interface RelationRepositoryPort { - List findRelationsSummariesByTargetEntityIdentifiers( - List targetEntityIdentifiers); + List findRelationsSummariesByTargetEntityIdentifiers( + List targetEntityIdentifiers); } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/RelationService.java b/src/main/java/com/decathlon/idp_core/domain/service/RelationService.java index bdc934aa..79a8c119 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/RelationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/RelationService.java @@ -23,18 +23,21 @@ @AllArgsConstructor public class RelationService { - private final RelationRepositoryPort relationRepository; - - /// Finds all incoming relationships where specified entities are targets. - /// - /// **Contract:** Returns relationship summaries for dependency analysis and - /// impact assessment. Useful for understanding entity interconnections before - /// deletion or modification operations. - /// - /// @param targetEntityIdentifiers business identifiers of entities to analyze - /// @return relationship summaries showing incoming connections to target entities - public List findRelationsSummariesByTargetEntityIdentifiers(List targetEntityIdentifiers) { - return relationRepository.findRelationsSummariesByTargetEntityIdentifiers(targetEntityIdentifiers); - } + private final RelationRepositoryPort relationRepository; + + /// Finds all incoming relationships where specified entities are targets. + /// + /// **Contract:** Returns relationship summaries for dependency analysis and + /// impact assessment. Useful for understanding entity interconnections before + /// deletion or modification operations. + /// + /// @param targetEntityIdentifiers business identifiers of entities to analyze + /// @return relationship summaries showing incoming connections to target + /// entities + public List findRelationsSummariesByTargetEntityIdentifiers( + List targetEntityIdentifiers) { + return relationRepository + .findRelationsSummariesByTargetEntityIdentifiers(targetEntityIdentifiers); + } } 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 72e40ad1..e11a767b 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java @@ -2,6 +2,9 @@ import java.util.List; +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; @@ -18,8 +21,6 @@ import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; -import jakarta.transaction.Transactional; -import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; /// Domain service orchestrating [Entity] business operations and validations. @@ -37,74 +38,83 @@ @Validated @RequiredArgsConstructor public class EntityService { - private final EntityRepositoryPort entityRepository; - private final EntityValidationService entityValidationService; - private final EntityTemplateValidationService entityTemplateValidationService; - private final EntityTemplateService entityTemplateService; - - /// Retrieves entities filtered by template with existence validation. - /// - /// **Contract:** Returns paginated entities that conform to the specified template. - /// Template existence is validated first to ensure meaningful results. - /// - /// @param pageable pagination configuration for large entity sets - /// @param templateIdentifier business identifier of the entity template - /// @return paginated entities matching the template - /// @throws EntityTemplateNotFoundException when template doesn't exist - @Transactional - public Page getEntitiesByTemplateIdentifier(Pageable pageable, String templateIdentifier) { - entityTemplateValidationService.validateTemplateExists(templateIdentifier); - return entityRepository.findByTemplateIdentifier(templateIdentifier, pageable); + private final EntityRepositoryPort entityRepository; + private final EntityValidationService entityValidationService; + private final EntityTemplateValidationService entityTemplateValidationService; + private final EntityTemplateService entityTemplateService; - } + /// Retrieves entities filtered by template with existence validation. + /// + /// **Contract:** Returns paginated entities that conform to the specified + /// template. + /// Template existence is validated first to ensure meaningful results. + /// + /// @param pageable pagination configuration for large entity sets + /// @param templateIdentifier business identifier of the entity template + /// @return paginated entities matching the template + /// @throws EntityTemplateNotFoundException when template doesn't exist + @Transactional + public Page getEntitiesByTemplateIdentifier(Pageable pageable, + String templateIdentifier) { + entityTemplateValidationService.validateTemplateExists(templateIdentifier); + return entityRepository.findByTemplateIdentifier(templateIdentifier, pageable); - /// Provides lightweight entity summaries for efficient bulk operations. - /// - /// **Contract:** Returns summary projections without full entity data, - /// optimized for UI lists and relationship resolution scenarios. - /// - /// @param identifiers business identifiers of entities to summarize - /// @return lightweight entity summaries for the specified identifiers - public List getEntitiesSummariesByIndentifiers(List identifiers) { - return entityRepository.findByIdentifierIn(identifiers); - } + } - /// Retrieves a specific entity with template and entity validation. - /// - /// **Contract:** Returns the entity identified by both template and entity identifiers. - /// Validates template existence first, then entity existence, ensuring referential integrity. - /// - /// @param templateIdentifier business identifier of the entity template - /// @param entityIdentifier unique business identifier of the entity within template - /// @return the entity matching both identifiers - /// @throws EntityTemplateNotFoundException when template doesn't exist - /// @throws EntityNotFoundException when entity doesn't exist - @Transactional - public Entity getEntityByTemplateIdentifierAndIdentifier(String templateIdentifier, String entityIdentifier) { - entityTemplateValidationService.validateTemplateExists(templateIdentifier); - return entityRepository.findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) - .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, - entityIdentifier)); - } + /// Provides lightweight entity summaries for efficient bulk operations. + /// + /// **Contract:** Returns summary projections without full entity data, + /// optimized for UI lists and relationship resolution scenarios. + /// + /// @param identifiers business identifiers of entities to summarize + /// @return lightweight entity summaries for the specified identifiers + public List getEntitiesSummariesByIndentifiers(List identifiers) { + return entityRepository.findByIdentifierIn(identifiers); + } - /// Creates and persists a new entity with business validation. - /// - /// **Contract:** Resolves the referenced template (single round-trip — combined - /// existence check and fetch), enforces entity identifier uniqueness within the - /// template scope, then validates entity/property data integrity against the - /// resolved template before persisting. - /// - /// @param entity validated entity to create and persist - /// @return the persisted entity with generated identifiers - /// @throws EntityTemplateNotFoundException when the referenced template doesn't exist - /// @throws EntityAlreadyExistsException when an entity with the same identifier already exists for this template - /// @throws EntityValidationException when entity, property, or relation data is invalid - @Transactional - public Entity createEntity(@Valid Entity entity) { - EntityTemplate template = entityTemplateService.getEntityTemplateByIdentifier(entity.templateIdentifier()); - entityValidationService.validateForCreation(entity, template); - return entityRepository.save(entity); - } + /// Retrieves a specific entity with template and entity validation. + /// + /// **Contract:** Returns the entity identified by both template and entity + /// identifiers. + /// Validates template existence first, then entity existence, ensuring + /// referential integrity. + /// + /// @param templateIdentifier business identifier of the entity template + /// @param entityIdentifier unique business identifier of the entity within + /// template + /// @return the entity matching both identifiers + /// @throws EntityTemplateNotFoundException when template doesn't exist + /// @throws EntityNotFoundException when entity doesn't exist + @Transactional + public Entity getEntityByTemplateIdentifierAndIdentifier(String templateIdentifier, + String entityIdentifier) { + entityTemplateValidationService.validateTemplateExists(templateIdentifier); + return entityRepository + .findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) + .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); + } + /// Creates and persists a new entity with business validation. + /// + /// **Contract:** Resolves the referenced template (single round-trip — combined + /// existence check and fetch), enforces entity identifier uniqueness within the + /// template scope, then validates entity/property data integrity against the + /// resolved template before persisting. + /// + /// @param entity validated entity to create and persist + /// @return the persisted entity with generated identifiers + /// @throws EntityTemplateNotFoundException when the referenced template doesn't + /// exist + /// @throws EntityAlreadyExistsException when an entity with the same identifier + /// already exists for this template + /// @throws EntityValidationException when entity, property, or relation data is + /// invalid + @Transactional + public Entity createEntity(@Valid Entity entity) { + EntityTemplate template = entityTemplateService + .getEntityTemplateByIdentifier(entity.templateIdentifier()); + entityValidationService.validateForCreation(entity, template); + return entityRepository.save(entity); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java index 8143e6c3..bb15baf5 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java @@ -30,67 +30,71 @@ @AllArgsConstructor public class EntityValidationService { - private final EntityRepositoryPort entityRepository; - private final PropertyValidationService propertyValidationService; + private final EntityRepositoryPort entityRepository; + private final PropertyValidationService propertyValidationService; - /// Validates intrinsic entity data integrity and template-driven rules. - /// - /// **Contract:** the caller is responsible for resolving the [EntityTemplate] - /// (typically via [com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort]) - /// and passing it in. This avoids a redundant database round-trip and clarifies - /// the dependency graph of the validation service. - /// - /// @param entity the entity to validate - /// @param template the already-resolved template the entity must conform to - /// @throws EntityValidationException when one or more validation rules are violated - /// @throws EntityAlreadyExistsException if an entity with the same identifier exists for the template - void validateForCreation(Entity entity, EntityTemplate template) { - validateUniqueness(entity); - validateAgainstTemplate(template, entity.properties()); - } - - /// Validates entity properties against the template's property definitions, enforcing required fields and value rules. - /// @param template the entity template whose property definitions are used for validation - /// @param properties the list of properties from the entity to validate - private void validateAgainstTemplate(EntityTemplate template, - List properties) { - Violations violations = new Violations(); - List definitions = Optional.ofNullable(template.propertiesDefinitions()).orElse(List.of()); - Map propertiesByName = Optional.ofNullable(properties).orElse(List.of()).stream() - .filter(p -> p.name() != null) - .collect(Collectors.toMap(Property::name, p -> p, (left, _) -> left)); + /// Validates intrinsic entity data integrity and template-driven rules. + /// + /// **Contract:** the caller is responsible for resolving the [EntityTemplate] + /// (typically via + /// [com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort]) + /// and passing it in. This avoids a redundant database round-trip and clarifies + /// the dependency graph of the validation service. + /// + /// @param entity the entity to validate + /// @param template the already-resolved template the entity must conform to + /// @throws EntityValidationException when one or more validation rules are + /// violated + /// @throws EntityAlreadyExistsException if an entity with the same identifier + /// exists for the template + void validateForCreation(Entity entity, EntityTemplate template) { + validateUniqueness(entity); + validateAgainstTemplate(template, entity.properties()); + } - for (PropertyDefinition definition : definitions) { - Property property = propertiesByName.get(definition.name()); - boolean missing = property == null - || property.value() == null - || (property.value().isBlank()); + /// Validates entity properties against the template's property definitions, + /// enforcing required fields and value rules. + /// @param template the entity template whose property definitions are used for + /// validation + /// @param properties the list of properties from the entity to validate + private void validateAgainstTemplate(EntityTemplate template, List properties) { + Violations violations = new Violations(); + List definitions = Optional.ofNullable(template.propertiesDefinitions()) + .orElse(List.of()); + Map propertiesByName = Optional.ofNullable(properties).orElse(List.of()) + .stream().filter(p -> p.name() != null) + .collect(Collectors.toMap(Property::name, p -> p, (left, _) -> left)); - if (missing) { - if (definition.required()) { - violations.add(PROPERTY_REQUIRED_MISSING, definition.name(), template.identifier()); - } - continue; - } + for (PropertyDefinition definition : definitions) { + Property property = propertiesByName.get(definition.name()); + boolean missing = property == null || property.value() == null + || (property.value().isBlank()); - propertyValidationService - .validatePropertyValue(definition, property.value()) - .forEach(violations::add); - } - if (!violations.isEmpty()) { - throw new EntityValidationException(violations.asList()); + if (missing) { + if (definition.required()) { + violations.add(PROPERTY_REQUIRED_MISSING, definition.name(), template.identifier()); } + continue; + } + + propertyValidationService.validatePropertyValue(definition, property.value()) + .forEach(violations::add); + } + if (!violations.isEmpty()) { + throw new EntityValidationException(violations.asList()); } + } - /// Checks for existing entity with same template and identifier to prevent duplicates. - /// @param entity the entity to check for existence - /// @throws EntityAlreadyExistsException if an entity with the same template and identifier already exists - private void validateUniqueness(final Entity entity) { - if (entity.identifier() != null - && entityRepository - .findByTemplateIdentifierAndIdentifier(entity.templateIdentifier(), entity.identifier()) - .isPresent()) { - throw new EntityAlreadyExistsException(entity.templateIdentifier(), entity.identifier()); - } + /// Checks for existing entity with same template and identifier to prevent + /// duplicates. + /// @param entity the entity to check for existence + /// @throws EntityAlreadyExistsException if an entity with the same template and + /// identifier already exists + private void validateUniqueness(final Entity entity) { + if (entity.identifier() != null && entityRepository + .findByTemplateIdentifierAndIdentifier(entity.templateIdentifier(), entity.identifier()) + .isPresent()) { + throw new EntityAlreadyExistsException(entity.templateIdentifier(), entity.identifier()); } + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/Violations.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/Violations.java index 92a3dd62..ddaad98e 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/Violations.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/Violations.java @@ -8,28 +8,28 @@ /// validators stay focused on the rule they enforce rather than on string /// concatenation. Not thread-safe; intended for short-lived per-request use. final class Violations { - private final List messages = new ArrayList<>(); - void add(String message) { - messages.add(message); - } - void add(String template, Object... args) { - messages.add(template.formatted(args)); - } - void addIfBlank(String value, String message) { - if (value == null || value.isBlank()) { - messages.add(message); - } + private final List messages = new ArrayList<>(); + void add(String message) { + messages.add(message); + } + void add(String template, Object... args) { + messages.add(template.formatted(args)); + } + void addIfBlank(String value, String message) { + if (value == null || value.isBlank()) { + messages.add(message); } + } - /// Adds a violation prefixed with the indexed collection name, e.g. - /// `Property[2]: Property name is mandatory`. - void addIndexed(String collection, int index, String message) { - messages.add("%s[%d]: %s".formatted(collection, index, message)); - } - boolean isEmpty() { - return messages.isEmpty(); - } - List asList() { - return List.copyOf(messages); - } + /// Adds a violation prefixed with the indexed collection name, e.g. + /// `Property[2]: Property name is mandatory`. + void addIndexed(String collection, int index, String message) { + messages.add("%s[%d]: %s".formatted(collection, index, message)); + } + boolean isEmpty() { + return messages.isEmpty(); + } + List asList() { + return List.copyOf(messages); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java index af23ff72..121145eb 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java @@ -45,132 +45,129 @@ @RequiredArgsConstructor public class EntityGraphService { - private static final int MAX_DEPTH = 10; - - private final EntityRepositoryPort entityRepositoryPort; - private final EntityGraphRepositoryPort entityGraphRepositoryPort; - - /// Builds the relationship graph for an entity starting from its composite key. - /// - /// @param templateIdentifier the template identifier of the root entity - /// @param entityIdentifier the business identifier of the root entity - /// @param depth the maximum traversal depth (clamped to [1, MAX_DEPTH]) - /// @param includeProperties when true, each graph node carries the entity's full property list - /// @return the root graph node with all resolved relations - /// @throws EntityNotFoundException when no entity matches the given identifiers - @Transactional(readOnly = true) - public EntityGraphNode getEntityGraph(String templateIdentifier, String entityIdentifier, int depth, - boolean includeProperties) { - int effectiveDepth = Math.clamp(depth, 1, MAX_DEPTH); - - Entity rootEntity = entityRepositoryPort - .findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) - .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); - - Map entityMap = entityGraphRepositoryPort - .findEntityGraph(templateIdentifier, entityIdentifier, effectiveDepth, includeProperties); - - EntityCompositeKey rootKey = new EntityCompositeKey(rootEntity.templateIdentifier(), rootEntity.identifier()); - - // One shared visited set per request — each node is fully expanded at most once, - // preventing O(2^depth) recursion from mutual outbound/inbound re-expansion. - Set visitedNodeIds = new HashSet<>(); - - return buildGraphNode(rootKey, entityMap, effectiveDepth, includeProperties, visitedNodeIds); + private static final int MAX_DEPTH = 10; + + private final EntityRepositoryPort entityRepositoryPort; + private final EntityGraphRepositoryPort entityGraphRepositoryPort; + + /// Builds the relationship graph for an entity starting from its composite key. + /// + /// @param templateIdentifier the template identifier of the root entity + /// @param entityIdentifier the business identifier of the root entity + /// @param depth the maximum traversal depth (clamped to [1, MAX_DEPTH]) + /// @param includeProperties when true, each graph node carries the entity's + /// full property list + /// @return the root graph node with all resolved relations + /// @throws EntityNotFoundException when no entity matches the given identifiers + @Transactional(readOnly = true) + public EntityGraphNode getEntityGraph(String templateIdentifier, String entityIdentifier, + int depth, boolean includeProperties) { + int effectiveDepth = Math.clamp(depth, 1, MAX_DEPTH); + + Entity rootEntity = entityRepositoryPort + .findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) + .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); + + Map entityMap = entityGraphRepositoryPort + .findEntityGraph(templateIdentifier, entityIdentifier, effectiveDepth, includeProperties); + + EntityCompositeKey rootKey = new EntityCompositeKey(rootEntity.templateIdentifier(), + rootEntity.identifier()); + + // One shared visited set per request — each node is fully expanded at most + // once, + // preventing O(2^depth) recursion from mutual outbound/inbound re-expansion. + Set visitedNodeIds = new HashSet<>(); + + return buildGraphNode(rootKey, entityMap, effectiveDepth, includeProperties, visitedNodeIds); + } + + /// Builds a graph node from a pre-loaded entity map (no database calls). + /// + /// [visitedNodeIds] tracks nodes that have already been fully built in this + /// traversal. + /// When a node is encountered again (cycle or shared reference), a stub leaf is + /// returned + /// immediately to cut the recursion — preventing the exponential blowup that + /// arises from + /// inbound scanning re-expanding the same nodes at every depth level. + private EntityGraphNode buildGraphNode(EntityCompositeKey key, + Map entityMap, int remainingDepth, boolean includeProperties, + Set visitedNodeIds) { + Entity entity = entityMap.get(key); + if (entity == null) { + return new EntityGraphNode(key.templateIdentifier(), key.identifier(), key.identifier(), + List.of(), List.of(), List.of()); } - /// Builds a graph node from a pre-loaded entity map (no database calls). - /// - /// [visitedNodeIds] tracks nodes that have already been fully built in this traversal. - /// When a node is encountered again (cycle or shared reference), a stub leaf is returned - /// immediately to cut the recursion — preventing the exponential blowup that arises from - /// inbound scanning re-expanding the same nodes at every depth level. - private EntityGraphNode buildGraphNode(EntityCompositeKey key, - Map entityMap, - int remainingDepth, - boolean includeProperties, - Set visitedNodeIds) { - Entity entity = entityMap.get(key); - if (entity == null) { - return new EntityGraphNode(key.templateIdentifier(), key.identifier(), key.identifier(), - List.of(), List.of(), List.of()); - } - - // Guard: return a stub leaf if this node was already fully built in another branch. - // This breaks both directed cycles (A→B→A) and shared references (A→B, C→B). - // Properties are still included so data is not silently dropped for shared nodes. - var nodeId = entity.templateIdentifier() + ":" + entity.identifier(); - if (!visitedNodeIds.add(nodeId)) { - List stubProperties = includeProperties ? entity.properties() : List.of(); - return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), - stubProperties, List.of(), List.of()); - } - - // Depth exhausted — return a leaf with no relations but still carry properties - // so the deepest reachable entities expose their data when include_data=true. - if (remainingDepth <= 0) { - List leafProperties = includeProperties ? entity.properties() : List.of(); - return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), - leafProperties, List.of(), List.of()); - } - - List outboundRelations = entity.relations().stream() - .map(relation -> new EntityGraphRelation( - relation.name(), - relation.targetEntityIdentifiers().stream() - .map(targetId -> buildGraphNode( - findKeyByIdentifier(targetId, entityMap), - entityMap, remainingDepth - 1, includeProperties, visitedNodeIds)) - .toList() - )) - .toList(); - - List inboundRelations = buildRelationsAsTargetFromMap( - entity.identifier(), entityMap, remainingDepth - 1, includeProperties, visitedNodeIds); - - List properties = includeProperties ? entity.properties() : List.of(); - return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), - properties, outboundRelations, inboundRelations); + // Guard: return a stub leaf if this node was already fully built in another + // branch. + // This breaks both directed cycles (A→B→A) and shared references (A→B, C→B). + // Properties are still included so data is not silently dropped for shared + // nodes. + var nodeId = entity.templateIdentifier() + ":" + entity.identifier(); + if (!visitedNodeIds.add(nodeId)) { + List stubProperties = includeProperties ? entity.properties() : List.of(); + return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), + stubProperties, List.of(), List.of()); } - /// Looks up a composite key from the map by identifier alone. - /// Falls back to a synthetic key if no match is found (entity not in graph). - private EntityCompositeKey findKeyByIdentifier(String identifier, Map entityMap) { - return entityMap.keySet().stream() - .filter(k -> k.identifier().equals(identifier)) - .findFirst() - .orElse(new EntityCompositeKey("", identifier)); + // Depth exhausted — return a leaf with no relations but still carry properties + // so the deepest reachable entities expose their data when include_data=true. + if (remainingDepth <= 0) { + List leafProperties = includeProperties ? entity.properties() : List.of(); + return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), + leafProperties, List.of(), List.of()); } - /// Builds incoming relations (where this entity is the target) from the pre-loaded entity map. - /// Passes [visitedNodeIds] through so that source nodes already expanded elsewhere are not - /// re-expanded here, preventing the mutual recursion that causes OOM at high depths. - private List buildRelationsAsTargetFromMap(String targetIdentifier, - Map entityMap, - int remainingDepth, - boolean includeProperties, - Set visitedNodeIds) { - Map> sourcesByRelationName = new HashMap<>(); - - for (Map.Entry entry : entityMap.entrySet()) { - Entity sourceEntity = entry.getValue(); - for (Relation relation : sourceEntity.relations()) { - if (relation.targetEntityIdentifiers().contains(targetIdentifier)) { - sourcesByRelationName - .computeIfAbsent(relation.name(), k -> new ArrayList<>()) - .add(entry.getKey()); - } - } + List outboundRelations = entity.relations().stream() + .map(relation -> new EntityGraphRelation(relation.name(), relation.targetEntityIdentifiers() + .stream().map(targetId -> buildGraphNode(findKeyByIdentifier(targetId, entityMap), + entityMap, remainingDepth - 1, includeProperties, visitedNodeIds)) + .toList())) + .toList(); + + List inboundRelations = buildRelationsAsTargetFromMap(entity.identifier(), + entityMap, remainingDepth - 1, includeProperties, visitedNodeIds); + + List properties = includeProperties ? entity.properties() : List.of(); + return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), + properties, outboundRelations, inboundRelations); + } + + /// Looks up a composite key from the map by identifier alone. + /// Falls back to a synthetic key if no match is found (entity not in graph). + private EntityCompositeKey findKeyByIdentifier(String identifier, + Map entityMap) { + return entityMap.keySet().stream().filter(k -> k.identifier().equals(identifier)).findFirst() + .orElse(new EntityCompositeKey("", identifier)); + } + + /// Builds incoming relations (where this entity is the target) from the + /// pre-loaded entity map. + /// Passes [visitedNodeIds] through so that source nodes already expanded + /// elsewhere are not + /// re-expanded here, preventing the mutual recursion that causes OOM at high + /// depths. + private List buildRelationsAsTargetFromMap(String targetIdentifier, + Map entityMap, int remainingDepth, boolean includeProperties, + Set visitedNodeIds) { + Map> sourcesByRelationName = new HashMap<>(); + + for (Map.Entry entry : entityMap.entrySet()) { + Entity sourceEntity = entry.getValue(); + for (Relation relation : sourceEntity.relations()) { + if (relation.targetEntityIdentifiers().contains(targetIdentifier)) { + sourcesByRelationName.computeIfAbsent(relation.name(), k -> new ArrayList<>()) + .add(entry.getKey()); } - - return sourcesByRelationName.entrySet().stream() - .map(e -> new EntityGraphRelation( - e.getKey(), - e.getValue().stream() - .map(sourceKey -> buildGraphNode(sourceKey, entityMap, remainingDepth, - includeProperties, visitedNodeIds)) - .toList() - )) - .toList(); + } } + + return sourcesByRelationName.entrySet().stream() + .map(e -> new EntityGraphRelation(e.getKey(), + e.getValue().stream().map(sourceKey -> buildGraphNode(sourceKey, entityMap, + remainingDepth, includeProperties, visitedNodeIds)).toList())) + .toList(); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateService.java index 3528e14c..877a0846 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateService.java @@ -8,6 +8,9 @@ import java.util.function.Function; import java.util.stream.Collectors; +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; @@ -23,8 +26,6 @@ import com.decathlon.idp_core.domain.port.EntityRepositoryPort; import com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort; -import jakarta.transaction.Transactional; -import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; /// Domain service orchestrating [EntityTemplate] business operations and lifecycle management. @@ -44,264 +45,272 @@ @RequiredArgsConstructor public class EntityTemplateService { - private final EntityTemplateRepositoryPort entityTemplateRepositoryPort; - private final EntityTemplateValidationService entityTemplateValidationService; - private final EntityRepositoryPort entityRepositoryPort; + private final EntityTemplateRepositoryPort entityTemplateRepositoryPort; + private final EntityTemplateValidationService entityTemplateValidationService; + private final EntityRepositoryPort entityRepositoryPort; - /// Retrieves paginated entity templates for management interface display. - /// - /// **Contract:** Returns templates with pagination metadata for efficient UI rendering. - /// Supports sorting and filtering through Spring's Pageable interface for flexible - /// template browsing and administration. - /// - /// @param pageable pagination configuration including page size, number, and sorting - /// @return paginated template results with metadata - public Page getEntityTemplates(Pageable pageable) { - return entityTemplateRepositoryPort.findAll(pageable); - } + /// Retrieves paginated entity templates for management interface display. + /// + /// **Contract:** Returns templates with pagination metadata for efficient UI + /// rendering. + /// Supports sorting and filtering through Spring's Pageable interface for + /// flexible + /// template browsing and administration. + /// + /// @param pageable pagination configuration including page size, number, and + /// sorting + /// @return paginated template results with metadata + public Page getEntityTemplates(Pageable pageable) { + return entityTemplateRepositoryPort.findAll(pageable); + } - /// Retrieves a specific entity template by business identifier. - /// - /// **Contract:** Performs exact match lookup for template identification. - /// Case-sensitive matching ensures precise template resolution for entity operations. - /// - /// @param identifier unique business identifier of the template - /// @return the matching [EntityTemplate] - /// @throws EntityTemplateNotFoundException when template doesn't exist - public EntityTemplate getEntityTemplateByIdentifier(String identifier) { - return entityTemplateRepositoryPort.findByIdentifier(identifier) - .orElseThrow(() -> new EntityTemplateNotFoundException("identifier", identifier)); - } + /// Retrieves a specific entity template by business identifier. + /// + /// **Contract:** Performs exact match lookup for template identification. + /// Case-sensitive matching ensures precise template resolution for entity + /// operations. + /// + /// @param identifier unique business identifier of the template + /// @return the matching [EntityTemplate] + /// @throws EntityTemplateNotFoundException when template doesn't exist + public EntityTemplate getEntityTemplateByIdentifier(String identifier) { + return entityTemplateRepositoryPort.findByIdentifier(identifier) + .orElseThrow(() -> new EntityTemplateNotFoundException("identifier", identifier)); + } - /// Creates and persists a new entity template. - /// - /// **Contract:** Validates the provided `EntityTemplate` and enforces uniqueness - /// constraints on both `identifier` and `name` when present. If validation passes, - /// the template is persisted and the persisted instance (including any generated - /// identifiers) is returned. - /// - /// **Business rules enforced:** - /// - If `identifier` is provided it must not already exist in the system. - /// - If `name` is provided it must not already exist in the system. - /// - Validation of property rules according to their defined constraints. - /// - /// @param entityTemplate validated template to create and persist - /// @return the persisted template with generated identifiers - /// @throws EntityTemplateAlreadyExistsException when identifier already exists - /// @throws EntityTemplateNameAlreadyExistsException when name already exists - @Transactional - public EntityTemplate createEntityTemplate(@Valid EntityTemplate entityTemplate) { - entityTemplateValidationService.validateForCreation(entityTemplate); - return entityTemplateRepositoryPort.save(entityTemplate); - } - - /// Updates an existing entity template using full replacement with smart merging. - /// - /// **Contract:** Replaces the template's scalar fields (identifier, name, description) with the - /// incoming values, while performing an intelligent merge on nested collections - /// (properties and relations). Matching children (by name) preserve their existing UUIDs - /// so the persistence layer treats them as updates rather than delete-and-recreate, - /// avoiding unnecessary orphan removal and re-insertion. - /// - /// **Business rules enforced:** - /// - The target template must already exist (looked up by the path `identifier`). - /// - If the caller changes the identifier, the new value must not collide with another template. - /// - Property and relation definitions are merged by name: - /// - *Matched by name* → existing ID is preserved, other fields are overwritten. - /// - *Not matched* → treated as a new definition (no ID yet). - /// - *Missing from update* → removed (handled downstream by the persistence adapter). - /// - Validation of property rules according to their defined constraints. - /// - /// @param identifier current business identifier of the template to update - /// @param entityTemplate validated template carrying the desired state - /// @return the persisted template after merge, with generated or preserved identifiers - /// @throws EntityTemplateNotFoundException when no template matches `identifier` - /// @throws EntityTemplateAlreadyExistsException when renaming would cause a duplicate - @Transactional - public EntityTemplate updateEntityTemplate(String identifier, @Valid EntityTemplate entityTemplate) { - EntityTemplate existingTemplate = getEntityTemplateByIdentifier(identifier); - EntityTemplate mergedTemplate = new EntityTemplate( - existingTemplate.id(), - entityTemplate.identifier(), - entityTemplate.name(), - entityTemplate.description(), - mergePropertyDefinitions(existingTemplate.propertiesDefinitions(), - entityTemplate.propertiesDefinitions()), - mergeRelationDefinitions(existingTemplate.relationsDefinitions(), - entityTemplate.relationsDefinitions()) - ); - entityTemplateValidationService.validateForUpdate(identifier, existingTemplate.name(), existingTemplate, mergedTemplate); - EntityTemplate savedTemplate = entityTemplateRepositoryPort.save(mergedTemplate); - purgeRemovedProperties(identifier, existingTemplate.propertiesDefinitions(), entityTemplate.propertiesDefinitions()); - purgeRemovedRelations(identifier, existingTemplate.relationsDefinitions(), entityTemplate.relationsDefinitions()); - return savedTemplate; - } + /// Creates and persists a new entity template. + /// + /// **Contract:** Validates the provided `EntityTemplate` and enforces + /// uniqueness + /// constraints on both `identifier` and `name` when present. If validation + /// passes, + /// the template is persisted and the persisted instance (including any + /// generated + /// identifiers) is returned. + /// + /// **Business rules enforced:** + /// - If `identifier` is provided it must not already exist in the system. + /// - If `name` is provided it must not already exist in the system. + /// - Validation of property rules according to their defined constraints. + /// + /// @param entityTemplate validated template to create and persist + /// @return the persisted template with generated identifiers + /// @throws EntityTemplateAlreadyExistsException when identifier already exists + /// @throws EntityTemplateNameAlreadyExistsException when name already exists + @Transactional + public EntityTemplate createEntityTemplate(@Valid EntityTemplate entityTemplate) { + entityTemplateValidationService.validateForCreation(entityTemplate); + return entityTemplateRepositoryPort.save(entityTemplate); + } - /// Deletes an entity template by business identifier with existence validation. - /// - /// **Contract:** Validates template existence before deletion to ensure referential - /// integrity. Deletion cascades through persistence layer according to configured - /// relationships. This operation is irreversible once committed. - /// - /// @param identifier unique business identifier of template to delete - /// @throws EntityTemplateNotFoundException when template doesn't exist - @Transactional - public void deleteEntityTemplate(String identifier) { - entityTemplateValidationService.validateForDeletion(identifier); - entityTemplateRepositoryPort.deleteByIdentifier(identifier); - } + /// Updates an existing entity template using full replacement with smart + /// merging. + /// + /// **Contract:** Replaces the template's scalar fields (identifier, name, + /// description) with the + /// incoming values, while performing an intelligent merge on nested collections + /// (properties and relations). Matching children (by name) preserve their + /// existing UUIDs + /// so the persistence layer treats them as updates rather than + /// delete-and-recreate, + /// avoiding unnecessary orphan removal and re-insertion. + /// + /// **Business rules enforced:** + /// - The target template must already exist (looked up by the path + /// `identifier`). + /// - If the caller changes the identifier, the new value must not collide with + /// another template. + /// - Property and relation definitions are merged by name: + /// - *Matched by name* → existing ID is preserved, other fields are + /// overwritten. + /// - *Not matched* → treated as a new definition (no ID yet). + /// - *Missing from update* → removed (handled downstream by the persistence + /// adapter). + /// - Validation of property rules according to their defined constraints. + /// + /// @param identifier current business identifier of the template to update + /// @param entityTemplate validated template carrying the desired state + /// @return the persisted template after merge, with generated or preserved + /// identifiers + /// @throws EntityTemplateNotFoundException when no template matches + /// `identifier` + /// @throws EntityTemplateAlreadyExistsException when renaming would cause a + /// duplicate + @Transactional + public EntityTemplate updateEntityTemplate(String identifier, + @Valid EntityTemplate entityTemplate) { + EntityTemplate existingTemplate = getEntityTemplateByIdentifier(identifier); + EntityTemplate mergedTemplate = new EntityTemplate(existingTemplate.id(), + entityTemplate.identifier(), entityTemplate.name(), entityTemplate.description(), + mergePropertyDefinitions(existingTemplate.propertiesDefinitions(), + entityTemplate.propertiesDefinitions()), + mergeRelationDefinitions(existingTemplate.relationsDefinitions(), + entityTemplate.relationsDefinitions())); + entityTemplateValidationService.validateForUpdate(identifier, existingTemplate.name(), + existingTemplate, mergedTemplate); + EntityTemplate savedTemplate = entityTemplateRepositoryPort.save(mergedTemplate); + purgeRemovedProperties(identifier, existingTemplate.propertiesDefinitions(), + entityTemplate.propertiesDefinitions()); + purgeRemovedRelations(identifier, existingTemplate.relationsDefinitions(), + entityTemplate.relationsDefinitions()); + return savedTemplate; + } - private List mergePropertyDefinitions( - List existing, - List updated) { + /// Deletes an entity template by business identifier with existence validation. + /// + /// **Contract:** Validates template existence before deletion to ensure + /// referential + /// integrity. Deletion cascades through persistence layer according to + /// configured + /// relationships. This operation is irreversible once committed. + /// + /// @param identifier unique business identifier of template to delete + /// @throws EntityTemplateNotFoundException when template doesn't exist + @Transactional + public void deleteEntityTemplate(String identifier) { + entityTemplateValidationService.validateForDeletion(identifier); + entityTemplateRepositoryPort.deleteByIdentifier(identifier); + } - if (existing == null) existing = new ArrayList<>(); - if (updated == null) return existing; + private List mergePropertyDefinitions(List existing, + List updated) { - Map existingMap = existing.stream() - .collect(Collectors.toMap(p -> p.name().toLowerCase(java.util.Locale.ROOT), Function.identity())); + if (existing == null) + existing = new ArrayList<>(); + if (updated == null) + return existing; - List result = new ArrayList<>(); + Map existingMap = existing.stream().collect( + Collectors.toMap(p -> p.name().toLowerCase(java.util.Locale.ROOT), Function.identity())); - for (PropertyDefinition prop : updated) { - PropertyDefinition existingProp = existingMap.get(prop.name().toLowerCase(java.util.Locale.ROOT)); - if (existingProp != null) { - result.add(new PropertyDefinition( - existingProp.id(), - prop.name(), - prop.description(), - prop.type(), - prop.required(), - mergePropertyRules(existingProp.rules(), prop.rules()) - )); - } else { - result.add(prop); - } - } + List result = new ArrayList<>(); - return result; + for (PropertyDefinition prop : updated) { + PropertyDefinition existingProp = existingMap + .get(prop.name().toLowerCase(java.util.Locale.ROOT)); + if (existingProp != null) { + result.add(new PropertyDefinition(existingProp.id(), prop.name(), prop.description(), + prop.type(), prop.required(), mergePropertyRules(existingProp.rules(), prop.rules()))); + } else { + result.add(prop); + } } - private PropertyRules mergePropertyRules(PropertyRules existingRules, PropertyRules newRules) { - if (newRules == null) { - return existingRules; - } - if (existingRules == null) { - return newRules; - } + return result; + } - return new PropertyRules( - existingRules.id(), - newRules.format(), - newRules.enumValues(), - newRules.regex(), - newRules.maxLength(), - newRules.minLength(), - newRules.maxValue(), - newRules.minValue() - ); + private PropertyRules mergePropertyRules(PropertyRules existingRules, PropertyRules newRules) { + if (newRules == null) { + return existingRules; + } + if (existingRules == null) { + return newRules; } - private List mergeRelationDefinitions( - List existing, - List updated) { + return new PropertyRules(existingRules.id(), newRules.format(), newRules.enumValues(), + newRules.regex(), newRules.maxLength(), newRules.minLength(), newRules.maxValue(), + newRules.minValue()); + } - if (existing == null) existing = new ArrayList<>(); - if (updated == null) return existing; + private List mergeRelationDefinitions(List existing, + List updated) { - Map existingMap = existing.stream() - .collect(Collectors.toMap(r -> r.name().toLowerCase(java.util.Locale.ROOT), Function.identity())); + if (existing == null) + existing = new ArrayList<>(); + if (updated == null) + return existing; - List result = new ArrayList<>(); + Map existingMap = existing.stream().collect( + Collectors.toMap(r -> r.name().toLowerCase(java.util.Locale.ROOT), Function.identity())); - for (RelationDefinition rel : updated) { - RelationDefinition existingRel = existingMap.get(rel.name().toLowerCase(java.util.Locale.ROOT)); - if (existingRel != null) { - result.add(new RelationDefinition( - existingRel.id(), - rel.name(), - rel.targetTemplateIdentifier(), - rel.required(), - rel.toMany() - )); - } else { - result.add(rel); - } - } + List result = new ArrayList<>(); - return result; + for (RelationDefinition rel : updated) { + RelationDefinition existingRel = existingMap + .get(rel.name().toLowerCase(java.util.Locale.ROOT)); + if (existingRel != null) { + result.add(new RelationDefinition(existingRel.id(), rel.name(), + rel.targetTemplateIdentifier(), rel.required(), rel.toMany())); + } else { + result.add(rel); + } } - /// Computes the names of relation definitions present in [existing] but absent from [updated]. - /// - /// **Business purpose:** Identifies which relations were removed in the - /// PUT request so the linked entity relation values can be purged. - /// - /// @param existing relation definitions currently persisted on the template - /// @param updated relation definitions from the incoming PUT request - /// @return names of relation definitions that were removed (never null, may be empty) - private List identifyDeletedRelationNames( - List existing, - List updated) { - if (existing == null || existing.isEmpty()) { - return List.of(); - } - Set updatedRelationNames = (updated == null ? List.of() : updated) - .stream() - .map(r -> r.name().toLowerCase(Locale.ROOT)) - .collect(Collectors.toSet()); - return existing.stream() - .filter(r -> !updatedRelationNames.contains(r.name().toLowerCase(Locale.ROOT))) - .map(RelationDefinition::name) - .toList(); + return result; + } + + /// Computes the names of relation definitions present in [existing] but absent + /// from [updated]. + /// + /// **Business purpose:** Identifies which relations were removed in the + /// PUT request so the linked entity relation values can be purged. + /// + /// @param existing relation definitions currently persisted on the template + /// @param updated relation definitions from the incoming PUT request + /// @return names of relation definitions that were removed (never null, may be + /// empty) + private List identifyDeletedRelationNames(List existing, + List updated) { + if (existing == null || existing.isEmpty()) { + return List.of(); } + Set updatedRelationNames = (updated == null ? List.of() : updated) + .stream().map(r -> r.name().toLowerCase(Locale.ROOT)).collect(Collectors.toSet()); + return existing.stream() + .filter(r -> !updatedRelationNames.contains(r.name().toLowerCase(Locale.ROOT))) + .map(RelationDefinition::name).toList(); + } - /// Computes the names of property definitions present in [existing] but absent from [updated]. - /// - /// **Business purpose:** Identifies which properties were removed in the - /// PUT request so their corresponding entity property values can be purged. - /// - /// @param existing property definitions currently persisted on the template - /// @param updated property definitions from the incoming PUT request - /// @return names of property definitions that were removed (never null, may be empty) - private List identifyDeletedPropertyNames( - List existing, - List updated) { - if (existing == null || existing.isEmpty()) { - return List.of(); - } - Set updatedPropertyNames = (updated == null ? List.of() : updated) - .stream() - .map(p -> p.name().toLowerCase(Locale.ROOT)) - .collect(Collectors.toSet()); - return existing.stream() - .filter(p -> !updatedPropertyNames.contains(p.name().toLowerCase(Locale.ROOT))) - .map(PropertyDefinition::name) - .toList(); + /// Computes the names of property definitions present in [existing] but absent + /// from [updated]. + /// + /// **Business purpose:** Identifies which properties were removed in the + /// PUT request so their corresponding entity property values can be purged. + /// + /// @param existing property definitions currently persisted on the template + /// @param updated property definitions from the incoming PUT request + /// @return names of property definitions that were removed (never null, may be + /// empty) + private List identifyDeletedPropertyNames(List existing, + List updated) { + if (existing == null || existing.isEmpty()) { + return List.of(); } + Set updatedPropertyNames = (updated == null ? List.of() : updated) + .stream().map(p -> p.name().toLowerCase(Locale.ROOT)).collect(Collectors.toSet()); + return existing.stream() + .filter(p -> !updatedPropertyNames.contains(p.name().toLowerCase(Locale.ROOT))) + .map(PropertyDefinition::name).toList(); + } - /// Identifies and purges property values from entities whose definitions were removed during a template update. - /// - /// @param templateIdentifier the template's business identifier - /// @param existing property definitions currently persisted on the template - /// @param updated property definitions from the incoming PUT request - private void purgeRemovedProperties(String templateIdentifier, List existing, List updated) { - List removedNames = identifyDeletedPropertyNames(existing, updated); - if (!removedNames.isEmpty()) { - entityRepositoryPort.deletePropertiesByTemplateIdentifierAndPropertyName(templateIdentifier, removedNames); - } + /// Identifies and purges property values from entities whose definitions were + /// removed during a template update. + /// + /// @param templateIdentifier the template's business identifier + /// @param existing property definitions currently persisted on the template + /// @param updated property definitions from the incoming PUT request + private void purgeRemovedProperties(String templateIdentifier, List existing, + List updated) { + List removedNames = identifyDeletedPropertyNames(existing, updated); + if (!removedNames.isEmpty()) { + entityRepositoryPort.deletePropertiesByTemplateIdentifierAndPropertyName(templateIdentifier, + removedNames); } + } - /// Identifies and purges relation values from entities whose definitions were removed during a template update. - /// - /// @param templateIdentifier the template's business identifier - /// @param existing relation definitions currently persisted on the template - /// @param updated relation definitions from the incoming PUT request - private void purgeRemovedRelations(String templateIdentifier, List existing, List updated) { - List removedNames = identifyDeletedRelationNames(existing, updated); - if (!removedNames.isEmpty()) { - entityRepositoryPort.deleteRelationsByTemplateIdentifierAndRelationName(templateIdentifier, removedNames); - } + /// Identifies and purges relation values from entities whose definitions were + /// removed during a template update. + /// + /// @param templateIdentifier the template's business identifier + /// @param existing relation definitions currently persisted on the template + /// @param updated relation definitions from the incoming PUT request + private void purgeRemovedRelations(String templateIdentifier, List existing, + List updated) { + List removedNames = identifyDeletedRelationNames(existing, updated); + if (!removedNames.isEmpty()) { + entityRepositoryPort.deleteRelationsByTemplateIdentifierAndRelationName(templateIdentifier, + removedNames); } + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java index 5e6e1e50..4ec24faa 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java @@ -31,158 +31,186 @@ @RequiredArgsConstructor public class EntityTemplateValidationService { - private final EntityTemplateRepositoryPort entityTemplateRepositoryPort; - private final PropertyDefinitionValidationService propertyDefinitionValidationService; - private final RelationDefinitionValidationService relationDefinitionValidationService; + private final EntityTemplateRepositoryPort entityTemplateRepositoryPort; + private final PropertyDefinitionValidationService propertyDefinitionValidationService; + private final RelationDefinitionValidationService relationDefinitionValidationService; - /// Validates all business rules before creating a new entity template. - /// - /// **Business rules enforced:** - /// - If `identifier` is provided it must not already exist in the system. - /// - If `name` is provided it must not already exist in the system. - /// - Property rules must be compatible with their declared property type. - /// - Relation names must be unique within the template. - /// - No relation may target the template's own identifier (no self-reference). - /// - All target templates referenced by relations must exist in the system. - /// - /// @param entityTemplate the template candidate to validate - /// @throws EntityTemplateAlreadyExistsException when identifier is already taken - /// @throws EntityTemplateNameAlreadyExistsException when name is already taken - /// @throws PropertyDefinitionRulesConflictException when rules violate business invariants - /// @throws PropertyNameAlreadyExistsException if duplicate property names are found - /// @throws RelationNameAlreadyExistsException if duplicate relation names are found - /// @throws RelationCannotTargetItselfException when a relation targets the template itself - public void validateForCreation(EntityTemplate entityTemplate) { - validateIdentifierUniqueness(entityTemplate.identifier()); - validateNameUniqueness(entityTemplate.name()); - if (entityTemplate.propertiesDefinitions() != null) { - propertyDefinitionValidationService.validatePropertyNamesUniqueness(entityTemplate.propertiesDefinitions()); - validateTemplateProperties(entityTemplate); - } - if (entityTemplate.relationsDefinitions() != null) { - relationDefinitionValidationService.validateRelationNamesUniqueness(entityTemplate.relationsDefinitions()); - validateTemplateRelations(entityTemplate); - } + /// Validates all business rules before creating a new entity template. + /// + /// **Business rules enforced:** + /// - If `identifier` is provided it must not already exist in the system. + /// - If `name` is provided it must not already exist in the system. + /// - Property rules must be compatible with their declared property type. + /// - Relation names must be unique within the template. + /// - No relation may target the template's own identifier (no self-reference). + /// - All target templates referenced by relations must exist in the system. + /// + /// @param entityTemplate the template candidate to validate + /// @throws EntityTemplateAlreadyExistsException when identifier is already + /// taken + /// @throws EntityTemplateNameAlreadyExistsException when name is already taken + /// @throws PropertyDefinitionRulesConflictException when rules violate business + /// invariants + /// @throws PropertyNameAlreadyExistsException if duplicate property names are + /// found + /// @throws RelationNameAlreadyExistsException if duplicate relation names are + /// found + /// @throws RelationCannotTargetItselfException when a relation targets the + /// template itself + public void validateForCreation(EntityTemplate entityTemplate) { + validateIdentifierUniqueness(entityTemplate.identifier()); + validateNameUniqueness(entityTemplate.name()); + if (entityTemplate.propertiesDefinitions() != null) { + propertyDefinitionValidationService + .validatePropertyNamesUniqueness(entityTemplate.propertiesDefinitions()); + validateTemplateProperties(entityTemplate); } - - /// Validates all business rules before persisting an updated entity template. - /// - /// **Business rules enforced:** - /// - If the identifier changed, the new value must not collide with another template. - /// - If the name changed, the new value must not collide with another template. - /// - Property rules in the merged template must be compatible with their declared type. - /// - Relation names must be unique within the template. - /// - No relation may target the template's own identifier (no self-reference). - /// - All target templates referenced by relations must exist in the system. - /// - Relation target template identifiers cannot be changed after creation. - /// - /// @param currentIdentifier the identifier of the template being replaced - /// @param existingName the current name of the template being replaced - /// @param existingTemplate the current state of the template being replaced - /// @param mergedTemplate the fully-merged template carrying the desired state - /// @throws EntityTemplateAlreadyExistsException when the new identifier is already taken - /// @throws EntityTemplateNameAlreadyExistsException when the new name is already taken - /// @throws PropertyDefinitionRulesConflictException when rules violate business invariants - /// @throws PropertyNameAlreadyExistsException if duplicate property names are found - /// @throws RelationNameAlreadyExistsException if duplicate relation names are found - /// @throws RelationTargetTemplateChangeException when a relation target template is changed - /// @throws RelationCannotTargetItselfException when a relation targets the template itself - public void validateForUpdate(String currentIdentifier, String existingName, EntityTemplate existingTemplate, EntityTemplate mergedTemplate) { - if (!currentIdentifier.equals(mergedTemplate.identifier())) { - throw new EntityTemplateIdentifierCannotChangeException(mergedTemplate.identifier()); - } - if (!Objects.equals(existingName, mergedTemplate.name())) { - validateNameUniqueness(mergedTemplate.name()); - } - if (mergedTemplate.propertiesDefinitions() != null) { - propertyDefinitionValidationService.validatePropertyNamesUniqueness(mergedTemplate.propertiesDefinitions()); - propertyDefinitionValidationService.validateTypeChanges(existingTemplate.propertiesDefinitions(), mergedTemplate.propertiesDefinitions()); - validateTemplateProperties(mergedTemplate); - } - if (mergedTemplate.relationsDefinitions() != null) { - relationDefinitionValidationService.validateRelationNamesUniqueness(mergedTemplate.relationsDefinitions()); - relationDefinitionValidationService.validateTargetTemplateChanges(existingTemplate.relationsDefinitions(), mergedTemplate.relationsDefinitions()); - validateTemplateRelations(mergedTemplate); - } + if (entityTemplate.relationsDefinitions() != null) { + relationDefinitionValidationService + .validateRelationNamesUniqueness(entityTemplate.relationsDefinitions()); + validateTemplateRelations(entityTemplate); } + } - /// Validates that a template identifier is non-null and refers to an existing template. - /// - /// @param identifier the identifier of the template to delete - /// @throws EntityTemplateNotFoundException when `identifier` is null - /// @throws EntityTemplateNotFoundException when no template matches `identifier` - public void validateForDeletion(String identifier) { - if (identifier == null) { - throw new EntityTemplateNotFoundException("identifier", "null"); - } - validateTemplateExists(identifier); + /// Validates all business rules before persisting an updated entity template. + /// + /// **Business rules enforced:** + /// - If the identifier changed, the new value must not collide with another + /// template. + /// - If the name changed, the new value must not collide with another template. + /// - Property rules in the merged template must be compatible with their + /// declared type. + /// - Relation names must be unique within the template. + /// - No relation may target the template's own identifier (no self-reference). + /// - All target templates referenced by relations must exist in the system. + /// - Relation target template identifiers cannot be changed after creation. + /// + /// @param currentIdentifier the identifier of the template being replaced + /// @param existingName the current name of the template being replaced + /// @param existingTemplate the current state of the template being replaced + /// @param mergedTemplate the fully-merged template carrying the desired state + /// @throws EntityTemplateAlreadyExistsException when the new identifier is + /// already taken + /// @throws EntityTemplateNameAlreadyExistsException when the new name is + /// already taken + /// @throws PropertyDefinitionRulesConflictException when rules violate business + /// invariants + /// @throws PropertyNameAlreadyExistsException if duplicate property names are + /// found + /// @throws RelationNameAlreadyExistsException if duplicate relation names are + /// found + /// @throws RelationTargetTemplateChangeException when a relation target + /// template is changed + /// @throws RelationCannotTargetItselfException when a relation targets the + /// template itself + public void validateForUpdate(String currentIdentifier, String existingName, + EntityTemplate existingTemplate, EntityTemplate mergedTemplate) { + if (!currentIdentifier.equals(mergedTemplate.identifier())) { + throw new EntityTemplateIdentifierCannotChangeException(mergedTemplate.identifier()); + } + if (!Objects.equals(existingName, mergedTemplate.name())) { + validateNameUniqueness(mergedTemplate.name()); + } + if (mergedTemplate.propertiesDefinitions() != null) { + propertyDefinitionValidationService + .validatePropertyNamesUniqueness(mergedTemplate.propertiesDefinitions()); + propertyDefinitionValidationService.validateTypeChanges( + existingTemplate.propertiesDefinitions(), mergedTemplate.propertiesDefinitions()); + validateTemplateProperties(mergedTemplate); } + if (mergedTemplate.relationsDefinitions() != null) { + relationDefinitionValidationService + .validateRelationNamesUniqueness(mergedTemplate.relationsDefinitions()); + relationDefinitionValidationService.validateTargetTemplateChanges( + existingTemplate.relationsDefinitions(), mergedTemplate.relationsDefinitions()); + validateTemplateRelations(mergedTemplate); + } + } - /// Checks that the entity template exists. - /// - /// @param identifier the identifier to check for existence - /// @throws EntityTemplateNotFoundException when no template matches `identifier` - public void validateTemplateExists(String identifier) { - if (!entityTemplateRepositoryPort.existsByIdentifier(identifier)) { - throw new EntityTemplateNotFoundException("identifier", identifier); - } + /// Validates that a template identifier is non-null and refers to an existing + /// template. + /// + /// @param identifier the identifier of the template to delete + /// @throws EntityTemplateNotFoundException when `identifier` is null + /// @throws EntityTemplateNotFoundException when no template matches + /// `identifier` + public void validateForDeletion(String identifier) { + if (identifier == null) { + throw new EntityTemplateNotFoundException("identifier", "null"); } + validateTemplateExists(identifier); + } - /// Checks that no other template already uses the given identifier. - /// - /// @param identifier the identifier to check for uniqueness - /// @throws EntityTemplateAlreadyExistsException when identifier is already taken - public void validateIdentifierUniqueness(String identifier) { - if (entityTemplateRepositoryPort.existsByIdentifier(identifier)) { - throw new EntityTemplateAlreadyExistsException(identifier); - } + /// Checks that the entity template exists. + /// + /// @param identifier the identifier to check for existence + /// @throws EntityTemplateNotFoundException when no template matches + /// `identifier` + public void validateTemplateExists(String identifier) { + if (!entityTemplateRepositoryPort.existsByIdentifier(identifier)) { + throw new EntityTemplateNotFoundException("identifier", identifier); } + } - /// Checks that no other template already uses the given name. - /// - /// @param name the name to check for uniqueness - /// @throws EntityTemplateNameAlreadyExistsException when name is already taken - public void validateNameUniqueness(String name) { - if (entityTemplateRepositoryPort.existsByName(name)) { - throw new EntityTemplateNameAlreadyExistsException(name); - } + /// Checks that no other template already uses the given identifier. + /// + /// @param identifier the identifier to check for uniqueness + /// @throws EntityTemplateAlreadyExistsException when identifier is already + /// taken + public void validateIdentifierUniqueness(String identifier) { + if (entityTemplateRepositoryPort.existsByIdentifier(identifier)) { + throw new EntityTemplateAlreadyExistsException(identifier); } + } - /// Validates all property definitions within the template for structural and - /// referential integrity. - /// - /// **Contract:** Enforces properties business rules - /// - Property rules integrity: all rules referenced by properties must - /// be valid and coherent based on the property's type - /// - /// **Precondition:** propertiesDefinitions must not be null - /// - /// @param entityTemplate the template containing properties to validate - /// @throws PropertyDefinitionRulesConflictException when rules violate business - /// logic - private void validateTemplateProperties(EntityTemplate entityTemplate) { - for (PropertyDefinition property : entityTemplate.propertiesDefinitions()) { - propertyDefinitionValidationService.validatePropertyDefinitionRules(property); - } + /// Checks that no other template already uses the given name. + /// + /// @param name the name to check for uniqueness + /// @throws EntityTemplateNameAlreadyExistsException when name is already taken + public void validateNameUniqueness(String name) { + if (entityTemplateRepositoryPort.existsByName(name)) { + throw new EntityTemplateNameAlreadyExistsException(name); } + } - /// Validates all relation definitions within the template for structural and - /// referential integrity. - /// - /// **Contract:** Enforces relation business rules - /// - No relation may target the template's own identifier - /// - Referential integrity: all target templates referenced by relations must - /// exist in the system - /// - /// **Precondition:** relationsDefinitions must not be null - /// - /// @param entityTemplate the template containing relations to validate - /// @throws TargetTemplateNotFoundException if any referenced target template - /// @throws RelationCannotTargetItselfException if a relation targets the template itself - /// doesn't exist - private void validateTemplateRelations(EntityTemplate entityTemplate) { - relationDefinitionValidationService.validateRelationNoSelfReference(entityTemplate.identifier(), entityTemplate.relationsDefinitions()); - relationDefinitionValidationService.validateTargetTemplatesExist(entityTemplate.relationsDefinitions()); + /// Validates all property definitions within the template for structural and + /// referential integrity. + /// + /// **Contract:** Enforces properties business rules + /// - Property rules integrity: all rules referenced by properties must + /// be valid and coherent based on the property's type + /// + /// **Precondition:** propertiesDefinitions must not be null + /// + /// @param entityTemplate the template containing properties to validate + /// @throws PropertyDefinitionRulesConflictException when rules violate business + /// logic + private void validateTemplateProperties(EntityTemplate entityTemplate) { + for (PropertyDefinition property : entityTemplate.propertiesDefinitions()) { + propertyDefinitionValidationService.validatePropertyDefinitionRules(property); } + } + + /// Validates all relation definitions within the template for structural and + /// referential integrity. + /// + /// **Contract:** Enforces relation business rules + /// - No relation may target the template's own identifier + /// - Referential integrity: all target templates referenced by relations must + /// exist in the system + /// + /// **Precondition:** relationsDefinitions must not be null + /// + /// @param entityTemplate the template containing relations to validate + /// @throws TargetTemplateNotFoundException if any referenced target template + /// @throws RelationCannotTargetItselfException if a relation targets the + /// template itself + /// doesn't exist + private void validateTemplateRelations(EntityTemplate entityTemplate) { + relationDefinitionValidationService.validateRelationNoSelfReference(entityTemplate.identifier(), + entityTemplate.relationsDefinitions()); + relationDefinitionValidationService + .validateTargetTemplatesExist(entityTemplate.relationsDefinitions()); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java index 209f285c..d61a4bda 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java @@ -44,305 +44,264 @@ @RequiredArgsConstructor public class PropertyDefinitionValidationService { - private final PropertyRegexValidationService propertyRegexValidationService; + private final PropertyRegexValidationService propertyRegexValidationService; - // Rule name constants - public static final String REGEX = "regex"; - public static final String LENGTH = "length"; - public static final String VALUE = "value"; - public static final String FORMAT = "format"; - public static final String ENUM_VALUES = "enum_values"; - public static final String MAX_LENGTH = "max_length"; - public static final String MIN_LENGTH = "min_length"; - public static final String MAX_VALUE = "max_value"; - public static final String MIN_VALUE = "min_value"; + // Rule name constants + public static final String REGEX = "regex"; + public static final String LENGTH = "length"; + public static final String VALUE = "value"; + public static final String FORMAT = "format"; + public static final String ENUM_VALUES = "enum_values"; + public static final String MAX_LENGTH = "max_length"; + public static final String MIN_LENGTH = "min_length"; + public static final String MAX_VALUE = "max_value"; + public static final String MIN_VALUE = "min_value"; - /// Validates that all property names are unique within a template. - /// - /// **Contract:** Enforces the invariant that property names must be unique. Used - /// during template creation and updates to prevent duplicate property - /// definitions. - /// - /// @param properties the list of property definitions to validate - /// @throws PropertyNameAlreadyExistsException if duplicate property names - /// are found - public void validatePropertyNamesUniqueness(List properties) { - Set names = new HashSet<>(); - for (PropertyDefinition property : properties) { - if (property.name() != null) { - String normalizedName = property.name().toLowerCase(Locale.ROOT); - if (!names.add(normalizedName)) { - throw new PropertyNameAlreadyExistsException(property.name()); - } - } + /// Validates that all property names are unique within a template. + /// + /// **Contract:** Enforces the invariant that property names must be unique. + /// Used + /// during template creation and updates to prevent duplicate property + /// definitions. + /// + /// @param properties the list of property definitions to validate + /// @throws PropertyNameAlreadyExistsException if duplicate property names + /// are found + public void validatePropertyNamesUniqueness(List properties) { + Set names = new HashSet<>(); + for (PropertyDefinition property : properties) { + if (property.name() != null) { + String normalizedName = property.name().toLowerCase(Locale.ROOT); + if (!names.add(normalizedName)) { + throw new PropertyNameAlreadyExistsException(property.name()); } + } } + } - /// Validates that property types are not changed on existing properties. - /// - /// **Contract:** Enforces the invariant that property types cannot be modified - /// after initial creation. Any attempt to change a property type is forbidden. - /// Users must delete and recreate the property if they need to change its type. - /// - /// @param existingProperties the existing property definitions - /// @param incomingProperties the new/updated property definitions - /// @throws PropertyTypeChangeException if any property type change is attempted - public void validateTypeChanges(List existingProperties, List incomingProperties) { - if (existingProperties == null || existingProperties.isEmpty() || - incomingProperties == null || incomingProperties.isEmpty()) { - return; - } - Map updatedMap = incomingProperties.stream() - .collect(Collectors.toMap(p -> p.name().toLowerCase(Locale.ROOT), p -> p)); + /// Validates that property types are not changed on existing properties. + /// + /// **Contract:** Enforces the invariant that property types cannot be modified + /// after initial creation. Any attempt to change a property type is forbidden. + /// Users must delete and recreate the property if they need to change its type. + /// + /// @param existingProperties the existing property definitions + /// @param incomingProperties the new/updated property definitions + /// @throws PropertyTypeChangeException if any property type change is attempted + public void validateTypeChanges(List existingProperties, + List incomingProperties) { + if (existingProperties == null || existingProperties.isEmpty() || incomingProperties == null + || incomingProperties.isEmpty()) { + return; + } + Map updatedMap = incomingProperties.stream() + .collect(Collectors.toMap(p -> p.name().toLowerCase(Locale.ROOT), p -> p)); - for (PropertyDefinition existing : existingProperties) { - PropertyDefinition updated = updatedMap.get(existing.name().toLowerCase(Locale.ROOT)); - boolean propertyTypeChanged = updated != null && !existing.type().equals(updated.type()); + for (PropertyDefinition existing : existingProperties) { + PropertyDefinition updated = updatedMap.get(existing.name().toLowerCase(Locale.ROOT)); + boolean propertyTypeChanged = updated != null && !existing.type().equals(updated.type()); - if (propertyTypeChanged) { - throw new PropertyTypeChangeException( - existing.name(), - existing.type(), - updated.type()); - } - } + if (propertyTypeChanged) { + throw new PropertyTypeChangeException(existing.name(), existing.type(), updated.type()); + } } + } - /// Validates property rules are compatible with the property's data type. - /// - /// **Contract:** Performs comprehensive validation including: - /// - Rule type compatibility with property type - /// - Numeric constraint ordering (min ≤ max) - /// - Boolean properties reject all rules - /// - /// @param propertyDefinition the property definition containing type and rules - /// @throws PropertyDefinitionRulesConflictException when rules violate business invariants - public void validatePropertyDefinitionRules(PropertyDefinition propertyDefinition) { - if (propertyDefinition.rules() == null) { - return; - } + /// Validates property rules are compatible with the property's data type. + /// + /// **Contract:** Performs comprehensive validation including: + /// - Rule type compatibility with property type + /// - Numeric constraint ordering (min ≤ max) + /// - Boolean properties reject all rules + /// + /// @param propertyDefinition the property definition containing type and rules + /// @throws PropertyDefinitionRulesConflictException when rules violate business + /// invariants + public void validatePropertyDefinitionRules(PropertyDefinition propertyDefinition) { + if (propertyDefinition.rules() == null) { + return; + } - PropertyRules rules = propertyDefinition.rules(); - PropertyType type = propertyDefinition.type(); + PropertyRules rules = propertyDefinition.rules(); + PropertyType type = propertyDefinition.type(); - switch (type) { - case STRING: - validateStringPropertyRules(propertyDefinition.name(), rules); - break; - case NUMBER: - validateNumberPropertyRules(propertyDefinition.name(), rules); - break; - case BOOLEAN: - validateBooleanPropertyRules(propertyDefinition.name(), rules); - break; - default: - throw new IllegalArgumentException("Unknown property type: " + type); - } + switch (type) { + case STRING : + validateStringPropertyRules(propertyDefinition.name(), rules); + break; + case NUMBER : + validateNumberPropertyRules(propertyDefinition.name(), rules); + break; + case BOOLEAN : + validateBooleanPropertyRules(propertyDefinition.name(), rules); + break; + default : + throw new IllegalArgumentException("Unknown property type: " + type); } + } - /// Validates rules for STRING property type. - /// - /// **Allowed rules:** format, enum_values, regex, max_length, min_length - /// **Rejected rules:** max_value, min_value (numeric) - /// **Conflicting rules:** format, regex, and enum_values are mutually exclusive; - /// enum_values is also mutually exclusive with max_length and min_length - /// **Constraints:** 0 ≤ min_length ≤ max_length, regex must be valid - /// - /// @param propertyName name of the property (for error reporting) - /// @param rules the property rules to validate - /// @throws PropertyDefinitionRulesConflictException when rules defined violate any of the above constraints - private void validateStringPropertyRules(String propertyName, PropertyRules rules) { - validateStringIncompatibleRules(propertyName, rules); - validateStringConstraints(propertyName, rules); + /// Validates rules for STRING property type. + /// + /// **Allowed rules:** format, enum_values, regex, max_length, min_length + /// **Rejected rules:** max_value, min_value (numeric) + /// **Conflicting rules:** format, regex, and enum_values are mutually + /// exclusive; + /// enum_values is also mutually exclusive with max_length and min_length + /// **Constraints:** 0 ≤ min_length ≤ max_length, regex must be valid + /// + /// @param propertyName name of the property (for error reporting) + /// @param rules the property rules to validate + /// @throws PropertyDefinitionRulesConflictException when rules defined violate + /// any of the above constraints + private void validateStringPropertyRules(String propertyName, PropertyRules rules) { + validateStringIncompatibleRules(propertyName, rules); + validateStringConstraints(propertyName, rules); - // Validate regex pattern is valid - if (rules.regex() != null && !rules.regex().isBlank()) { - propertyRegexValidationService.validateRegexPattern(propertyName, rules.regex()); - } + // Validate regex pattern is valid + if (rules.regex() != null && !rules.regex().isBlank()) { + propertyRegexValidationService.validateRegexPattern(propertyName, rules.regex()); } + } - /// Validates numeric constraints for STRING property rules. - /// - /// **Constraints enforced:** - /// - min_length must be non-negative (≥ 0) - /// - max_length must be positive (> 0) - /// - min_length must be less than or equal to max_length - /// - /// @param propertyName name of the property (for error reporting) - /// @param rules the property rules to validate - /// @throws PropertyDefinitionRulesConflictException when any constraint is violated - private void validateStringConstraints(String propertyName, PropertyRules rules) { - // Validate min_length is non-negative - if (rules.minLength() != null && rules.minLength() < 0) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.STRING, - PROPERTY_RULES_MIN_LENGTH_NON_NEGATIVE - ); - } - // Validate max_length is not zero or negative - if (rules.maxLength() != null && rules.maxLength() <= 0) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.STRING, - PROPERTY_RULES_MAX_LENGTH_POSITIVE - ); - } - // Validate min_length is below or equal to max_length - if (rules.minLength() != null && rules.maxLength() != null && rules.minLength() > rules.maxLength()) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.STRING, - minMaxConstraintViolated(LENGTH) - ); - } + /// Validates numeric constraints for STRING property rules. + /// + /// **Constraints enforced:** + /// - min_length must be non-negative (≥ 0) + /// - max_length must be positive (> 0) + /// - min_length must be less than or equal to max_length + /// + /// @param propertyName name of the property (for error reporting) + /// @param rules the property rules to validate + /// @throws PropertyDefinitionRulesConflictException when any constraint is + /// violated + private void validateStringConstraints(String propertyName, PropertyRules rules) { + // Validate min_length is non-negative + if (rules.minLength() != null && rules.minLength() < 0) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + PROPERTY_RULES_MIN_LENGTH_NON_NEGATIVE); } + // Validate max_length is not zero or negative + if (rules.maxLength() != null && rules.maxLength() <= 0) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + PROPERTY_RULES_MAX_LENGTH_POSITIVE); + } + // Validate min_length is below or equal to max_length + if (rules.minLength() != null && rules.maxLength() != null + && rules.minLength() > rules.maxLength()) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + minMaxConstraintViolated(LENGTH)); + } + } - /// Validates rule compatibility and mutual exclusivity for STRING property rules. - /// - /// **Incompatibility rules enforced:** - /// - Numeric rules (max_value, min_value) are not allowed for STRING type - /// - format, regex, and enum_values are mutually exclusive - /// - enum_values and length constraints (max_length, min_length) are mutually exclusive - /// - /// @param propertyName name of the property (for error reporting) - /// @param rules the property rules to validate - /// @throws PropertyDefinitionRulesConflictException when incompatible rules are both present - private void validateStringIncompatibleRules(String propertyName, PropertyRules rules) { - // Reject numeric rules for STRING type - if (rules.maxValue() != null || rules.minValue() != null) { - String ruleName = rules.maxValue() != null ? MAX_VALUE : MIN_VALUE; - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.STRING, - PROPERTY_RULES_NUMERIC_RULE_NOT_ALLOWED.replace("{rule}", ruleName) - ); - } - - // format, regex, and enum_values are incompatible with each other - if (rules.format() != null && rules.enumValues() != null) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.STRING, - rulesAreIncompatible(FORMAT, ENUM_VALUES) - ); - } - if (rules.format() != null && rules.regex() != null) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.STRING, - rulesAreIncompatible(FORMAT, REGEX) - ); - } - if (rules.regex() != null && rules.enumValues() != null) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.STRING, - rulesAreIncompatible(REGEX, ENUM_VALUES) - ); - } + /// Validates rule compatibility and mutual exclusivity for STRING property + /// rules. + /// + /// **Incompatibility rules enforced:** + /// - Numeric rules (max_value, min_value) are not allowed for STRING type + /// - format, regex, and enum_values are mutually exclusive + /// - enum_values and length constraints (max_length, min_length) are mutually + /// exclusive + /// + /// @param propertyName name of the property (for error reporting) + /// @param rules the property rules to validate + /// @throws PropertyDefinitionRulesConflictException when incompatible rules are + /// both present + private void validateStringIncompatibleRules(String propertyName, PropertyRules rules) { + // Reject numeric rules for STRING type + if (rules.maxValue() != null || rules.minValue() != null) { + String ruleName = rules.maxValue() != null ? MAX_VALUE : MIN_VALUE; + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + PROPERTY_RULES_NUMERIC_RULE_NOT_ALLOWED.replace("{rule}", ruleName)); + } - // enum_values and length constraints are incompatible with each other - if (rules.enumValues() != null && rules.maxLength() != null) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.STRING, - rulesAreIncompatible(ENUM_VALUES, MAX_LENGTH) - ); - } - if (rules.enumValues() != null && rules.minLength() != null) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.STRING, - rulesAreIncompatible(ENUM_VALUES, MIN_LENGTH) - ); - } + // format, regex, and enum_values are incompatible with each other + if (rules.format() != null && rules.enumValues() != null) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + rulesAreIncompatible(FORMAT, ENUM_VALUES)); + } + if (rules.format() != null && rules.regex() != null) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + rulesAreIncompatible(FORMAT, REGEX)); + } + if (rules.regex() != null && rules.enumValues() != null) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + rulesAreIncompatible(REGEX, ENUM_VALUES)); + } + // enum_values and length constraints are incompatible with each other + if (rules.enumValues() != null && rules.maxLength() != null) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + rulesAreIncompatible(ENUM_VALUES, MAX_LENGTH)); + } + if (rules.enumValues() != null && rules.minLength() != null) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + rulesAreIncompatible(ENUM_VALUES, MIN_LENGTH)); } - /// Validates rules for NUMBER property type. - /// - /// **Allowed rules:** max_value, min_value - /// **Rejected rules:** format, enum_values, regex, max_length, min_length (string) - /// **Constraints:** min_value ≤ max_value - /// - /// @param propertyName name of the property (for error reporting) - /// @param rules the property rules to validate - /// @throws PropertyDefinitionRulesConflictException when string rules are present - /// or min/max value constraints are violated - private void validateNumberPropertyRules(String propertyName, PropertyRules rules) { - if (rules.format() != null) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.NUMBER, - ruleNotAllowed(FORMAT, PropertyType.NUMBER.name()) - ); - } + } - if (rules.enumValues() != null) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.NUMBER, - ruleNotAllowed(ENUM_VALUES, PropertyType.NUMBER.name()) - ); - } + /// Validates rules for NUMBER property type. + /// + /// **Allowed rules:** max_value, min_value + /// **Rejected rules:** format, enum_values, regex, max_length, min_length + /// (string) + /// **Constraints:** min_value ≤ max_value + /// + /// @param propertyName name of the property (for error reporting) + /// @param rules the property rules to validate + /// @throws PropertyDefinitionRulesConflictException when string rules are + /// present + /// or min/max value constraints are violated + private void validateNumberPropertyRules(String propertyName, PropertyRules rules) { + if (rules.format() != null) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER, + ruleNotAllowed(FORMAT, PropertyType.NUMBER.name())); + } - if (rules.regex() != null) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.NUMBER, - ruleNotAllowed(REGEX, PropertyType.NUMBER.name()) - ); - } + if (rules.enumValues() != null) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER, + ruleNotAllowed(ENUM_VALUES, PropertyType.NUMBER.name())); + } - if (rules.minLength() != null) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.NUMBER, - ruleNotAllowed(MIN_LENGTH, PropertyType.NUMBER.name()) - ); - } + if (rules.regex() != null) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER, + ruleNotAllowed(REGEX, PropertyType.NUMBER.name())); + } - if (rules.maxLength() != null) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.NUMBER, - ruleNotAllowed(MAX_LENGTH, PropertyType.NUMBER.name()) - ); - } + if (rules.minLength() != null) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER, + ruleNotAllowed(MIN_LENGTH, PropertyType.NUMBER.name())); + } - if (rules.minValue() != null && rules.maxValue() != null && rules.minValue() > rules.maxValue()) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.NUMBER, - minMaxConstraintViolated(VALUE) - ); - } + if (rules.maxLength() != null) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER, + ruleNotAllowed(MAX_LENGTH, PropertyType.NUMBER.name())); + } + + if (rules.minValue() != null && rules.maxValue() != null + && rules.minValue() > rules.maxValue()) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER, + minMaxConstraintViolated(VALUE)); } + } - /// Validates rules for BOOLEAN property type. - /// - /// **Allowed rules:** None - /// **Rejected rules:** All rules must be null or empty - /// - /// @param propertyName name of the property (for error reporting) - /// @param rules the property rules to validate - /// @throws PropertyDefinitionRulesConflictException when any rule is set for BOOLEAN - private void validateBooleanPropertyRules(String propertyName, PropertyRules rules) { - if (rules.format() != null || - rules.enumValues() != null || - rules.regex() != null || - rules.maxLength() != null || - rules.minLength() != null || - rules.maxValue() != null || - rules.minValue() != null) { + /// Validates rules for BOOLEAN property type. + /// + /// **Allowed rules:** None + /// **Rejected rules:** All rules must be null or empty + /// + /// @param propertyName name of the property (for error reporting) + /// @param rules the property rules to validate + /// @throws PropertyDefinitionRulesConflictException when any rule is set for + /// BOOLEAN + private void validateBooleanPropertyRules(String propertyName, PropertyRules rules) { + if (rules.format() != null || rules.enumValues() != null || rules.regex() != null + || rules.maxLength() != null || rules.minLength() != null || rules.maxValue() != null + || rules.minValue() != null) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.BOOLEAN, - PROPERTY_RULES_BOOLEAN_NOT_ALLOWED - ); - } + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.BOOLEAN, + PROPERTY_RULES_BOOLEAN_NOT_ALLOWED); } + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationService.java index 2d70bc14..ee4aca98 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationService.java @@ -26,301 +26,312 @@ @Service public class PropertyRegexValidationService { - // Static and thread-safe regex validator executor - private static final ExecutorService VALIDATION_EXECUTOR = Executors.newFixedThreadPool( - Runtime.getRuntime().availableProcessors(), - runnable -> { - Thread thread = new Thread(runnable, "regex-validator-thread"); - thread.setDaemon(true); - return thread; - } - ); - private static final int MAX_REGEX_LENGTH = 1000; - private static final int VALIDATION_TIMEOUT_MS = 30; - // Validation ReDoS probe string designed to trigger backtracking in vulnerable patterns - private static final String STRESS_PROBE = "a".repeat(50) + "!"; + // Static and thread-safe regex validator executor + private static final ExecutorService VALIDATION_EXECUTOR = Executors + .newFixedThreadPool(Runtime.getRuntime().availableProcessors(), runnable -> { + Thread thread = new Thread(runnable, "regex-validator-thread"); + thread.setDaemon(true); + return thread; + }); + private static final int MAX_REGEX_LENGTH = 1000; + private static final int VALIDATION_TIMEOUT_MS = 30; + // Validation ReDoS probe string designed to trigger backtracking in vulnerable + // patterns + private static final String STRESS_PROBE = "a".repeat(50) + "!"; - /// Validates the user-provided regex pattern against ReDoS and injection risks. - /// - /// **Security checks:** - /// 1. Rejects patterns exceeding 1,000 characters. - /// 2. Rejects known dangerous regex patterns. - /// 3. Ensures the pattern is valid Java regex. - /// 4. Detects ReDoS by executing pattern matching within 10ms timeout. - /// - /// @param propertyName name of the property (for error reporting) - /// @param regexPattern the regex pattern to validate - /// @throws PropertyDefinitionRulesConflictException if any security check fails - public void validateRegexPattern(String propertyName, String regexPattern) { - if (regexPattern.length() > MAX_REGEX_LENGTH) { - throw new PropertyDefinitionRulesConflictException( - propertyName, PropertyType.STRING, "Regex pattern too long (max " + MAX_REGEX_LENGTH + " characters)"); - } + /// Validates the user-provided regex pattern against ReDoS and injection risks. + /// + /// **Security checks:** + /// 1. Rejects patterns exceeding 1,000 characters. + /// 2. Rejects known dangerous regex patterns. + /// 3. Ensures the pattern is valid Java regex. + /// 4. Detects ReDoS by executing pattern matching within 10ms timeout. + /// + /// @param propertyName name of the property (for error reporting) + /// @param regexPattern the regex pattern to validate + /// @throws PropertyDefinitionRulesConflictException if any security check fails + public void validateRegexPattern(String propertyName, String regexPattern) { + if (regexPattern.length() > MAX_REGEX_LENGTH) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + "Regex pattern too long (max " + MAX_REGEX_LENGTH + " characters)"); + } - if (containsDangerousPatterns(regexPattern)) { - throw new PropertyDefinitionRulesConflictException( - propertyName, PropertyType.STRING, "Regex pattern contains potentially unsafe constructs"); - } + if (containsDangerousPatterns(regexPattern)) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + "Regex pattern contains potentially unsafe constructs"); + } - Pattern compiledRegexPattern; - try { - compiledRegexPattern = Pattern.compile(regexPattern); - } catch (PatternSyntaxException e) { - throw new PropertyDefinitionRulesConflictException( - propertyName, PropertyType.STRING, "Invalid regex pattern: " + e.getMessage()); - } + Pattern compiledRegexPattern; + try { + compiledRegexPattern = Pattern.compile(regexPattern); + } catch (PatternSyntaxException e) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + "Invalid regex pattern: " + e.getMessage()); + } - validatePatternWithTimeout(propertyName, compiledRegexPattern); - } + validatePatternWithTimeout(propertyName, compiledRegexPattern); + } - /// Validates pattern matching with a timeout to detect ReDoS (Regular Expression Denial of Service) vulnerabilities. - /// - /// Executes a pattern match against a stress probe within a 10 ms timeout using a shared, bounded executor - /// If the pattern takes longer than the timeout, it is rejected as potentially vulnerable to catastrophic backtracking. - /// - /// @param propertyName name of the property (for error reporting) - /// @param pattern the compiled pattern to test - /// @throws PropertyDefinitionRulesConflictException if the pattern times out or validation fails - private void validatePatternWithTimeout(String propertyName, Pattern pattern) { - Future future = VALIDATION_EXECUTOR.submit(() -> pattern.matcher(STRESS_PROBE).matches()); - try { - future.get(VALIDATION_TIMEOUT_MS, TimeUnit.MILLISECONDS); - } catch (TimeoutException _) { - future.cancel(true); - throw new PropertyDefinitionRulesConflictException( - propertyName, PropertyType.STRING, "Regex pattern rejected: execution time exceeded safety limits (ReDoS risk)"); - } catch (InterruptedException _) { - Thread.currentThread().interrupt(); - throw new PropertyDefinitionRulesConflictException( - propertyName, PropertyType.STRING, "Regex pattern validation was interrupted"); - } catch (ExecutionException e) { - throw new PropertyDefinitionRulesConflictException( - propertyName, PropertyType.STRING, "Regex validation failed: " + e.getCause().getMessage()); - } - } + /// Validates pattern matching with a timeout to detect ReDoS (Regular + /// Expression Denial of Service) vulnerabilities. + /// + /// Executes a pattern match against a stress probe within a 10 ms timeout using + /// a shared, bounded executor + /// If the pattern takes longer than the timeout, it is rejected as potentially + /// vulnerable to catastrophic backtracking. + /// + /// @param propertyName name of the property (for error reporting) + /// @param pattern the compiled pattern to test + /// @throws PropertyDefinitionRulesConflictException if the pattern times out or + /// validation fails + private void validatePatternWithTimeout(String propertyName, Pattern pattern) { + Future future = VALIDATION_EXECUTOR + .submit(() -> pattern.matcher(STRESS_PROBE).matches()); + try { + future.get(VALIDATION_TIMEOUT_MS, TimeUnit.MILLISECONDS); + } catch (TimeoutException _) { + future.cancel(true); + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + "Regex pattern rejected: execution time exceeded safety limits (ReDoS risk)"); + } catch (InterruptedException _) { + Thread.currentThread().interrupt(); + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + "Regex pattern validation was interrupted"); + } catch (ExecutionException e) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + "Regex validation failed: " + e.getCause().getMessage()); + } + } - /// Checks for known dangerous regex constructs using static string analysis. - /// - /// **Patterns detected:** - /// - Nested quantifiers: `(a+)+`, `(a*)*`, `(a+)*`, etc. - /// - Quantified alternation groups: `(a|b)+`, `(a|b)*` - /// - Unbounded repetition upper bounds greater than 1,000 (e.g. `{5,9999}`) - /// - Lookarounds with quantifiers: `(?=a+)`, `(?!a*)` - /// - /// **Implementation:** Uses static string analysis without regex matching - /// to avoid ReDoS vulnerabilities in the validator itself. - /// - /// @param pattern the raw regex string to analyse - /// @return `true` if potentially dangerous constructs are detected - private boolean containsDangerousPatterns(String pattern) { - return hasNestedQuantifiers(pattern) || - hasQuantifiedAlternation(pattern) || - hasLargeRepetitionBounds(pattern) || - hasLookaroundsWithQuantifiers(pattern); - } + /// Checks for known dangerous regex constructs using static string analysis. + /// + /// **Patterns detected:** + /// - Nested quantifiers: `(a+)+`, `(a*)*`, `(a+)*`, etc. + /// - Quantified alternation groups: `(a|b)+`, `(a|b)*` + /// - Unbounded repetition upper bounds greater than 1,000 (e.g. `{5,9999}`) + /// - Lookarounds with quantifiers: `(?=a+)`, `(?!a*)` + /// + /// **Implementation:** Uses static string analysis without regex matching + /// to avoid ReDoS vulnerabilities in the validator itself. + /// + /// @param pattern the raw regex string to analyse + /// @return `true` if potentially dangerous constructs are detected + private boolean containsDangerousPatterns(String pattern) { + return hasNestedQuantifiers(pattern) || hasQuantifiedAlternation(pattern) + || hasLargeRepetitionBounds(pattern) || hasLookaroundsWithQuantifiers(pattern); + } - /// Detects nested quantifiers like `(a+)+`, `(a*)*`, `(a+)*`, etc. - /// Uses simple character-by-character analysis without regex. - /// - /// @param pattern the regex pattern string - /// @return true if nested quantifiers are found - private boolean hasNestedQuantifiers(String pattern) { - for (int i = 0; i < pattern.length(); i++) { - if (pattern.charAt(i) == '(' && matchesQuantifiedGroup(pattern, i, this::containsQuantifier)) { - return true; - } - } - return false; - } + /// Detects nested quantifiers like `(a+)+`, `(a*)*`, `(a+)*`, etc. + /// Uses simple character-by-character analysis without regex. + /// + /// @param pattern the regex pattern string + /// @return true if nested quantifiers are found + private boolean hasNestedQuantifiers(String pattern) { + for (int i = 0; i < pattern.length(); i++) { + if (pattern.charAt(i) == '(' + && matchesQuantifiedGroup(pattern, i, this::containsQuantifier)) { + return true; + } + } + return false; + } - /// Checks if a group starting at index i matches the quantified pattern criteria. - /// The pattern must have a closing paren followed by a quantifier (+, *, ?, {), - /// and the group content must match the provided test. - /// - /// @param pattern the regex pattern string - /// @param groupStartIndex the index of the opening parenthesis - /// @param test the test to apply to group content - /// @return true if the group matches the criteria - private boolean matchesQuantifiedGroup(String pattern, int groupStartIndex, Predicate test) { - int closeIndex = findMatchingCloseParenthesis(pattern, groupStartIndex); - if (closeIndex == -1 || closeIndex + 1 >= pattern.length()) { - return false; - } + /// Checks if a group starting at index i matches the quantified pattern + /// criteria. + /// The pattern must have a closing paren followed by a quantifier (+, *, ?, {), + /// and the group content must match the provided test. + /// + /// @param pattern the regex pattern string + /// @param groupStartIndex the index of the opening parenthesis + /// @param test the test to apply to group content + /// @return true if the group matches the criteria + private boolean matchesQuantifiedGroup(String pattern, int groupStartIndex, + Predicate test) { + int closeIndex = findMatchingCloseParenthesis(pattern, groupStartIndex); + if (closeIndex == -1 || closeIndex + 1 >= pattern.length()) { + return false; + } - char nextChar = pattern.charAt(closeIndex + 1); - if (!isQuantifier(nextChar)) { - return false; - } + char nextChar = pattern.charAt(closeIndex + 1); + if (!isQuantifier(nextChar)) { + return false; + } - String groupContent = pattern.substring(groupStartIndex + 1, closeIndex); - return test.test(groupContent); - } + String groupContent = pattern.substring(groupStartIndex + 1, closeIndex); + return test.test(groupContent); + } - /// Detects quantified alternation groups like `(a|b)+` or `(a|b)*`. - /// Uses simple character-by-character analysis without regex. - /// - /// @param pattern the regex pattern string - /// @return true if quantified alternation is found - private boolean hasQuantifiedAlternation(String pattern) { - for (int i = 0; i < pattern.length(); i++) { - if (pattern.charAt(i) == '(' && matchesQuantifiedGroup(pattern, i, groupContent -> groupContent.contains("|"))) { - return true; - } - } - return false; - } + /// Detects quantified alternation groups like `(a|b)+` or `(a|b)*`. + /// Uses simple character-by-character analysis without regex. + /// + /// @param pattern the regex pattern string + /// @return true if quantified alternation is found + private boolean hasQuantifiedAlternation(String pattern) { + for (int i = 0; i < pattern.length(); i++) { + if (pattern.charAt(i) == '(' + && matchesQuantifiedGroup(pattern, i, groupContent -> groupContent.contains("|"))) { + return true; + } + } + return false; + } - /// Detects repetition bounds with excessively large upper limits like `{5,9999}`. - /// Uses simple character-by-character analysis without regex. - /// - /// @param pattern the regex pattern string - /// @return true if large repetition bounds are found - private boolean hasLargeRepetitionBounds(String pattern) { - int i = 0; - while (i < pattern.length()) { - if (pattern.charAt(i) == '{') { - if (isLargeRepetitionBound(pattern, i)) { - return true; - } - int closeIndex = pattern.indexOf('}', i); - i = closeIndex != -1 ? closeIndex + 1 : i + 1; - } else { - i++; - } - } - return false; - } + /// Detects repetition bounds with excessively large upper limits like + /// `{5,9999}`. + /// Uses simple character-by-character analysis without regex. + /// + /// @param pattern the regex pattern string + /// @return true if large repetition bounds are found + private boolean hasLargeRepetitionBounds(String pattern) { + int i = 0; + while (i < pattern.length()) { + if (pattern.charAt(i) == '{') { + if (isLargeRepetitionBound(pattern, i)) { + return true; + } + int closeIndex = pattern.indexOf('}', i); + i = closeIndex != -1 ? closeIndex + 1 : i + 1; + } else { + i++; + } + } + return false; + } - /// Checks if the repetition bound starting at position i exceeds the safe limit. - /// - /// @param pattern the regex pattern string - /// @param startIndex the index of the opening brace - /// @return true if the upper bound is greater than 1000 - private boolean isLargeRepetitionBound(String pattern, int startIndex) { - int closeIndex = pattern.indexOf('}', startIndex); - if (closeIndex == -1) { - return false; - } + /// Checks if the repetition bound starting at position i exceeds the safe + /// limit. + /// + /// @param pattern the regex pattern string + /// @param startIndex the index of the opening brace + /// @return true if the upper bound is greater than 1000 + private boolean isLargeRepetitionBound(String pattern, int startIndex) { + int closeIndex = pattern.indexOf('}', startIndex); + if (closeIndex == -1) { + return false; + } - String bounds = pattern.substring(startIndex + 1, closeIndex); - return hasExcessiveUpperBound(bounds); - } + String bounds = pattern.substring(startIndex + 1, closeIndex); + return hasExcessiveUpperBound(bounds); + } - /// Parses a repetition bound string and checks if the upper limit exceeds 1000. - /// - /// @param bounds the bounds string (e.g., "5,9999" or "1,100") - /// @return true if upper bound is greater than 1000 - private boolean hasExcessiveUpperBound(String bounds) { - if (!bounds.contains(",")) { - return false; - } + /// Parses a repetition bound string and checks if the upper limit exceeds 1000. + /// + /// @param bounds the bounds string (e.g., "5,9999" or "1,100") + /// @return true if upper bound is greater than 1000 + private boolean hasExcessiveUpperBound(String bounds) { + if (!bounds.contains(",")) { + return false; + } - String[] parts = bounds.split(","); - if (parts.length != 2 || parts[1].trim().isEmpty()) { - return false; - } + String[] parts = bounds.split(","); + if (parts.length != 2 || parts[1].trim().isEmpty()) { + return false; + } - try { - int upper = Integer.parseInt(parts[1].trim()); - return upper > 1000; - } catch (NumberFormatException _) { - return false; - } - } + try { + int upper = Integer.parseInt(parts[1].trim()); + return upper > 1000; + } catch (NumberFormatException _) { + return false; + } + } - /// Detects lookarounds with quantifiers like `(?=a+)`, `(?!a*)`, etc. - /// These can amplify backtracking behavior and pose ReDoS risks. - /// Uses simple character-by-character analysis without regex. - /// - /// @param pattern the regex pattern string - /// @return true if lookarounds with quantifiers are found - private boolean hasLookaroundsWithQuantifiers(String pattern) { - for (int i = 0; i < pattern.length() - 3; i++) { - if (isLookaroundAt(pattern, i)) { - int closeIndex = findMatchingCloseParenthesis(pattern, i); - if (closeIndex != -1) { - String lookaroundContent = pattern.substring(i, closeIndex + 1); - if (containsQuantifier(lookaroundContent)) { - return true; - } - } - } - } - return false; - } + /// Detects lookarounds with quantifiers like `(?=a+)`, `(?!a*)`, etc. + /// These can amplify backtracking behavior and pose ReDoS risks. + /// Uses simple character-by-character analysis without regex. + /// + /// @param pattern the regex pattern string + /// @return true if lookarounds with quantifiers are found + private boolean hasLookaroundsWithQuantifiers(String pattern) { + for (int i = 0; i < pattern.length() - 3; i++) { + if (isLookaroundAt(pattern, i)) { + int closeIndex = findMatchingCloseParenthesis(pattern, i); + if (closeIndex != -1) { + String lookaroundContent = pattern.substring(i, closeIndex + 1); + if (containsQuantifier(lookaroundContent)) { + return true; + } + } + } + } + return false; + } - /// Checks if position i in pattern is the start of a lookaround construct. - /// Lookarounds are: `(?=...)`, `(?!...)`, `(?<=...)`, `(? relations) { - Set names = new HashSet<>(); - relations.stream() - .map(RelationDefinition::name) - .filter(Objects::nonNull) - .map(name -> name.toLowerCase(Locale.ROOT)) - .filter(name -> !names.add(name)) - .findFirst() - .ifPresent(name -> { - throw new RelationNameAlreadyExistsException(name); - }); - } + /// Validates that all relation names are unique within a template. + /// + /// @param relations the list of relation definitions to validate + /// @throws RelationNameAlreadyExistsException if duplicate relation names + /// are found + public void validateRelationNamesUniqueness(List relations) { + Set names = new HashSet<>(); + relations.stream().map(RelationDefinition::name).filter(Objects::nonNull) + .map(name -> name.toLowerCase(Locale.ROOT)).filter(name -> !names.add(name)).findFirst() + .ifPresent(name -> { + throw new RelationNameAlreadyExistsException(name); + }); + } - /// Validates that all target templates exist for the given relations. - /// - /// **Contract:** Ensures referential integrity by verifying that every - /// target template referenced by a relation exists in the system. - /// - /// @param relations the list of relation definitions to validate - /// @throws TargetTemplateNotFoundException if any referenced target template - /// doesn't exist or is null - public void validateTargetTemplatesExist(List relations) { - for (RelationDefinition relation : relations) { - String targetIdentifier = relation.targetTemplateIdentifier(); - if (targetIdentifier == null || !entityTemplateRepositoryPort.existsByIdentifier(targetIdentifier)) { - throw new TargetTemplateNotFoundException(targetIdentifier); - } - } + /// Validates that all target templates exist for the given relations. + /// + /// **Contract:** Ensures referential integrity by verifying that every + /// target template referenced by a relation exists in the system. + /// + /// @param relations the list of relation definitions to validate + /// @throws TargetTemplateNotFoundException if any referenced target template + /// doesn't exist or is null + public void validateTargetTemplatesExist(List relations) { + for (RelationDefinition relation : relations) { + String targetIdentifier = relation.targetTemplateIdentifier(); + if (targetIdentifier == null + || !entityTemplateRepositoryPort.existsByIdentifier(targetIdentifier)) { + throw new TargetTemplateNotFoundException(targetIdentifier); + } } + } - /// Validates that no relation definition targets itself (the template that owns it). - /// - /// @param templateIdentifier the identifier of the template being created or updated - /// @param relations the list of relation definitions to check - /// @throws RelationCannotTargetItselfException if any relation's target template equals the template's own identifier - public void validateRelationNoSelfReference(String templateIdentifier, List relations) { - if (templateIdentifier == null || relations == null || relations.isEmpty()) { - return; - } - relations.stream() - .filter(r -> templateIdentifier.equals(r.targetTemplateIdentifier())) - .findFirst() - .ifPresent(r -> { - throw new RelationCannotTargetItselfException(r.name(), templateIdentifier); - }); + /// Validates that no relation definition targets itself (the template that owns + /// it). + /// + /// @param templateIdentifier the identifier of the template being created or + /// updated + /// @param relations the list of relation definitions to check + /// @throws RelationCannotTargetItselfException if any relation's target + /// template equals the template's own identifier + public void validateRelationNoSelfReference(String templateIdentifier, + List relations) { + if (templateIdentifier == null || relations == null || relations.isEmpty()) { + return; } + relations.stream().filter(r -> templateIdentifier.equals(r.targetTemplateIdentifier())) + .findFirst().ifPresent(r -> { + throw new RelationCannotTargetItselfException(r.name(), templateIdentifier); + }); + } - /// Validates that target template identifiers are not changed on existing relations. - /// - /// **Contract:** Enforces the invariant that relation target templates cannot be - /// modified after initial creation. Any attempt to change a target template identifier - /// is forbidden, as existing entity relation values would point to the wrong template type. - /// - /// @param existingRelations the existing relation definitions (from the persisted template) - /// @param incomingRelations the new/updated relation definitions - /// @throws RelationTargetTemplateChangeException if any relation target template change is attempted - public void validateTargetTemplateChanges(List existingRelations, List incomingRelations) { - if (existingRelations == null || existingRelations.isEmpty() || - incomingRelations == null || incomingRelations.isEmpty()) { - return; - } + /// Validates that target template identifiers are not changed on existing + /// relations. + /// + /// **Contract:** Enforces the invariant that relation target templates cannot + /// be + /// modified after initial creation. Any attempt to change a target template + /// identifier + /// is forbidden, as existing entity relation values would point to the wrong + /// template type. + /// + /// @param existingRelations the existing relation definitions (from the + /// persisted template) + /// @param incomingRelations the new/updated relation definitions + /// @throws RelationTargetTemplateChangeException if any relation target + /// template change is attempted + public void validateTargetTemplateChanges(List existingRelations, + List incomingRelations) { + if (existingRelations == null || existingRelations.isEmpty() || incomingRelations == null + || incomingRelations.isEmpty()) { + return; + } - Map incomingMap = incomingRelations.stream() - .collect(Collectors.toMap(r -> r.name().toLowerCase(Locale.ROOT), Function.identity())); + Map incomingMap = incomingRelations.stream() + .collect(Collectors.toMap(r -> r.name().toLowerCase(Locale.ROOT), Function.identity())); - for (RelationDefinition existing : existingRelations) { - RelationDefinition incoming = incomingMap.get(existing.name().toLowerCase(Locale.ROOT)); - boolean targetChanged = incoming != null && - !Objects.equals(existing.targetTemplateIdentifier(), incoming.targetTemplateIdentifier()); + for (RelationDefinition existing : existingRelations) { + RelationDefinition incoming = incomingMap.get(existing.name().toLowerCase(Locale.ROOT)); + boolean targetChanged = incoming != null && !Objects + .equals(existing.targetTemplateIdentifier(), incoming.targetTemplateIdentifier()); - if (targetChanged) { - throw new RelationTargetTemplateChangeException( - existing.name(), - existing.targetTemplateIdentifier(), - incoming.targetTemplateIdentifier()); - } - } + if (targetChanged) { + throw new RelationTargetTemplateChangeException(existing.name(), + existing.targetTemplateIdentifier(), incoming.targetTemplateIdentifier()); + } } + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java index 604891cf..1fa6a619 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java @@ -24,71 +24,77 @@ import com.decathlon.idp_core.domain.model.enums.PropertyType; /** - * Domain service validating entity property values against template definitions. + * Domain service validating entity property values against template + * definitions. */ @Service public class PropertyValidationService { - private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9+_.-]+@(.+)$"); - private static final Pattern URL_PATTERN = Pattern.compile("^https?://.*$"); - - /// Cache of compiled regex patterns keyed by their source string. - /// Avoids recompiling the same pattern on every property validation call. - private final Map patternCache = new ConcurrentHashMap<>(); - - /** - * Validates a concrete property value against its property definition. - * The value's runtime Java type is checked first against the expected - * [PropertyType] (STRING ⇒ {@link String}, NUMBER ⇒ {@link Number}, - * BOOLEAN ⇒ {@link Boolean}). When the type matches, the value is - * normalized to a string and the type-specific rules are evaluated. - * - * @param propertyDefinition property definition with expected type and optional rules - * @param rawValue raw property value preserving its original JSON type - * @return list of violations for this value; empty when valid - */ - public List validatePropertyValue(PropertyDefinition propertyDefinition, Object rawValue) { - return switch (propertyDefinition.type()) { - case STRING -> validateStringPropertyValue(propertyDefinition.name(), rawValue, propertyDefinition.rules()); - case NUMBER -> validateNumberPropertyValue(propertyDefinition.name(), rawValue, propertyDefinition.rules()); - case BOOLEAN -> validateBooleanPropertyValue(propertyDefinition.name(), rawValue); - }; + private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9+_.-]+@(.+)$"); + private static final Pattern URL_PATTERN = Pattern.compile("^https?://.*$"); + + /// Cache of compiled regex patterns keyed by their source string. + /// Avoids recompiling the same pattern on every property validation call. + private final Map patternCache = new ConcurrentHashMap<>(); + + /** + * Validates a concrete property value against its property definition. The + * value's runtime Java type is checked first against the expected + * [PropertyType] (STRING ⇒ {@link String}, NUMBER ⇒ {@link Number}, BOOLEAN ⇒ + * {@link Boolean}). When the type matches, the value is normalized to a string + * and the type-specific rules are evaluated. + * + * @param propertyDefinition + * property definition with expected type and optional rules + * @param rawValue + * raw property value preserving its original JSON type + * @return list of violations for this value; empty when valid + */ + public List validatePropertyValue(PropertyDefinition propertyDefinition, + Object rawValue) { + return switch (propertyDefinition.type()) { + case STRING -> validateStringPropertyValue(propertyDefinition.name(), rawValue, + propertyDefinition.rules()); + case NUMBER -> validateNumberPropertyValue(propertyDefinition.name(), rawValue, + propertyDefinition.rules()); + case BOOLEAN -> validateBooleanPropertyValue(propertyDefinition.name(), rawValue); + }; + } + + private List validateStringPropertyValue(String propertyName, Object rawValue, + PropertyRules rules) { + if (!(rawValue instanceof String stringValue)) { + return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.STRING)); } + if (rules == null) { + return List.of(); + } - private List validateStringPropertyValue(String propertyName, Object rawValue, PropertyRules rules) { - if (!(rawValue instanceof String stringValue)) { - return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.STRING)); - } + var violations = new ArrayList(); - if (rules == null) { - return List.of(); - } - - var violations = new ArrayList(); - - if (rules.minLength() != null && stringValue.length() < rules.minLength()) { - violations.add(PROPERTY_MIN_LENGTH_VIOLATION.formatted(propertyName, rules.minLength())); - } - if (rules.maxLength() != null && stringValue.length() > rules.maxLength()) { - violations.add(PROPERTY_MAX_LENGTH_VIOLATION.formatted(propertyName, rules.maxLength())); - } - if (rules.regex() != null - && !patternCache.computeIfAbsent(rules.regex(), Pattern::compile).matcher(stringValue).matches()) { - violations.add(PROPERTY_REGEX_VIOLATION.formatted(propertyName)); - } - if (rules.enumValues() != null && !rules.enumValues().isEmpty() - && rules.enumValues().stream().noneMatch(enumValue -> enumValue.equalsIgnoreCase(stringValue))) { - violations.add(PROPERTY_ENUM_VIOLATION.formatted(propertyName, rules.enumValues())); - } - if (rules.format() != null && !matchesFormat(rules.format(), stringValue)) { - violations.add(PROPERTY_FORMAT_VIOLATION.formatted(propertyName, rules.format())); - } - - return List.copyOf(violations); + if (rules.minLength() != null && stringValue.length() < rules.minLength()) { + violations.add(PROPERTY_MIN_LENGTH_VIOLATION.formatted(propertyName, rules.minLength())); + } + if (rules.maxLength() != null && stringValue.length() > rules.maxLength()) { + violations.add(PROPERTY_MAX_LENGTH_VIOLATION.formatted(propertyName, rules.maxLength())); + } + if (rules.regex() != null && !patternCache.computeIfAbsent(rules.regex(), Pattern::compile) + .matcher(stringValue).matches()) { + violations.add(PROPERTY_REGEX_VIOLATION.formatted(propertyName)); + } + if (rules.enumValues() != null && !rules.enumValues().isEmpty() && rules.enumValues().stream() + .noneMatch(enumValue -> enumValue.equalsIgnoreCase(stringValue))) { + violations.add(PROPERTY_ENUM_VIOLATION.formatted(propertyName, rules.enumValues())); + } + if (rules.format() != null && !matchesFormat(rules.format(), stringValue)) { + violations.add(PROPERTY_FORMAT_VIOLATION.formatted(propertyName, rules.format())); } - private List validateNumberPropertyValue(String propertyName, Object rawValue, PropertyRules rules) { + return List.copyOf(violations); + } + + private List validateNumberPropertyValue(String propertyName, Object rawValue, PropertyRules rules) { final BigDecimal parsedValue; switch (rawValue) { case Number number -> parsedValue = new BigDecimal(number.toString()); @@ -120,21 +126,21 @@ private List validateNumberPropertyValue(String propertyName, Object raw return List.copyOf(violations); } - private List validateBooleanPropertyValue(String propertyName, Object rawValue) { - if (rawValue instanceof Boolean) { - return List.of(); - } - if (rawValue instanceof String string - && ("true".equalsIgnoreCase(string) || "false".equalsIgnoreCase(string))) { - return List.of(); - } - return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.BOOLEAN)); + private List validateBooleanPropertyValue(String propertyName, Object rawValue) { + if (rawValue instanceof Boolean) { + return List.of(); } - - private boolean matchesFormat(PropertyFormat format, String value) { - return switch (format) { - case EMAIL -> EMAIL_PATTERN.matcher(value).matches(); - case URL -> URL_PATTERN.matcher(value).matches(); - }; + if (rawValue instanceof String string + && ("true".equalsIgnoreCase(string) || "false".equalsIgnoreCase(string))) { + return List.of(); } + return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.BOOLEAN)); + } + + private boolean matchesFormat(PropertyFormat format, String value) { + return switch (format) { + case EMAIL -> EMAIL_PATTERN.matcher(value).matches(); + case URL -> URL_PATTERN.matcher(value).matches(); + }; + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java index d71c3287..6c516c05 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java @@ -6,15 +6,17 @@ /// Type-safe CORS configuration properties bound from `spring.web.cors`. @ConfigurationProperties(prefix = "spring.web.cors") -public record CorsProperties( - List allowedOrigins, - List allowedOriginPatterns -) { - /// Compact constructor: normalises null to empty and defensively copies every list - /// to prevent external mutation of the internal state (EI_EXPOSE_REP / EI_EXPOSE_REP2). - /// List.copyOf() also rejects null elements, enforcing a clean configuration contract. - public CorsProperties { - allowedOrigins = allowedOrigins == null ? List.of() : List.copyOf(allowedOrigins); - allowedOriginPatterns = allowedOriginPatterns == null ? List.of() : List.copyOf(allowedOriginPatterns); - } +public record CorsProperties(List allowedOrigins, List allowedOriginPatterns) { + /// Compact constructor: normalises null to empty and defensively copies every + /// list + /// to prevent external mutation of the internal state (EI_EXPOSE_REP / + /// EI_EXPOSE_REP2). + /// List.copyOf() also rejects null elements, enforcing a clean configuration + /// contract. + public CorsProperties { + allowedOrigins = allowedOrigins == null ? List.of() : List.copyOf(allowedOrigins); + allowedOriginPatterns = allowedOriginPatterns == null + ? List.of() + : List.copyOf(allowedOriginPatterns); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/JwtConfiguration.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/JwtConfiguration.java index 9bd7861e..c90315dd 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/JwtConfiguration.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/JwtConfiguration.java @@ -22,13 +22,12 @@ @Configuration public class JwtConfiguration { - @Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") - private String jwkSetUri; + @Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") + private String jwkSetUri; - @Bean - @ConditionalOnMissingBean - public JwtDecoder jwtDecoder() { - return NimbusJwtDecoder.withJwkSetUri(jwkSetUri) - .build(); - } + @Bean + @ConditionalOnMissingBean + public JwtDecoder jwtDecoder() { + return NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build(); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java index 9242ef9b..8a492d21 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SecurityConfiguration.java @@ -32,44 +32,40 @@ @EnableConfigurationProperties(CorsProperties.class) public class SecurityConfiguration { - private final CorsProperties corsProperties; + private final CorsProperties corsProperties; - public SecurityConfiguration(CorsProperties corsProperties) { - this.corsProperties = corsProperties; - } + public SecurityConfiguration(CorsProperties corsProperties) { + this.corsProperties = corsProperties; + } - @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) { - http.authorizeHttpRequests(authorize -> authorize - .requestMatchers("/actuator/**").permitAll() - .requestMatchers("/", "/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**").permitAll() - .requestMatchers("/api/v1/**").fullyAuthenticated() - .anyRequest().authenticated() - ) - .cors(withDefaults()) - .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())); - return http.build(); - } + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) { + http.authorizeHttpRequests(authorize -> authorize.requestMatchers("/actuator/**").permitAll() + .requestMatchers("/", "/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**").permitAll() + .requestMatchers("/api/v1/**").fullyAuthenticated().anyRequest().authenticated()) + .cors(withDefaults()).oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())); + return http.build(); + } - @Bean - public CorsConfigurationSource corsConfigurationSource() { - CorsConfiguration configuration = new CorsConfiguration(); + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); - // Exact origins (no wildcard, safe with allowCredentials) - if (!corsProperties.allowedOrigins().isEmpty()) { - configuration.setAllowedOrigins(corsProperties.allowedOrigins()); - } + // Exact origins (no wildcard, safe with allowCredentials) + if (!corsProperties.allowedOrigins().isEmpty()) { + configuration.setAllowedOrigins(corsProperties.allowedOrigins()); + } - if (!corsProperties.allowedOriginPatterns().isEmpty()) { - configuration.setAllowedOriginPatterns(corsProperties.allowedOriginPatterns()); - } + if (!corsProperties.allowedOriginPatterns().isEmpty()) { + configuration.setAllowedOriginPatterns(corsProperties.allowedOriginPatterns()); + } - configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); - configuration.setAllowedHeaders(List.of("*")); - configuration.setAllowCredentials(true); + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(List.of("*")); + configuration.setAllowCredentials(true); - UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", configuration); - return source; - } + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SpringDataWebConfiguration.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SpringDataWebConfiguration.java index e426d98c..43285da3 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SpringDataWebConfiguration.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SpringDataWebConfiguration.java @@ -4,7 +4,6 @@ import org.springframework.context.annotation.Configuration; import org.springframework.data.web.config.EnableSpringDataWebSupport; - /// Spring Data Web configuration optimizing REST API response serialization. /// /// **Infrastructure rationale:** Configures clean DTO-style pagination responses instead @@ -21,9 +20,7 @@ /// **Alternative avoided:** Default HATEOAS format includes `_links` and `_embedded` /// properties that increase response size and complexity for simple API consumption. @Configuration -@EnableSpringDataWebSupport( - pageSerializationMode = EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO -) +@EnableSpringDataWebSupport(pageSerializationMode = EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO) public class SpringDataWebConfiguration { } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerConfiguration.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerConfiguration.java index e9eac3ec..a0e0e7cc 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerConfiguration.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerConfiguration.java @@ -49,59 +49,51 @@ @Profile("!test") public class SwaggerConfiguration { - public static final String AUTHENTICATION_SUFFIX = " authentication"; - public static final String CLIENT_ID = "clientId"; - public static final String BEARER = "bearer"; - - @Value("${spring.security.oauth2.client.provider.idp-core.token-uri}") - private String oauth2url; - - @Value("${app.idp-core-prefix-url}") - private String idpCorePrefixUrl; - - @Bean - public OpenAPI openAPI() { - ModelConverters.getInstance().addConverter(new ModelResolver(Json.mapper())); - return new OpenAPI() - .info(new Info() - .title("Idp core API") - .description("API dedicated to idp core functionalities") - .version("v1")) - .addServersItem(new Server().url(idpCorePrefixUrl)) - .schemaRequirement(CLIENT_ID, - new SecurityScheme().description(CLIENT_ID + AUTHENTICATION_SUFFIX) - .name(CLIENT_ID) - .type(OAUTH2) - .flows(new OAuthFlows().clientCredentials( - new OAuthFlow().tokenUrl(oauth2url)))) - .addSecurityItem(new SecurityRequirement().addList(CLIENT_ID)) - .schemaRequirement(BEARER, - new SecurityScheme().description(BEARER + AUTHENTICATION_SUFFIX) - .name(BEARER) - .scheme(BEARER) - .bearerFormat("JWT") - .type(HTTP)) - .addSecurityItem(new SecurityRequirement().addList(BEARER)); - } - - @Bean - public GroupedOpenApi allApis() { - return GroupedOpenApi.builder().group("internal").pathsToMatch("/**").build(); - } - - @Schema(description = "Paginated response containing Template objects") - public static class TemplatePageResponse extends PageImpl { - public TemplatePageResponse(List content, Pageable pageable, long total) { - super(content, pageable, total); - } + public static final String AUTHENTICATION_SUFFIX = " authentication"; + public static final String CLIENT_ID = "clientId"; + public static final String BEARER = "bearer"; + + @Value("${spring.security.oauth2.client.provider.idp-core.token-uri}") + private String oauth2url; + + @Value("${app.idp-core-prefix-url}") + private String idpCorePrefixUrl; + + @Bean + public OpenAPI openAPI() { + ModelConverters.getInstance().addConverter(new ModelResolver(Json.mapper())); + return new OpenAPI() + .info(new Info().title("Idp core API") + .description("API dedicated to idp core functionalities").version("v1")) + .addServersItem(new Server().url(idpCorePrefixUrl)) + .schemaRequirement(CLIENT_ID, + new SecurityScheme().description(CLIENT_ID + AUTHENTICATION_SUFFIX).name(CLIENT_ID) + .type(OAUTH2) + .flows(new OAuthFlows().clientCredentials(new OAuthFlow().tokenUrl(oauth2url)))) + .addSecurityItem(new SecurityRequirement().addList(CLIENT_ID)) + .schemaRequirement(BEARER, + new SecurityScheme().description(BEARER + AUTHENTICATION_SUFFIX).name(BEARER) + .scheme(BEARER).bearerFormat("JWT").type(HTTP)) + .addSecurityItem(new SecurityRequirement().addList(BEARER)); + } + + @Bean + public GroupedOpenApi allApis() { + return GroupedOpenApi.builder().group("internal").pathsToMatch("/**").build(); + } + + @Schema(description = "Paginated response containing Template objects") + public static class TemplatePageResponse extends PageImpl { + public TemplatePageResponse(List content, Pageable pageable, long total) { + super(content, pageable, total); } + } - @Schema(description = "Paginated response containing Entity objects") - public static class EntityPageResponse extends PageImpl { - public EntityPageResponse(List content, Pageable pageable, long total) { - super(content, pageable, total); - } + @Schema(description = "Paginated response containing Entity objects") + public static class EntityPageResponse extends PageImpl { + public EntityPageResponse(List content, Pageable pageable, long total) { + super(content, pageable, total); } - + } } 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 feb6d600..e6976b8a 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 @@ -20,152 +20,150 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public class SwaggerDescription { - /// HTTP response status codes for OpenAPI documentation - public static final String OK_CODE = "200"; - public static final String CREATED_CODE = "201"; - public static final String NO_CONTENT_CODE = "204"; - public static final String PARTIAL_CONTENT_CODE = "206"; - public static final String BAD_REQUEST_CODE = "400"; - public static final String UNAUTHORIZED_CODE = "401"; - public static final String FORBIDDEN_CODE = "403"; - public static final String NOT_FOUND_CODE = "404"; - public static final String CONFLICT_CODE = "409"; - public static final String SERVICE_UNAVAILABLE_CODE = "503"; - public static final String INTERNAL_SERVER_ERROR_CODE = "500"; - - /// Entity Template API endpoint constants - public static final String ENDPOINT_GET_TEMPLATES_SUMMARY = "Get all templates"; - public static final String ENDPOINT_GET_TEMPLATES_DESCRIPTION = "Retrieve a list of all available templates in the system"; - - public static final String ENDPOINT_GET_TEMPLATES_PAGINATED_SUMMARY = "Get paginated templates"; - public static final String ENDPOINT_GET_TEMPLATES_PAGINATED_DESCRIPTION = "Retrieve a paginated list of templates with optional sorting"; - - public static final String ENDPOINT_GET_TEMPLATE_BY_ID_SUMMARY = "Get template by ID"; - public static final String ENDPOINT_GET_TEMPLATE_BY_ID_DESCRIPTION = "Retrieve a specific template using its unique identifier"; - - public static final String ENDPOINT_GET_TEMPLATE_BY_IDENTIFIER_SUMMARY = "Get template by identifier"; - public static final String ENDPOINT_GET_TEMPLATE_BY_IDENTIFIER_DESCRIPTION = "Retrieve a specific template using its string identifier"; - - public static final String ENDPOINT_POST_TEMPLATE_SUMMARY = "Create a new template"; - public static final String ENDPOINT_POST_TEMPLATE_DESCRIPTION = "Create a new template in the system with the provided information"; - public static final String ENDPOINT_PUT_TEMPLATE_SUMMARY = "Update an existing template by template identifier"; - public static final String ENDPOINT_PUT_TEMPLATE_DESCRIPTION = "Update the details of an existing template identified by its unique string identifier"; - - public static final String ENDPOINT_DELETE_TEMPLATE_SUMMARY = "Delete template by identifier"; - public static final String ENDPOINT_DELETE_TEMPLATE_DESCRIPTION = "Remove a template from the system using its unique identifier"; - - /// Entity API endpoint constants - public static final String ENDPOINT_GET_ENTITIES_SUMMARY = "Get entities by template identifier"; - public static final String ENDPOINT_GET_ENTITIES_DESCRIPTION = "Retrieve a list of all available entities in the system"; - - public static final String ENDPOINT_GET_ENTITIES_PAGINATED_SUMMARY = "Get paginated entities"; - public static final String ENDPOINT_GET_ENTITIES_PAGINATED_DESCRIPTION = "Retrieve a paginated list of entities with optional sorting"; - - public static final String ENDPOINT_GET_ENTITY_BY_IDENTIFIER_SUMMARY = "Get entity by entity template and identifier"; - public static final String ENDPOINT_GET_ENTITY_BY_IDENTIFIER_DESCRIPTION = "Retrieve a specific entity using its string identifier and its template identifier"; - - public static final String ENDPOINT_POST_ENTITY_SUMMARY = "Create a new entity"; - public static final String ENDPOINT_POST_ENTITY_DESCRIPTION = "Create a new entity in the system with the provided information"; - - - /// API response description constants - public static final String RESPONSE_TEMPLATES_PAGINATED_SUCCESS = "Paginated templates retrieved successfully"; - public static final String RESPONSE_TEMPLATES_PARTIAL_CONTENT = "Partial content - paginated templates retrieved (subset of total data)"; - public static final String RESPONSE_TEMPLATE_FOUND = "Template found"; - public static final String RESPONSE_TEMPLATE_CREATED = "Template created successfully"; - public static final String RESPONSE_TEMPLATE_UPDATED = "Template update successfully"; - public static final String RESPONSE_TEMPLATE_DELETED = "Template deleted successfully"; - public static final String RESPONSE_TEMPLATE_NOT_FOUND_ID = "Template not found with the provided ID"; - public static final String RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER = "Template not found with the provided identifier"; - public static final String RESPONSE_INVALID_UUID = "Invalid UUID format"; - public static final String RESPONSE_INVALID_TEMPLATE_DATA = "Invalid template data provided"; - public static final String RESPONSE_INVALID_PAGINATION = "Invalid pagination parameters"; - public static final String RESPONSE_TEMPLATE_CONFLICT = "Template with this identifier already exists"; - public static final String RESPONSE_ENTITY_CONFLICT = "Entity already exists in this template"; - public static final String RESPONSE_ENTITIES_PAGINATED_SUCCESS = "Paginated entities retrieved successfully"; - public static final String RESPONSE_ENTITY_FOUND = "Entity found"; - public static final String RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER = "Entity not found with the provided identifier"; - public static final String RESPONSE_ENTITY_CREATED = "Entity created successfully"; - public static final String RESPONSE_INVALID_ENTITY_DATA = "Invalid entity data provided"; - public static final String RESPONSE_UNEXPECTED_SERVER_ERROR = "Unexpected server-side failure"; - public static final String RESPONSE_INSUFFICIENT_RIGHTS = "Insufficient rights"; - public static final String RESPONSE_UNAUTHORIZED = "Unauthorized - Missing or invalid token"; - - - // --- Schema (class) descriptions --- - public static final String SCHEMA_ENTITY_TEMPLATE_CREATE_IN = "Input DTO for creating an entity template"; - public static final String SCHEMA_ENTITY_TEMPLATE_UPDATE_IN = "Input DTO for updating an entity template"; - public static final String SCHEMA_PROPERTY_DEFINITION_IN = "Input DTO for creating or updating a property definition"; - public static final String SCHEMA_RELATION_DEFINITION_IN = "Input DTO for creating or updating a relation definition"; - public static final String SCHEMA_PROPERTY_RULES_IN = "Input DTO for property validation rules"; - public static final String SCHEMA_ENTITY_TEMPLATE_OUT = "Output DTO for entity template"; - public static final String SCHEMA_PROPERTY_DEFINITION_OUT = "Output DTO for property definition"; - public static final String SCHEMA_RELATION_DEFINITION_OUT = "Output DTO for relation definition"; - public static final String SCHEMA_PROPERTY_RULES_OUT = "Output DTO for property validation rules"; - public static final String SCHEMA_ENTITY_IN = "Input DTO for creating or updating an entity"; - public static final String SCHEMA_ENTITY_RELATION_IN = "Input DTO for an entity relation instance"; - - // --- Field descriptions (shared) --- - public static final String FIELD_TEMPLATE_ID = "Unique generated identifier of the entity template"; - public static final String FIELD_TEMPLATE_IDENTIFIER = "Unique Entity Template identifier"; - public static final String FIELD_TEMPLATE_NAME = "Unique Entity Template name"; - public static final String FIELD_TEMPLATE_DESCRIPTION = "Entity Template description"; - public static final String FIELD_TEMPLATE_PROPERTIES = "List of property definitions for this template"; - public static final String FIELD_TEMPLATE_RELATIONS = "List of relation definitions for this template"; - - public static final String FIELD_ENTITY_NAME = "Name of the entity"; - public static final String FIELD_ENTITY_IDENTIFIER = "Unique identifier of the entity within the template scope"; - public static final String FIELD_ENTITY_PROPERTIES = "Map of property name to value for this entity"; - public static final String FIELD_ENTITY_RELATIONS = "List of relations for this entity"; - public static final String FIELD_ENTITY_RELATION_NAME = "Name of the relation (must match a template relation definition)"; - public static final String FIELD_ENTITY_RELATION_TARGETS = "List of target entity identifiers for this relation"; - - public static final String FIELD_PROPERTY_ID = "Unique identifier of the property definition"; - public static final String FIELD_PROPERTY_NAME = "Property name"; - public static final String FIELD_PROPERTY_DESCRIPTION = "Property description"; - public static final String FIELD_PROPERTY_TYPE = "Property data type"; - public static final String FIELD_PROPERTY_REQUIRED = "Whether this property is required"; - public static final String FIELD_PROPERTY_RULES = "Property validation rules"; - - public static final String FIELD_PROPERTY_RULES_ID = "Unique identifier of the property rules"; - public static final String FIELD_PROPERTY_RULES_FORMAT = "Format of the property"; - public static final String FIELD_PROPERTY_RULES_ENUM_VALUES = "Allowed enum values for the property"; - public static final String FIELD_PROPERTY_RULES_REGEX = "Regular expression for property validation"; - public static final String FIELD_PROPERTY_RULES_MAX_LENGTH = "Maximum length of the property"; - public static final String FIELD_PROPERTY_RULES_MIN_LENGTH = "Minimum length of the property"; - public static final String FIELD_PROPERTY_RULES_MAX_VALUE = "Maximum value for the property"; - public static final String FIELD_PROPERTY_RULES_MIN_VALUE = "Minimum value for the property"; - public static final String FIELD_CREATED_AT = "Creation timestamp"; - public static final String FIELD_UPDATED_AT = "Last update timestamp"; - - public static final String FIELD_RELATION_ID = "Unique identifier of the relation definition"; - public static final String FIELD_RELATION_NAME = "Name of the relation"; - public static final String FIELD_RELATION_TARGET_IDENTIFIER = "Identifier of the target template"; - public static final String FIELD_RELATION_REQUIRED = "Whether this relation is required"; - public static final String FIELD_RELATION_TO_MANY = "Whether this relation can have multiple targets"; - - // --- Pagination and sorting parameter descriptions --- - public static final String PARAM_PAGE_DESCRIPTION = "Page number for pagination. Defaults to 0."; - public static final String PARAM_SIZE_DESCRIPTION = "Number of items per page. Defaults to 20."; - public static final String PARAM_SORT_DESCRIPTION = "Sorting criteria in the format: property(,asc|desc). Defaults to identifier,asc."; - - // --- Entity Graph (flat nodes & edges) descriptions --- - public static final String PARAM_DEPTH_DESCRIPTION = "Maximum traversal depth for relationship resolution. Clamped between 1 and 10."; - public static final String ENDPOINT_GET_ENTITY_GRAPH_FLAT_SUMMARY = "Get entity relationship graph as flat nodes and edges"; - public static final String ENDPOINT_GET_ENTITY_GRAPH_FLAT_DESCRIPTION = "Retrieves the entity relationship graph as a flat nodes-and-edges structure, suitable for frontend visualization tools such as React Flow, Vis.js, and Cytoscape."; - public static final String RESPONSE_ENTITY_GRAPH_FLAT_SUCCESS = "Flat entity graph successfully retrieved"; - public static final String ENTITY_GRAPH_FLAT_NODES_DESCRIPTION = "All entity nodes in the graph"; - public static final String ENTITY_GRAPH_FLAT_EDGES_DESCRIPTION = "All directed relation edges in the graph"; - public static final String ENTITY_GRAPH_FLAT_NODE_ID_DESCRIPTION = "Unique node identifier composed of templateIdentifier:identifier"; - public static final String ENTITY_GRAPH_FLAT_NODE_LABEL_DESCRIPTION = "Human-readable entity name"; - public static final String ENTITY_GRAPH_FLAT_NODE_TEMPLATE_DESCRIPTION = "Template identifier this entity belongs to"; - public static final String ENTITY_GRAPH_FLAT_NODE_IDENTIFIER_DESCRIPTION = "Business identifier of the entity within its template"; - public static final String ENTITY_GRAPH_FLAT_EDGE_ID_DESCRIPTION = "Unique edge identifier"; - public static final String ENTITY_GRAPH_FLAT_EDGE_SOURCE_DESCRIPTION = "Node id of the source entity"; - public static final String ENTITY_GRAPH_FLAT_EDGE_TARGET_DESCRIPTION = "Node id of the target entity"; - public static final String ENTITY_GRAPH_FLAT_EDGE_TYPE_DESCRIPTION = "Relation name as defined in the entity template"; - public static final String ENTITY_GRAPH_FLAT_NODE_DATA_DESCRIPTION = "Entity property values keyed by property name; present only when include_data=true is requested"; - public static final String PARAM_INCLUDE_DATA_DESCRIPTION = "When true, each graph node includes a data object containing the entity's property values. Defaults to false."; - public static final String PARAM_RELATIONS_DESCRIPTION = "When provided, only relations whose name matches one of the listed values are traversed and included. Omit to include all relations."; - public static final String PARAM_PROPERTIES_DESCRIPTION = "When provided, each node's data object is restricted to the listed property names. Requires include_data=true to have any effect. Omit to include all properties."; + /// HTTP response status codes for OpenAPI documentation + public static final String OK_CODE = "200"; + public static final String CREATED_CODE = "201"; + public static final String NO_CONTENT_CODE = "204"; + public static final String PARTIAL_CONTENT_CODE = "206"; + public static final String BAD_REQUEST_CODE = "400"; + public static final String UNAUTHORIZED_CODE = "401"; + public static final String FORBIDDEN_CODE = "403"; + public static final String NOT_FOUND_CODE = "404"; + public static final String CONFLICT_CODE = "409"; + public static final String SERVICE_UNAVAILABLE_CODE = "503"; + public static final String INTERNAL_SERVER_ERROR_CODE = "500"; + + /// Entity Template API endpoint constants + public static final String ENDPOINT_GET_TEMPLATES_SUMMARY = "Get all templates"; + public static final String ENDPOINT_GET_TEMPLATES_DESCRIPTION = "Retrieve a list of all available templates in the system"; + + public static final String ENDPOINT_GET_TEMPLATES_PAGINATED_SUMMARY = "Get paginated templates"; + public static final String ENDPOINT_GET_TEMPLATES_PAGINATED_DESCRIPTION = "Retrieve a paginated list of templates with optional sorting"; + + public static final String ENDPOINT_GET_TEMPLATE_BY_ID_SUMMARY = "Get template by ID"; + public static final String ENDPOINT_GET_TEMPLATE_BY_ID_DESCRIPTION = "Retrieve a specific template using its unique identifier"; + + public static final String ENDPOINT_GET_TEMPLATE_BY_IDENTIFIER_SUMMARY = "Get template by identifier"; + public static final String ENDPOINT_GET_TEMPLATE_BY_IDENTIFIER_DESCRIPTION = "Retrieve a specific template using its string identifier"; + + public static final String ENDPOINT_POST_TEMPLATE_SUMMARY = "Create a new template"; + public static final String ENDPOINT_POST_TEMPLATE_DESCRIPTION = "Create a new template in the system with the provided information"; + public static final String ENDPOINT_PUT_TEMPLATE_SUMMARY = "Update an existing template by template identifier"; + public static final String ENDPOINT_PUT_TEMPLATE_DESCRIPTION = "Update the details of an existing template identified by its unique string identifier"; + + public static final String ENDPOINT_DELETE_TEMPLATE_SUMMARY = "Delete template by identifier"; + public static final String ENDPOINT_DELETE_TEMPLATE_DESCRIPTION = "Remove a template from the system using its unique identifier"; + + /// Entity API endpoint constants + public static final String ENDPOINT_GET_ENTITIES_SUMMARY = "Get entities by template identifier"; + public static final String ENDPOINT_GET_ENTITIES_DESCRIPTION = "Retrieve a list of all available entities in the system"; + + public static final String ENDPOINT_GET_ENTITIES_PAGINATED_SUMMARY = "Get paginated entities"; + public static final String ENDPOINT_GET_ENTITIES_PAGINATED_DESCRIPTION = "Retrieve a paginated list of entities with optional sorting"; + + public static final String ENDPOINT_GET_ENTITY_BY_IDENTIFIER_SUMMARY = "Get entity by entity template and identifier"; + public static final String ENDPOINT_GET_ENTITY_BY_IDENTIFIER_DESCRIPTION = "Retrieve a specific entity using its string identifier and its template identifier"; + + public static final String ENDPOINT_POST_ENTITY_SUMMARY = "Create a new entity"; + public static final String ENDPOINT_POST_ENTITY_DESCRIPTION = "Create a new entity in the system with the provided information"; + + /// API response description constants + public static final String RESPONSE_TEMPLATES_PAGINATED_SUCCESS = "Paginated templates retrieved successfully"; + public static final String RESPONSE_TEMPLATES_PARTIAL_CONTENT = "Partial content - paginated templates retrieved (subset of total data)"; + public static final String RESPONSE_TEMPLATE_FOUND = "Template found"; + public static final String RESPONSE_TEMPLATE_CREATED = "Template created successfully"; + public static final String RESPONSE_TEMPLATE_UPDATED = "Template update successfully"; + public static final String RESPONSE_TEMPLATE_DELETED = "Template deleted successfully"; + public static final String RESPONSE_TEMPLATE_NOT_FOUND_ID = "Template not found with the provided ID"; + public static final String RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER = "Template not found with the provided identifier"; + public static final String RESPONSE_INVALID_UUID = "Invalid UUID format"; + public static final String RESPONSE_INVALID_TEMPLATE_DATA = "Invalid template data provided"; + public static final String RESPONSE_INVALID_PAGINATION = "Invalid pagination parameters"; + public static final String RESPONSE_TEMPLATE_CONFLICT = "Template with this identifier already exists"; + public static final String RESPONSE_ENTITY_CONFLICT = "Entity already exists in this template"; + public static final String RESPONSE_ENTITIES_PAGINATED_SUCCESS = "Paginated entities retrieved successfully"; + public static final String RESPONSE_ENTITY_FOUND = "Entity found"; + public static final String RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER = "Entity not found with the provided identifier"; + public static final String RESPONSE_ENTITY_CREATED = "Entity created successfully"; + public static final String RESPONSE_INVALID_ENTITY_DATA = "Invalid entity data provided"; + public static final String RESPONSE_UNEXPECTED_SERVER_ERROR = "Unexpected server-side failure"; + public static final String RESPONSE_INSUFFICIENT_RIGHTS = "Insufficient rights"; + public static final String RESPONSE_UNAUTHORIZED = "Unauthorized - Missing or invalid token"; + + // --- Schema (class) descriptions --- + public static final String SCHEMA_ENTITY_TEMPLATE_CREATE_IN = "Input DTO for creating an entity template"; + public static final String SCHEMA_ENTITY_TEMPLATE_UPDATE_IN = "Input DTO for updating an entity template"; + public static final String SCHEMA_PROPERTY_DEFINITION_IN = "Input DTO for creating or updating a property definition"; + public static final String SCHEMA_RELATION_DEFINITION_IN = "Input DTO for creating or updating a relation definition"; + public static final String SCHEMA_PROPERTY_RULES_IN = "Input DTO for property validation rules"; + public static final String SCHEMA_ENTITY_TEMPLATE_OUT = "Output DTO for entity template"; + public static final String SCHEMA_PROPERTY_DEFINITION_OUT = "Output DTO for property definition"; + public static final String SCHEMA_RELATION_DEFINITION_OUT = "Output DTO for relation definition"; + public static final String SCHEMA_PROPERTY_RULES_OUT = "Output DTO for property validation rules"; + public static final String SCHEMA_ENTITY_IN = "Input DTO for creating or updating an entity"; + public static final String SCHEMA_ENTITY_RELATION_IN = "Input DTO for an entity relation instance"; + + // --- Field descriptions (shared) --- + public static final String FIELD_TEMPLATE_ID = "Unique generated identifier of the entity template"; + public static final String FIELD_TEMPLATE_IDENTIFIER = "Unique Entity Template identifier"; + public static final String FIELD_TEMPLATE_NAME = "Unique Entity Template name"; + public static final String FIELD_TEMPLATE_DESCRIPTION = "Entity Template description"; + public static final String FIELD_TEMPLATE_PROPERTIES = "List of property definitions for this template"; + public static final String FIELD_TEMPLATE_RELATIONS = "List of relation definitions for this template"; + + public static final String FIELD_ENTITY_NAME = "Name of the entity"; + public static final String FIELD_ENTITY_IDENTIFIER = "Unique identifier of the entity within the template scope"; + public static final String FIELD_ENTITY_PROPERTIES = "Map of property name to value for this entity"; + public static final String FIELD_ENTITY_RELATIONS = "List of relations for this entity"; + public static final String FIELD_ENTITY_RELATION_NAME = "Name of the relation (must match a template relation definition)"; + public static final String FIELD_ENTITY_RELATION_TARGETS = "List of target entity identifiers for this relation"; + + public static final String FIELD_PROPERTY_ID = "Unique identifier of the property definition"; + public static final String FIELD_PROPERTY_NAME = "Property name"; + public static final String FIELD_PROPERTY_DESCRIPTION = "Property description"; + public static final String FIELD_PROPERTY_TYPE = "Property data type"; + public static final String FIELD_PROPERTY_REQUIRED = "Whether this property is required"; + public static final String FIELD_PROPERTY_RULES = "Property validation rules"; + + public static final String FIELD_PROPERTY_RULES_ID = "Unique identifier of the property rules"; + public static final String FIELD_PROPERTY_RULES_FORMAT = "Format of the property"; + public static final String FIELD_PROPERTY_RULES_ENUM_VALUES = "Allowed enum values for the property"; + public static final String FIELD_PROPERTY_RULES_REGEX = "Regular expression for property validation"; + public static final String FIELD_PROPERTY_RULES_MAX_LENGTH = "Maximum length of the property"; + public static final String FIELD_PROPERTY_RULES_MIN_LENGTH = "Minimum length of the property"; + public static final String FIELD_PROPERTY_RULES_MAX_VALUE = "Maximum value for the property"; + public static final String FIELD_PROPERTY_RULES_MIN_VALUE = "Minimum value for the property"; + public static final String FIELD_CREATED_AT = "Creation timestamp"; + public static final String FIELD_UPDATED_AT = "Last update timestamp"; + + public static final String FIELD_RELATION_ID = "Unique identifier of the relation definition"; + public static final String FIELD_RELATION_NAME = "Name of the relation"; + public static final String FIELD_RELATION_TARGET_IDENTIFIER = "Identifier of the target template"; + public static final String FIELD_RELATION_REQUIRED = "Whether this relation is required"; + public static final String FIELD_RELATION_TO_MANY = "Whether this relation can have multiple targets"; + + // --- Pagination and sorting parameter descriptions --- + public static final String PARAM_PAGE_DESCRIPTION = "Page number for pagination. Defaults to 0."; + public static final String PARAM_SIZE_DESCRIPTION = "Number of items per page. Defaults to 20."; + public static final String PARAM_SORT_DESCRIPTION = "Sorting criteria in the format: property(,asc|desc). Defaults to identifier,asc."; + + // --- Entity Graph (flat nodes & edges) descriptions --- + public static final String PARAM_DEPTH_DESCRIPTION = "Maximum traversal depth for relationship resolution. Clamped between 1 and 10."; + public static final String ENDPOINT_GET_ENTITY_GRAPH_FLAT_SUMMARY = "Get entity relationship graph as flat nodes and edges"; + public static final String ENDPOINT_GET_ENTITY_GRAPH_FLAT_DESCRIPTION = "Retrieves the entity relationship graph as a flat nodes-and-edges structure, suitable for frontend visualization tools such as React Flow, Vis.js, and Cytoscape."; + public static final String RESPONSE_ENTITY_GRAPH_FLAT_SUCCESS = "Flat entity graph successfully retrieved"; + public static final String ENTITY_GRAPH_FLAT_NODES_DESCRIPTION = "All entity nodes in the graph"; + public static final String ENTITY_GRAPH_FLAT_EDGES_DESCRIPTION = "All directed relation edges in the graph"; + public static final String ENTITY_GRAPH_FLAT_NODE_ID_DESCRIPTION = "Unique node identifier composed of templateIdentifier:identifier"; + public static final String ENTITY_GRAPH_FLAT_NODE_LABEL_DESCRIPTION = "Human-readable entity name"; + public static final String ENTITY_GRAPH_FLAT_NODE_TEMPLATE_DESCRIPTION = "Template identifier this entity belongs to"; + public static final String ENTITY_GRAPH_FLAT_NODE_IDENTIFIER_DESCRIPTION = "Business identifier of the entity within its template"; + public static final String ENTITY_GRAPH_FLAT_EDGE_ID_DESCRIPTION = "Unique edge identifier"; + public static final String ENTITY_GRAPH_FLAT_EDGE_SOURCE_DESCRIPTION = "Node id of the source entity"; + public static final String ENTITY_GRAPH_FLAT_EDGE_TARGET_DESCRIPTION = "Node id of the target entity"; + public static final String ENTITY_GRAPH_FLAT_EDGE_TYPE_DESCRIPTION = "Relation name as defined in the entity template"; + public static final String ENTITY_GRAPH_FLAT_NODE_DATA_DESCRIPTION = "Entity property values keyed by property name; present only when include_data=true is requested"; + public static final String PARAM_INCLUDE_DATA_DESCRIPTION = "When true, each graph node includes a data object containing the entity's property values. Defaults to false."; + public static final String PARAM_RELATIONS_DESCRIPTION = "When provided, only relations whose name matches one of the listed values are traversed and included. Omit to include all relations."; + public static final String PARAM_PROPERTIES_DESCRIPTION = "When provided, each node's data object is restricted to the listed property names. Requires include_data=true to have any effect. Omit to include all properties."; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/WebConfiguration.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/WebConfiguration.java index 41a5e37a..cab1b62f 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/WebConfiguration.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/WebConfiguration.java @@ -16,9 +16,9 @@ @Configuration public class WebConfiguration implements WebMvcConfigurer { - @Override - public void addViewControllers(ViewControllerRegistry registry) { - registry.addRedirectViewController("/", "swagger-ui/index.html"); - } + @Override + public void addViewControllers(ViewControllerRegistry registry) { + registry.addRedirectViewController("/", "swagger-ui/index.html"); + } } 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 f9f8d90f..e8fb346f 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 @@ -31,7 +31,9 @@ import static org.springframework.http.HttpStatus.CREATED; import static org.springframework.http.HttpStatus.OK; -import lombok.RequiredArgsConstructor; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; + import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -53,6 +55,7 @@ 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 io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.enums.ParameterIn; @@ -60,8 +63,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; /// REST API adapter providing entity management endpoints. /// @@ -77,85 +79,96 @@ @RequiredArgsConstructor public class EntityController { - private final EntityService entityService; - private final EntityDtoOutMapper entityDtoOutMapper; - private final EntityDtoInMapper entityDtoInMapper; + private final EntityService entityService; + private final EntityDtoOutMapper entityDtoOutMapper; + private final EntityDtoInMapper entityDtoInMapper; - /// Returns paginated entities filtered by template with HTTP pagination support. - /// - /// **API contract:** Provides paginated entity listings for template-specific views. - /// Supports standard REST pagination parameters and returns appropriate HTTP status codes. - /// Template validation is handled by the domain service layer. - /// - /// @param page zero-based page index for pagination navigation - /// @param size number of entities per page for response size control - /// @param templateIdentifier template filter for entity scope limitation - /// @return paginated entity DTOs optimized for API consumers - @Operation(summary = ENDPOINT_GET_ENTITIES_SUMMARY, description = ENDPOINT_GET_ENTITIES_PAGINATED_DESCRIPTION) - @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITIES_PAGINATED_SUCCESS, content = @Content(schema = @Schema(implementation = EntityPageResponse.class))) - @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_PAGINATION, content = { - @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) - @Parameter(name = "page", description = PARAM_PAGE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "0"))) - @Parameter(name = "size", description = PARAM_SIZE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "20"))) - @Parameter(name = "sort", description = PARAM_SORT_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string", defaultValue = "identifier,asc"))) - @ResponseStatus(OK) - @GetMapping("/{templateIdentifier}") - public Page getEntities( - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size, - @PathVariable String templateIdentifier) { - Pageable pageable = PageRequest.of(page, size); - Page entities = entityService.getEntitiesByTemplateIdentifier(pageable, templateIdentifier); - return entityDtoOutMapper.fromEntitiesPageToDtoPage(entities, templateIdentifier); - } + /// Returns paginated entities filtered by template with HTTP pagination + /// support. + /// + /// **API contract:** Provides paginated entity listings for template-specific + /// views. + /// Supports standard REST pagination parameters and returns appropriate HTTP + /// status codes. + /// Template validation is handled by the domain service layer. + /// + /// @param page zero-based page index for pagination navigation + /// @param size number of entities per page for response size control + /// @param templateIdentifier template filter for entity scope limitation + /// @return paginated entity DTOs optimized for API consumers + @Operation(summary = ENDPOINT_GET_ENTITIES_SUMMARY, description = ENDPOINT_GET_ENTITIES_PAGINATED_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITIES_PAGINATED_SUCCESS, content = @Content(schema = @Schema(implementation = EntityPageResponse.class))) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_PAGINATION, content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @Parameter(name = "page", description = PARAM_PAGE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "0"))) + @Parameter(name = "size", description = PARAM_SIZE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "20"))) + @Parameter(name = "sort", description = PARAM_SORT_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string", defaultValue = "identifier,asc"))) + @ResponseStatus(OK) + @GetMapping("/{templateIdentifier}") + public Page getEntities(@RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, @PathVariable String templateIdentifier) { + Pageable pageable = PageRequest.of(page, size); + Page entities = entityService.getEntitiesByTemplateIdentifier(pageable, + templateIdentifier); + return entityDtoOutMapper.fromEntitiesPageToDtoPage(entities, templateIdentifier); + } - /// Retrieves a single entity by template and entity identifiers. - /// - /// **API contract:** Provides specific entity lookup using compound identifier pattern. - /// Returns HTTP 404 if either template or entity doesn't exist, maintaining REST semantics. - /// - /// @param templateIdentifier business template identifier for entity scope - /// @param entityIdentifier unique business identifier within template context - /// @return entity DTO with full property and relationship data - @Operation(summary = ENDPOINT_GET_ENTITY_BY_IDENTIFIER_SUMMARY, description = ENDPOINT_GET_ENTITY_BY_IDENTIFIER_DESCRIPTION) - @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_FOUND, content = { - @Content(schema = @Schema(implementation = EntityDtoOut.class))}) - @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = { - @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) - @GetMapping("/{templateIdentifier}/identifier/{entityIdentifier}") - @ResponseStatus(OK) - public EntityDtoOut getEntity( - @PathVariable String templateIdentifier, - @PathVariable String entityIdentifier) { - Entity entity = entityService.getEntityByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier); - return entityDtoOutMapper.fromEntity(entity); - } + /// Retrieves a single entity by template and entity identifiers. + /// + /// **API contract:** Provides specific entity lookup using compound identifier + /// pattern. + /// Returns HTTP 404 if either template or entity doesn't exist, maintaining + /// REST semantics. + /// + /// @param templateIdentifier business template identifier for entity scope + /// @param entityIdentifier unique business identifier within template context + /// @return entity DTO with full property and relationship data + @Operation(summary = ENDPOINT_GET_ENTITY_BY_IDENTIFIER_SUMMARY, description = ENDPOINT_GET_ENTITY_BY_IDENTIFIER_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_FOUND, content = { + @Content(schema = @Schema(implementation = EntityDtoOut.class))}) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @GetMapping("/{templateIdentifier}/identifier/{entityIdentifier}") + @ResponseStatus(OK) + public EntityDtoOut getEntity(@PathVariable String templateIdentifier, + @PathVariable String entityIdentifier) { + Entity entity = entityService.getEntityByTemplateIdentifierAndIdentifier(templateIdentifier, + entityIdentifier); + return entityDtoOutMapper.fromEntity(entity); + } - /// Creates a new entity for the specified template with validation. - /// - /// **API contract:** Accepts entity creation payload and returns created entity with - /// generated identifiers. Validates entity structure against template constraints - /// and returns HTTP 201 on success, HTTP 400 for validation errors. - /// - /// @param templateIdentifier target template identifier for entity creation context - /// @param entityDtoIn entity creation payload with properties and relationships - /// @return created entity DTO with server-generated identifiers - @Operation(summary = ENDPOINT_POST_ENTITY_SUMMARY, description = ENDPOINT_POST_ENTITY_DESCRIPTION) - @ApiResponse(responseCode = CREATED_CODE, description = RESPONSE_ENTITY_CREATED, content = {@Content(schema = @Schema(implementation = EntityDtoOut.class))}) - @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_ENTITY_DATA, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) - @ApiResponse(responseCode = UNAUTHORIZED_CODE, description = RESPONSE_UNAUTHORIZED, content = @Content) - @ApiResponse(responseCode = FORBIDDEN_CODE, description = RESPONSE_INSUFFICIENT_RIGHTS, content = @Content) - @ApiResponse(responseCode = CONFLICT_CODE, description = RESPONSE_ENTITY_CONFLICT, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) - @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) - @ApiResponse(responseCode = INTERNAL_SERVER_ERROR_CODE, description = RESPONSE_UNEXPECTED_SERVER_ERROR, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) - @PostMapping("/{templateIdentifier}") - @ResponseStatus(CREATED) - public EntityDtoOut createEntity( - @NotBlank @PathVariable String templateIdentifier, - @Valid @RequestBody EntityDtoIn entityDtoIn) { + /// Creates a new entity for the specified template with validation. + /// + /// **API contract:** Accepts entity creation payload and returns created entity + /// with + /// generated identifiers. Validates entity structure against template + /// constraints + /// and returns HTTP 201 on success, HTTP 400 for validation errors. + /// + /// @param templateIdentifier target template identifier for entity creation + /// context + /// @param entityDtoIn entity creation payload with properties and relationships + /// @return created entity DTO with server-generated identifiers + @Operation(summary = ENDPOINT_POST_ENTITY_SUMMARY, description = ENDPOINT_POST_ENTITY_DESCRIPTION) + @ApiResponse(responseCode = CREATED_CODE, description = RESPONSE_ENTITY_CREATED, content = { + @Content(schema = @Schema(implementation = EntityDtoOut.class))}) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_ENTITY_DATA, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = UNAUTHORIZED_CODE, description = RESPONSE_UNAUTHORIZED, content = @Content) + @ApiResponse(responseCode = FORBIDDEN_CODE, description = RESPONSE_INSUFFICIENT_RIGHTS, content = @Content) + @ApiResponse(responseCode = CONFLICT_CODE, description = RESPONSE_ENTITY_CONFLICT, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = INTERNAL_SERVER_ERROR_CODE, description = RESPONSE_UNEXPECTED_SERVER_ERROR, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @PostMapping("/{templateIdentifier}") + @ResponseStatus(CREATED) + public EntityDtoOut createEntity(@NotBlank @PathVariable String templateIdentifier, + @Valid @RequestBody EntityDtoIn entityDtoIn) { - Entity entity = entityDtoInMapper.fromEntityDtoInToEntity(entityDtoIn, templateIdentifier); - Entity savedEntity = entityService.createEntity(entity); - return entityDtoOutMapper.fromEntity(savedEntity); - } + Entity entity = entityDtoInMapper.fromEntityDtoInToEntity(entityDtoIn, templateIdentifier); + Entity savedEntity = entityService.createEntity(entity); + return entityDtoOutMapper.fromEntity(savedEntity); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java index 7ea3de4a..a90639b9 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java @@ -15,6 +15,8 @@ import java.util.List; import java.util.Set; +import jakarta.validation.constraints.NotBlank; + import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; @@ -34,7 +36,6 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.constraints.NotBlank; import lombok.RequiredArgsConstructor; /// REST controller for entity relationship graph operations. @@ -48,52 +49,44 @@ @Tag(name = "Entity Graph", description = "Entity relationship graph operations") public class EntityGraphController { - private final EntityGraphService entityGraphService; + private final EntityGraphService entityGraphService; - /// Retrieves the entity relationship graph as a flat nodes-and-edges structure. - /// - /// Returns all entities as nodes and all directed relations as edges. Nodes are - /// deduplicated; edges encode directionality. Suitable for React Flow, Vis.js, - /// Cytoscape, and similar frontend graph visualization libraries. - /// - /// @param templateIdentifier the template identifier of the root entity - /// @param entityIdentifier the business identifier of the root entity - /// @param depth the maximum traversal depth (default 1, clamped between 1 and 10) - /// @param includeData when true, each node includes a data object with entity property values - /// @param relations when provided, only relations with matching names are included - /// @param properties when provided, each node's data object is restricted to the listed property names - /// @return flat DTO containing nodes and edges arrays - @GetMapping("/{templateIdentifier}/{entityIdentifier}/graph") - @ResponseStatus(OK) - @Operation( - summary = ENDPOINT_GET_ENTITY_GRAPH_FLAT_SUMMARY, - description = ENDPOINT_GET_ENTITY_GRAPH_FLAT_DESCRIPTION, - responses = { - @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_GRAPH_FLAT_SUCCESS, - content = @Content(schema = @Schema(implementation = EntityGraphFlatDtoOut.class))), - @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, - content = @Content(schema = @Schema(implementation = ErrorResponse.class))) - } - ) - public EntityGraphFlatDtoOut getEntityGraph( - @PathVariable @NotBlank String templateIdentifier, - @PathVariable @NotBlank String entityIdentifier, - @Parameter(description = PARAM_DEPTH_DESCRIPTION) - @RequestParam(defaultValue = "1") int depth, - @Parameter(description = PARAM_INCLUDE_DATA_DESCRIPTION) - @RequestParam(name = "include_data", defaultValue = "false") boolean includeData, - @Parameter(description = PARAM_RELATIONS_DESCRIPTION) - @RequestParam(required = false) List relations, - @Parameter(description = PARAM_PROPERTIES_DESCRIPTION) - @RequestParam(required = false) List properties) { + /// Retrieves the entity relationship graph as a flat nodes-and-edges structure. + /// + /// Returns all entities as nodes and all directed relations as edges. Nodes are + /// deduplicated; edges encode directionality. Suitable for React Flow, Vis.js, + /// Cytoscape, and similar frontend graph visualization libraries. + /// + /// @param templateIdentifier the template identifier of the root entity + /// @param entityIdentifier the business identifier of the root entity + /// @param depth the maximum traversal depth (default 1, clamped between 1 and + /// 10) + /// @param includeData when true, each node includes a data object with entity + /// property values + /// @param relations when provided, only relations with matching names are + /// included + /// @param properties when provided, each node's data object is restricted to + /// the listed property names + /// @return flat DTO containing nodes and edges arrays + @GetMapping("/{templateIdentifier}/{entityIdentifier}/graph") + @ResponseStatus(OK) + @Operation(summary = ENDPOINT_GET_ENTITY_GRAPH_FLAT_SUMMARY, description = ENDPOINT_GET_ENTITY_GRAPH_FLAT_DESCRIPTION, responses = { + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_GRAPH_FLAT_SUCCESS, content = @Content(schema = @Schema(implementation = EntityGraphFlatDtoOut.class))), + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = @Content(schema = @Schema(implementation = ErrorResponse.class)))}) + public EntityGraphFlatDtoOut getEntityGraph(@PathVariable @NotBlank String templateIdentifier, + @PathVariable @NotBlank String entityIdentifier, + @Parameter(description = PARAM_DEPTH_DESCRIPTION) @RequestParam(defaultValue = "1") int depth, + @Parameter(description = PARAM_INCLUDE_DATA_DESCRIPTION) @RequestParam(name = "include_data", defaultValue = "false") boolean includeData, + @Parameter(description = PARAM_RELATIONS_DESCRIPTION) @RequestParam(required = false) List relations, + @Parameter(description = PARAM_PROPERTIES_DESCRIPTION) @RequestParam(required = false) List properties) { - // Convert the nullable lists to Sets for O(1) lookup; empty set means no filter - Set relationFilter = relations != null ? Set.copyOf(relations) : Set.of(); - Set propertyFilter = properties != null ? Set.copyOf(properties) : Set.of(); + // Convert the nullable lists to Sets for O(1) lookup; empty set means no filter + Set relationFilter = relations != null ? Set.copyOf(relations) : Set.of(); + Set propertyFilter = properties != null ? Set.copyOf(properties) : Set.of(); - EntityGraphNode graphNode = entityGraphService.getEntityGraph( - templateIdentifier, entityIdentifier, depth, includeData); + EntityGraphNode graphNode = entityGraphService.getEntityGraph(templateIdentifier, + entityIdentifier, depth, includeData); - return EntityGraphFlatDtoOutMapper.toFlatDto(graphNode, relationFilter, propertyFilter); - } + return EntityGraphFlatDtoOutMapper.toFlatDto(graphNode, relationFilter, propertyFilter); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateController.java index fa0f947d..b82c6a3d 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateController.java @@ -30,6 +30,8 @@ import static org.springframework.http.HttpStatus.NO_CONTENT; import static org.springframework.http.HttpStatus.OK; +import jakarta.validation.Valid; + import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; @@ -60,7 +62,6 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; /// REST API adapter for Entity Template management operations. @@ -86,93 +87,99 @@ @Tag(name = "Entities Templates Management", description = "Operations related to entity template management") public class EntityTemplateController { - private final EntityTemplateService entityTemplateService; - private final EntityTemplateMapper templateMapper; + private final EntityTemplateService entityTemplateService; + private final EntityTemplateMapper templateMapper; - /// Retrieves paginated entity templates for administrative interfaces. - /// - /// **API contract:** Provides paginated template listings with configurable sorting - /// and page size. Defaults to 20 templates per page sorted by identifier for - /// consistent management interface display. - @Operation(summary = ENDPOINT_GET_TEMPLATES_PAGINATED_SUMMARY, description = ENDPOINT_GET_TEMPLATES_PAGINATED_DESCRIPTION) - @ApiResponse(responseCode = OK_CODE, description = RESPONSE_TEMPLATES_PAGINATED_SUCCESS, content = @Content(schema = @Schema(implementation = TemplatePageResponse.class))) - @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_PAGINATION, content = { - @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class)) }) - @Parameter(name = "page", description = PARAM_PAGE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "0"))) - @Parameter(name = "size", description = PARAM_SIZE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "20"))) - @Parameter(name = "sort", description = PARAM_SORT_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string", defaultValue = "identifier,asc"))) - @GetMapping - @ResponseStatus(OK) - public Page getTemplatesPaginated( - @PageableDefault(size = 20, sort = "identifier") @Parameter(hidden = true) Pageable pageable) { - Page templates = entityTemplateService.getEntityTemplates(pageable); - return templates.map(templateMapper::fromEntityTemplatetoDto); - } + /// Retrieves paginated entity templates for administrative interfaces. + /// + /// **API contract:** Provides paginated template listings with configurable + /// sorting + /// and page size. Defaults to 20 templates per page sorted by identifier for + /// consistent management interface display. + @Operation(summary = ENDPOINT_GET_TEMPLATES_PAGINATED_SUMMARY, description = ENDPOINT_GET_TEMPLATES_PAGINATED_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_TEMPLATES_PAGINATED_SUCCESS, content = @Content(schema = @Schema(implementation = TemplatePageResponse.class))) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_PAGINATION, content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @Parameter(name = "page", description = PARAM_PAGE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "0"))) + @Parameter(name = "size", description = PARAM_SIZE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "20"))) + @Parameter(name = "sort", description = PARAM_SORT_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string", defaultValue = "identifier,asc"))) + @GetMapping + @ResponseStatus(OK) + public Page getTemplatesPaginated( + @PageableDefault(size = 20, sort = "identifier") @Parameter(hidden = true) Pageable pageable) { + Page templates = entityTemplateService.getEntityTemplates(pageable); + return templates.map(templateMapper::fromEntityTemplatetoDto); + } - /// Retrieves specific entity template by business identifier. - /// - /// **API contract:** Returns complete template definition using case-sensitive - /// business identifier lookup. Provides HTTP 404 for non-existent templates - /// with meaningful error messages for API consumers. - @Operation(summary = ENDPOINT_GET_TEMPLATE_BY_IDENTIFIER_SUMMARY, description = ENDPOINT_GET_TEMPLATE_BY_IDENTIFIER_DESCRIPTION) - @ApiResponse(responseCode = OK_CODE, description = RESPONSE_TEMPLATE_FOUND, content = { - @Content(schema = @Schema(implementation = EntityTemplateDtoOut.class)) }) - @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER, content = { - @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class)) }) - @GetMapping("/{identifier}") - @ResponseStatus(OK) - public EntityTemplateDtoOut getTemplateByIdentifier(@PathVariable String identifier) { - EntityTemplate entity = entityTemplateService.getEntityTemplateByIdentifier(identifier); - return templateMapper.fromEntityTemplatetoDto(entity); - } + /// Retrieves specific entity template by business identifier. + /// + /// **API contract:** Returns complete template definition using case-sensitive + /// business identifier lookup. Provides HTTP 404 for non-existent templates + /// with meaningful error messages for API consumers. + @Operation(summary = ENDPOINT_GET_TEMPLATE_BY_IDENTIFIER_SUMMARY, description = ENDPOINT_GET_TEMPLATE_BY_IDENTIFIER_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_TEMPLATE_FOUND, content = { + @Content(schema = @Schema(implementation = EntityTemplateDtoOut.class))}) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER, content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @GetMapping("/{identifier}") + @ResponseStatus(OK) + public EntityTemplateDtoOut getTemplateByIdentifier(@PathVariable String identifier) { + EntityTemplate entity = entityTemplateService.getEntityTemplateByIdentifier(identifier); + return templateMapper.fromEntityTemplatetoDto(entity); + } - /// Creates new entity template with validation and uniqueness checks. - /// - /// **API contract:** Accepts template creation payload with comprehensive validation. - /// Returns HTTP 201 with created template including generated identifiers, or - /// HTTP 409 for duplicate identifier conflicts. - @Operation(summary = ENDPOINT_POST_TEMPLATE_SUMMARY, description = ENDPOINT_POST_TEMPLATE_DESCRIPTION) - @ApiResponse(responseCode = CREATED_CODE, description = RESPONSE_TEMPLATE_CREATED, content = { - @Content(schema = @Schema(implementation = EntityTemplateDtoOut.class)) }) - @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_TEMPLATE_DATA, content = { - @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class)) }) - @PostMapping - @ResponseStatus(CREATED) - public EntityTemplateDtoOut createTemplate(@Valid @RequestBody EntityTemplateCreateDtoIn entityTemplateCreateDtoIn) { - EntityTemplate entityTemplate = entityTemplateService.createEntityTemplate(templateMapper.fromDtoToEntityTemplate(entityTemplateCreateDtoIn)); - return templateMapper.fromEntityTemplatetoDto(entityTemplate); - } + /// Creates new entity template with validation and uniqueness checks. + /// + /// **API contract:** Accepts template creation payload with comprehensive + /// validation. + /// Returns HTTP 201 with created template including generated identifiers, or + /// HTTP 409 for duplicate identifier conflicts. + @Operation(summary = ENDPOINT_POST_TEMPLATE_SUMMARY, description = ENDPOINT_POST_TEMPLATE_DESCRIPTION) + @ApiResponse(responseCode = CREATED_CODE, description = RESPONSE_TEMPLATE_CREATED, content = { + @Content(schema = @Schema(implementation = EntityTemplateDtoOut.class))}) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_TEMPLATE_DATA, content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @PostMapping + @ResponseStatus(CREATED) + public EntityTemplateDtoOut createTemplate( + @Valid @RequestBody EntityTemplateCreateDtoIn entityTemplateCreateDtoIn) { + EntityTemplate entityTemplate = entityTemplateService + .createEntityTemplate(templateMapper.fromDtoToEntityTemplate(entityTemplateCreateDtoIn)); + return templateMapper.fromEntityTemplatetoDto(entityTemplate); + } - /// Updates existing entity template with complete replacement strategy. - /// - /// **API contract:** Replaces entire template definition while preserving identifier. - /// Returns updated template with HTTP 200, or HTTP 404 for non-existent templates. - @Operation(summary = ENDPOINT_PUT_TEMPLATE_SUMMARY, description = ENDPOINT_PUT_TEMPLATE_DESCRIPTION) - @ApiResponse(responseCode = OK_CODE, description = RESPONSE_TEMPLATE_UPDATED, content = { - @Content(schema = @Schema(implementation = EntityTemplateDtoOut.class)) }) - @ApiResponse(responseCode = "404", description = RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER, content = { - @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class)) }) - @PutMapping("/{identifier}") - public EntityTemplateDtoOut updateTemplate( - @PathVariable(name = "identifier") String identifier, - @RequestBody @Valid EntityTemplateUpdateDtoIn entityTemplateUpdateDtoIn) { - EntityTemplate entityTemplate = entityTemplateService.updateEntityTemplate(identifier, templateMapper.fromPutDtoToEntityTemplate(identifier, entityTemplateUpdateDtoIn)); - return templateMapper.fromEntityTemplatetoDto(entityTemplate); - } + /// Updates existing entity template with complete replacement strategy. + /// + /// **API contract:** Replaces entire template definition while preserving + /// identifier. + /// Returns updated template with HTTP 200, or HTTP 404 for non-existent + /// templates. + @Operation(summary = ENDPOINT_PUT_TEMPLATE_SUMMARY, description = ENDPOINT_PUT_TEMPLATE_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_TEMPLATE_UPDATED, content = { + @Content(schema = @Schema(implementation = EntityTemplateDtoOut.class))}) + @ApiResponse(responseCode = "404", description = RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER, content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @PutMapping("/{identifier}") + public EntityTemplateDtoOut updateTemplate(@PathVariable(name = "identifier") String identifier, + @RequestBody @Valid EntityTemplateUpdateDtoIn entityTemplateUpdateDtoIn) { + EntityTemplate entityTemplate = entityTemplateService.updateEntityTemplate(identifier, + templateMapper.fromPutDtoToEntityTemplate(identifier, entityTemplateUpdateDtoIn)); + return templateMapper.fromEntityTemplatetoDto(entityTemplate); + } - /// Deletes entity template by business identifier with safety checks. - /// - /// **API contract:** Permanently removes template with HTTP 204 response. - /// Operation is idempotent - returns success even for non-existent templates. - /// **Warning:** Irreversible operation requiring referential integrity validation. - @Operation(summary = ENDPOINT_DELETE_TEMPLATE_SUMMARY, description = ENDPOINT_DELETE_TEMPLATE_DESCRIPTION) - @ApiResponse(responseCode = NO_CONTENT_CODE, description = RESPONSE_TEMPLATE_DELETED) - @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class)) - }) - @ResponseStatus(NO_CONTENT) - @DeleteMapping("/{identifier}") - public void deleteTemplate(@PathVariable String identifier) { - entityTemplateService.deleteEntityTemplate(identifier); - } + /// Deletes entity template by business identifier with safety checks. + /// + /// **API contract:** Permanently removes template with HTTP 204 response. + /// Operation is idempotent - returns success even for non-existent templates. + /// **Warning:** Irreversible operation requiring referential integrity + /// validation. + @Operation(summary = ENDPOINT_DELETE_TEMPLATE_SUMMARY, description = ENDPOINT_DELETE_TEMPLATE_DESCRIPTION) + @ApiResponse(responseCode = NO_CONTENT_CODE, description = RESPONSE_TEMPLATE_DELETED) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ResponseStatus(NO_CONTENT) + @DeleteMapping("/{identifier}") + public void deleteTemplate(@PathVariable String identifier) { + entityTemplateService.deleteEntityTemplate(identifier); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoIn.java index 75877117..81de10a6 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoIn.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoIn.java @@ -1,5 +1,9 @@ package com.decathlon.idp_core.infrastructure.adapters.api.dto.in; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_IDENTIFIER_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_NAME_MANDATORY; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_NAME_MANDATORY_SIMPLE; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TARGET_IDENTIFIERS_NOT_NULL; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_ENTITY_IDENTIFIER; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_ENTITY_NAME; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_ENTITY_PROPERTIES; @@ -8,21 +12,18 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_ENTITY_RELATION_TARGETS; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.SCHEMA_ENTITY_IN; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.SCHEMA_ENTITY_RELATION_IN; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_NAME_MANDATORY; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_IDENTIFIER_MANDATORY; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_NAME_MANDATORY_SIMPLE; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TARGET_IDENTIFIERS_NOT_NULL; import java.util.List; import java.util.Map; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -41,39 +42,39 @@ @Schema(description = SCHEMA_ENTITY_IN) public class EntityDtoIn { - @NotBlank(message = ENTITY_NAME_MANDATORY) - @Schema(description = FIELD_ENTITY_NAME, example = "my-web-service") - private String name; + @NotBlank(message = ENTITY_NAME_MANDATORY) + @Schema(description = FIELD_ENTITY_NAME, example = "my-web-service") + private String name; - @NotBlank(message = ENTITY_IDENTIFIER_MANDATORY) - @Schema(description = FIELD_ENTITY_IDENTIFIER, example = "my-web-service") - private String identifier; + @NotBlank(message = ENTITY_IDENTIFIER_MANDATORY) + @Schema(description = FIELD_ENTITY_IDENTIFIER, example = "my-web-service") + private String identifier; - @Schema(description = FIELD_ENTITY_PROPERTIES, example = "{\"port\": \"8080\", \"environment\": \"dev\"}") - private Map properties; + @Schema(description = FIELD_ENTITY_PROPERTIES, example = "{\"port\": \"8080\", \"environment\": \"dev\"}") + private Map properties; - @Valid - @Schema(description = FIELD_ENTITY_RELATIONS) - private List relations; + @Valid + @Schema(description = FIELD_ENTITY_RELATIONS) + private List relations; - /// Input DTO for an entity relation instance. - /// - /// **Infrastructure validation:** Validates relation name presence and target - /// identifiers at the API boundary before domain-level schema checks. - @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - @JsonNaming(SnakeCaseStrategy.class) - @Schema(description = SCHEMA_ENTITY_RELATION_IN) - public static class RelationDtoIn { + /// Input DTO for an entity relation instance. + /// + /// **Infrastructure validation:** Validates relation name presence and target + /// identifiers at the API boundary before domain-level schema checks. + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonNaming(SnakeCaseStrategy.class) + @Schema(description = SCHEMA_ENTITY_RELATION_IN) + public static class RelationDtoIn { - @NotBlank(message = RELATION_NAME_MANDATORY_SIMPLE) - @Schema(description = FIELD_ENTITY_RELATION_NAME, example = "depends-on") - private String name; + @NotBlank(message = RELATION_NAME_MANDATORY_SIMPLE) + @Schema(description = FIELD_ENTITY_RELATION_NAME, example = "depends-on") + private String name; - @NotNull(message = RELATION_TARGET_IDENTIFIERS_NOT_NULL) - @Schema(description = FIELD_ENTITY_RELATION_TARGETS, example = "[\"web-api-1\", \"web-api-2\"]") - private List targetEntityIdentifiers; - } + @NotNull(message = RELATION_TARGET_IDENTIFIERS_NOT_NULL) + @Schema(description = FIELD_ENTITY_RELATION_TARGETS, example = "[\"web-api-1\", \"web-api-2\"]") + private List targetEntityIdentifiers; + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityTemplateCreateDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityTemplateCreateDtoIn.java index d6456d42..c202f0e0 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityTemplateCreateDtoIn.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityTemplateCreateDtoIn.java @@ -4,13 +4,14 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_TEMPLATE_IDENTIFIER; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.SCHEMA_ENTITY_TEMPLATE_CREATE_IN; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; + import com.fasterxml.jackson.annotation.JsonUnwrapped; import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -19,10 +20,11 @@ /** * **Input DTO for creating entity templates.** * - * - Used as the request body for POST operations on entity templates. - * - Composes all updatable fields from {@link EntityTemplateCommonFields} and flattens them into the top-level JSON using {@code @JsonUnwrapped}. - * - Fields are validated using Jakarta Validation annotations. - * - Follows composition over inheritance for maintainability and clarity. + * - Used as the request body for POST operations on entity templates. - + * Composes all updatable fields from {@link EntityTemplateCommonFields} and + * flattens them into the top-level JSON using {@code @JsonUnwrapped}. - Fields + * are validated using Jakarta Validation annotations. - Follows composition + * over inheritance for maintainability and clarity. * * @see EntityTemplateCommonFields */ @@ -34,11 +36,11 @@ @Schema(description = SCHEMA_ENTITY_TEMPLATE_CREATE_IN) public class EntityTemplateCreateDtoIn { - @NotBlank(message = TEMPLATE_IDENTIFIER_MANDATORY) - @Schema(description = FIELD_TEMPLATE_IDENTIFIER, example = "service") - private String identifier; + @NotBlank(message = TEMPLATE_IDENTIFIER_MANDATORY) + @Schema(description = FIELD_TEMPLATE_IDENTIFIER, example = "service") + private String identifier; - @Valid - @JsonUnwrapped - private EntityTemplateDtoInCommonFields commonFields; + @Valid + @JsonUnwrapped + private EntityTemplateDtoInCommonFields commonFields; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityTemplateDtoInCommonFields.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityTemplateDtoInCommonFields.java index 3b55f245..4a99fdbc 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityTemplateDtoInCommonFields.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityTemplateDtoInCommonFields.java @@ -11,21 +11,23 @@ import java.util.List; -import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; -import com.fasterxml.jackson.databind.annotation.JsonNaming; - -import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; /** - * Common fields shared between EntityTemplateCreateDtoIn (POST) and EntityTemplateUpdateDtoIn (PUT). + * Common fields shared between EntityTemplateCreateDtoIn (POST) and + * EntityTemplateUpdateDtoIn (PUT). */ @Data @Builder @@ -34,20 +36,20 @@ @JsonNaming(SnakeCaseStrategy.class) public class EntityTemplateDtoInCommonFields { - @Size(max = 255, message = TEMPLATE_NAME_MAX_SIZE) - @Schema(description = FIELD_TEMPLATE_NAME, example = "Service") - @NotBlank(message = TEMPLATE_NAME_MANDATORY) - @Pattern(regexp = ENTITY_TEMPLATE_NAME_REGEX, message = TEMPLATE_NAME_FORMAT) - private String name; + @Size(max = 255, message = TEMPLATE_NAME_MAX_SIZE) + @Schema(description = FIELD_TEMPLATE_NAME, example = "Service") + @NotBlank(message = TEMPLATE_NAME_MANDATORY) + @Pattern(regexp = ENTITY_TEMPLATE_NAME_REGEX, message = TEMPLATE_NAME_FORMAT) + private String name; - @Schema(description = FIELD_TEMPLATE_DESCRIPTION, example = "A comprehensive service template") - private String description; + @Schema(description = FIELD_TEMPLATE_DESCRIPTION, example = "A comprehensive service template") + private String description; - @Valid - @Schema(description = FIELD_TEMPLATE_PROPERTIES) - private List propertiesDefinitions; + @Valid + @Schema(description = FIELD_TEMPLATE_PROPERTIES) + private List propertiesDefinitions; - @Valid - @Schema(description = FIELD_TEMPLATE_RELATIONS) - private List relationsDefinitions; + @Valid + @Schema(description = FIELD_TEMPLATE_RELATIONS) + private List relationsDefinitions; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityTemplateUpdateDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityTemplateUpdateDtoIn.java index 8e3b1c3c..39295b70 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityTemplateUpdateDtoIn.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityTemplateUpdateDtoIn.java @@ -1,25 +1,27 @@ package com.decathlon.idp_core.infrastructure.adapters.api.dto.in; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.SCHEMA_ENTITY_TEMPLATE_UPDATE_IN; + +import jakarta.validation.Valid; + import com.fasterxml.jackson.annotation.JsonUnwrapped; import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.Valid; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.SCHEMA_ENTITY_TEMPLATE_UPDATE_IN; - /** * **Input DTO for updating entity templates.** * - * - Used as the request body for PUT operations on entity templates. - * - Composes all updatable fields from {@link EntityTemplateCommonFields} and flattens them into the top-level JSON using {@code @JsonUnwrapped}. - * - Fields are validated using Jakarta Validation annotations. - * - Follows composition over inheritance for maintainability and clarity. + * - Used as the request body for PUT operations on entity templates. - Composes + * all updatable fields from {@link EntityTemplateCommonFields} and flattens + * them into the top-level JSON using {@code @JsonUnwrapped}. - Fields are + * validated using Jakarta Validation annotations. - Follows composition over + * inheritance for maintainability and clarity. * * @see EntityTemplateCommonFields */ @@ -31,7 +33,7 @@ @Schema(description = SCHEMA_ENTITY_TEMPLATE_UPDATE_IN) public class EntityTemplateUpdateDtoIn { - @Valid - @JsonUnwrapped - private EntityTemplateDtoInCommonFields commonFields; + @Valid + @JsonUnwrapped + private EntityTemplateDtoInCommonFields commonFields; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/PropertyDefinitionDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/PropertyDefinitionDtoIn.java index 80e74e4f..bb328735 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/PropertyDefinitionDtoIn.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/PropertyDefinitionDtoIn.java @@ -10,14 +10,15 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_PROPERTY_TYPE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.SCHEMA_PROPERTY_DEFINITION_IN; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + import com.decathlon.idp_core.domain.model.enums.PropertyType; import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -30,23 +31,23 @@ @JsonNaming(SnakeCaseStrategy.class) @Schema(description = SCHEMA_PROPERTY_DEFINITION_IN) public class PropertyDefinitionDtoIn { - @NotBlank(message = PROPERTY_NAME_MANDATORY) - @Schema(description = FIELD_PROPERTY_NAME, example = "applicationName") - private String name; + @NotBlank(message = PROPERTY_NAME_MANDATORY) + @Schema(description = FIELD_PROPERTY_NAME, example = "applicationName") + private String name; - @NotBlank(message = PROPERTY_DESCRIPTION_MANDATORY) - @Schema(description = FIELD_PROPERTY_DESCRIPTION, example = "Name of the application") - private String description; + @NotBlank(message = PROPERTY_DESCRIPTION_MANDATORY) + @Schema(description = FIELD_PROPERTY_DESCRIPTION, example = "Name of the application") + private String description; - @NotNull(message = PROPERTY_TYPE_MANDATORY) - @Schema(description = FIELD_PROPERTY_TYPE, example = "STRING") - private PropertyType type; + @NotNull(message = PROPERTY_TYPE_MANDATORY) + @Schema(description = FIELD_PROPERTY_TYPE, example = "STRING") + private PropertyType type; - @Builder.Default - @Schema(description = FIELD_PROPERTY_REQUIRED, example = "true", defaultValue = "false") - private boolean required = false; + @Builder.Default + @Schema(description = FIELD_PROPERTY_REQUIRED, example = "true", defaultValue = "false") + private boolean required = false; - @Valid - @Schema(description = FIELD_PROPERTY_RULES) - private PropertyRulesDtoIn rules; + @Valid + @Schema(description = FIELD_PROPERTY_RULES) + private PropertyRulesDtoIn rules; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/PropertyRulesDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/PropertyRulesDtoIn.java index 04a7511c..761758df 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/PropertyRulesDtoIn.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/PropertyRulesDtoIn.java @@ -20,24 +20,24 @@ @Schema(description = SCHEMA_PROPERTY_DEFINITION_IN) public class PropertyRulesDtoIn { - @Schema(description = "Property format validation", example = "EMAIL") - private PropertyFormat format; + @Schema(description = "Property format validation", example = "EMAIL") + private PropertyFormat format; - @Schema(description = "Enumeration values for enum properties", example = "[\"ACTIVE\", \"INACTIVE\"]") - private String[] enumValues; + @Schema(description = "Enumeration values for enum properties", example = "[\"ACTIVE\", \"INACTIVE\"]") + private String[] enumValues; - @Schema(description = "Regular expression pattern for validation", example = "^[a-zA-Z0-9]+$") - private String regex; + @Schema(description = "Regular expression pattern for validation", example = "^[a-zA-Z0-9]+$") + private String regex; - @Schema(description = "Maximum length for string properties", example = "255") - private Integer maxLength; + @Schema(description = "Maximum length for string properties", example = "255") + private Integer maxLength; - @Schema(description = "Minimum length for string properties", example = "1") - private Integer minLength; + @Schema(description = "Minimum length for string properties", example = "1") + private Integer minLength; - @Schema(description = "Maximum value for numeric properties", example = "100") - private Integer maxValue; + @Schema(description = "Maximum value for numeric properties", example = "100") + private Integer maxValue; - @Schema(description = "Minimum value for numeric properties", example = "0") - private Integer minValue; + @Schema(description = "Minimum value for numeric properties", example = "0") + private Integer minValue; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/RelationDefinitionDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/RelationDefinitionDtoIn.java index be1b9912..48a954b5 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/RelationDefinitionDtoIn.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/RelationDefinitionDtoIn.java @@ -8,11 +8,12 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_RELATION_TO_MANY; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.SCHEMA_RELATION_DEFINITION_IN; +import jakarta.validation.constraints.NotBlank; + import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotBlank; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -26,19 +27,19 @@ @Schema(description = SCHEMA_RELATION_DEFINITION_IN) public class RelationDefinitionDtoIn { - @NotBlank(message = RELATION_NAME_MANDATORY) - @Schema(description = FIELD_RELATION_NAME, example = "dependencies") - private String name; + @NotBlank(message = RELATION_NAME_MANDATORY) + @Schema(description = FIELD_RELATION_NAME, example = "dependencies") + private String name; - @NotBlank(message = RELATION_TARGET_IDENTIFIER_MANDATORY) - @Schema(description = FIELD_RELATION_TARGET_IDENTIFIER, example = "service") - private String targetTemplateIdentifier; + @NotBlank(message = RELATION_TARGET_IDENTIFIER_MANDATORY) + @Schema(description = FIELD_RELATION_TARGET_IDENTIFIER, example = "service") + private String targetTemplateIdentifier; - @Builder.Default - @Schema(description = FIELD_RELATION_REQUIRED, example = "false", defaultValue = "false") - private boolean required = false; + @Builder.Default + @Schema(description = FIELD_RELATION_REQUIRED, example = "false", defaultValue = "false") + private boolean required = false; - @Builder.Default - @Schema(description = FIELD_RELATION_TO_MANY, example = "true", defaultValue = "false") - private boolean toMany = false; + @Builder.Default + @Schema(description = FIELD_RELATION_TO_MANY, example = "true", defaultValue = "false") + private boolean toMany = false; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityDtoOut.java index c9278056..13e4c6d8 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityDtoOut.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityDtoOut.java @@ -14,11 +14,11 @@ @JsonNaming(SnakeCaseStrategy.class) public class EntityDtoOut { - private String templateIdentifier; - private String name; - private String identifier; - private Map properties; - private Map> relations; - private Map> relationsAsTarget; + private String templateIdentifier; + private String name; + private String identifier; + private Map properties; + private Map> relations; + private Map> relationsAsTarget; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphEdgeDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphEdgeDtoOut.java index c61800dc..d94bef18 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphEdgeDtoOut.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphEdgeDtoOut.java @@ -17,15 +17,11 @@ @JsonNaming(SnakeCaseStrategy.class) public record EntityGraphEdgeDtoOut( - @Schema(description = ENTITY_GRAPH_FLAT_EDGE_ID_DESCRIPTION) - String id, + @Schema(description = ENTITY_GRAPH_FLAT_EDGE_ID_DESCRIPTION) String id, - @Schema(description = ENTITY_GRAPH_FLAT_EDGE_SOURCE_DESCRIPTION) - String source, + @Schema(description = ENTITY_GRAPH_FLAT_EDGE_SOURCE_DESCRIPTION) String source, - @Schema(description = ENTITY_GRAPH_FLAT_EDGE_TARGET_DESCRIPTION) - String target, + @Schema(description = ENTITY_GRAPH_FLAT_EDGE_TARGET_DESCRIPTION) String target, - @Schema(description = ENTITY_GRAPH_FLAT_EDGE_TYPE_DESCRIPTION) - String type -) {} + @Schema(description = ENTITY_GRAPH_FLAT_EDGE_TYPE_DESCRIPTION) String type) { +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphFlatDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphFlatDtoOut.java index aa43eb8a..a6127851 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphFlatDtoOut.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphFlatDtoOut.java @@ -19,15 +19,12 @@ @JsonNaming(SnakeCaseStrategy.class) public record EntityGraphFlatDtoOut( - @Schema(description = ENTITY_GRAPH_FLAT_NODES_DESCRIPTION) - List nodes, + @Schema(description = ENTITY_GRAPH_FLAT_NODES_DESCRIPTION) List nodes, - @Schema(description = ENTITY_GRAPH_FLAT_EDGES_DESCRIPTION) - List edges -) { - /// Defensive copies prevent external mutation of the returned collections. - public EntityGraphFlatDtoOut { - nodes = nodes != null ? List.copyOf(nodes) : List.of(); - edges = edges != null ? List.copyOf(edges) : List.of(); - } + @Schema(description = ENTITY_GRAPH_FLAT_EDGES_DESCRIPTION) List edges) { + /// Defensive copies prevent external mutation of the returned collections. + public EntityGraphFlatDtoOut { + nodes = nodes != null ? List.copyOf(nodes) : List.of(); + edges = edges != null ? List.copyOf(edges) : List.of(); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java index c1fa208c..45fad52a 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java @@ -25,25 +25,19 @@ @JsonNaming(SnakeCaseStrategy.class) public record EntityGraphNodeFlatDtoOut( - @Schema(description = ENTITY_GRAPH_FLAT_NODE_ID_DESCRIPTION) - String id, - - @Schema(description = ENTITY_GRAPH_FLAT_NODE_LABEL_DESCRIPTION) - String label, - - @Schema(description = ENTITY_GRAPH_FLAT_NODE_TEMPLATE_DESCRIPTION) - String templateIdentifier, - - @Schema(description = ENTITY_GRAPH_FLAT_NODE_IDENTIFIER_DESCRIPTION) - String identifier, - - @JsonInclude(Include.NON_EMPTY) - @Schema(description = ENTITY_GRAPH_FLAT_NODE_DATA_DESCRIPTION) - Map data -) { - /// Compact constructor: defensively copies the data map to prevent external mutation - /// of the DTO after construction (EI_EXPOSE_REP2 / EI_EXPOSE_REP). - public EntityGraphNodeFlatDtoOut { - data = data == null ? Map.of() : Map.copyOf(data); - } + @Schema(description = ENTITY_GRAPH_FLAT_NODE_ID_DESCRIPTION) String id, + + @Schema(description = ENTITY_GRAPH_FLAT_NODE_LABEL_DESCRIPTION) String label, + + @Schema(description = ENTITY_GRAPH_FLAT_NODE_TEMPLATE_DESCRIPTION) String templateIdentifier, + + @Schema(description = ENTITY_GRAPH_FLAT_NODE_IDENTIFIER_DESCRIPTION) String identifier, + + @JsonInclude(Include.NON_EMPTY) @Schema(description = ENTITY_GRAPH_FLAT_NODE_DATA_DESCRIPTION) Map data) { + /// Compact constructor: defensively copies the data map to prevent external + /// mutation + /// of the DTO after construction (EI_EXPOSE_REP2 / EI_EXPOSE_REP). + public EntityGraphNodeFlatDtoOut { + data = data == null ? Map.of() : Map.copyOf(data); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntitySummaryDto.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntitySummaryDto.java index 8611f71b..c7641dca 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntitySummaryDto.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntitySummaryDto.java @@ -12,6 +12,6 @@ @AllArgsConstructor @JsonNaming(SnakeCaseStrategy.class) public class EntitySummaryDto { - private String identifier; - private String name; + private String identifier; + private String name; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/RelationAsTargetSummaryDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/RelationAsTargetSummaryDtoOut.java index 1e734bd1..b5a12aea 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/RelationAsTargetSummaryDtoOut.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/RelationAsTargetSummaryDtoOut.java @@ -5,9 +5,6 @@ /// Output DTO representing an incoming relationship where the entity is the target. @JsonNaming(SnakeCaseStrategy.class) -public record RelationAsTargetSummaryDtoOut( - String targetEntityIdentifier, - String relationName, - String sourceEntityIdentifier, - String sourceEntityName -) {} +public record RelationAsTargetSummaryDtoOut(String targetEntityIdentifier, String relationName, + String sourceEntityIdentifier, String sourceEntityName) { +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/EntityTemplateDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/EntityTemplateDtoOut.java index ea0ad86f..f8292527 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/EntityTemplateDtoOut.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/EntityTemplateDtoOut.java @@ -1,17 +1,18 @@ package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_template; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.*; + +import java.util.List; + import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; + import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -import java.util.List; - -import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.*; - @Data @Builder @NoArgsConstructor @@ -20,18 +21,18 @@ @Schema(description = "Output for entity template") public class EntityTemplateDtoOut { - @Schema(description = FIELD_TEMPLATE_IDENTIFIER, example = "service") - private String identifier; + @Schema(description = FIELD_TEMPLATE_IDENTIFIER, example = "service") + private String identifier; - @Schema(description = FIELD_TEMPLATE_NAME, example = "Service") - private String name; + @Schema(description = FIELD_TEMPLATE_NAME, example = "Service") + private String name; - @Schema(description = FIELD_TEMPLATE_DESCRIPTION, example = "A comprehensive service template") - private String description; + @Schema(description = FIELD_TEMPLATE_DESCRIPTION, example = "A comprehensive service template") + private String description; - @Schema(description = FIELD_TEMPLATE_PROPERTIES) - private List propertiesDefinitions; + @Schema(description = FIELD_TEMPLATE_PROPERTIES) + private List propertiesDefinitions; - @Schema(description = FIELD_TEMPLATE_RELATIONS) - private List relationsDefinitions; + @Schema(description = FIELD_TEMPLATE_RELATIONS) + private List relationsDefinitions; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/PropertyDefinitionDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/PropertyDefinitionDtoOut.java index b26f00d2..12d64082 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/PropertyDefinitionDtoOut.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/PropertyDefinitionDtoOut.java @@ -1,16 +1,17 @@ package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_template; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.*; + import com.decathlon.idp_core.domain.model.enums.PropertyType; import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; + import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.*; - @Data @Builder @NoArgsConstructor @@ -19,18 +20,18 @@ @Schema(description = SCHEMA_PROPERTY_DEFINITION_OUT) public class PropertyDefinitionDtoOut { - @Schema(description = FIELD_PROPERTY_NAME, example = "applicationName") - private String name; + @Schema(description = FIELD_PROPERTY_NAME, example = "applicationName") + private String name; - @Schema(description = FIELD_PROPERTY_DESCRIPTION, example = "Name of the application") - private String description; + @Schema(description = FIELD_PROPERTY_DESCRIPTION, example = "Name of the application") + private String description; - @Schema(description = FIELD_PROPERTY_TYPE, example = "STRING") - private PropertyType type; + @Schema(description = FIELD_PROPERTY_TYPE, example = "STRING") + private PropertyType type; - @Schema(description = FIELD_PROPERTY_REQUIRED, example = "true") - private boolean required; + @Schema(description = FIELD_PROPERTY_REQUIRED, example = "true") + private boolean required; - @Schema(description = FIELD_PROPERTY_RULES, example = "Property validation rules") - private PropertyRulesDtoOut rules; + @Schema(description = FIELD_PROPERTY_RULES, example = "Property validation rules") + private PropertyRulesDtoOut rules; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/PropertyRulesDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/PropertyRulesDtoOut.java index c754eeae..634930e0 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/PropertyRulesDtoOut.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/PropertyRulesDtoOut.java @@ -27,25 +27,25 @@ @Schema(description = SCHEMA_PROPERTY_RULES_OUT) public class PropertyRulesDtoOut { - @Schema(description = FIELD_PROPERTY_RULES_FORMAT, example = "STRING") - private PropertyFormat format; + @Schema(description = FIELD_PROPERTY_RULES_FORMAT, example = "STRING") + private PropertyFormat format; - @Schema(description = FIELD_PROPERTY_RULES_ENUM_VALUES, example = "[\"VALUE1\", \"VALUE2\"]") - private String[] enumValues; + @Schema(description = FIELD_PROPERTY_RULES_ENUM_VALUES, example = "[\"VALUE1\", \"VALUE2\"]") + private String[] enumValues; - @Schema(description = FIELD_PROPERTY_RULES_REGEX, example = "^[A-Za-z0-9]+$") - private String regex; + @Schema(description = FIELD_PROPERTY_RULES_REGEX, example = "^[A-Za-z0-9]+$") + private String regex; - @Schema(description = FIELD_PROPERTY_RULES_MAX_LENGTH, example = "255") - private Integer maxLength; + @Schema(description = FIELD_PROPERTY_RULES_MAX_LENGTH, example = "255") + private Integer maxLength; - @Schema(description = FIELD_PROPERTY_RULES_MIN_LENGTH, example = "1") - private Integer minLength; + @Schema(description = FIELD_PROPERTY_RULES_MIN_LENGTH, example = "1") + private Integer minLength; - @Schema(description = FIELD_PROPERTY_RULES_MAX_VALUE, example = "100") - private Integer maxValue; + @Schema(description = FIELD_PROPERTY_RULES_MAX_VALUE, example = "100") + private Integer maxValue; - @Schema(description = FIELD_PROPERTY_RULES_MIN_VALUE, example = "0") - private Integer minValue; + @Schema(description = FIELD_PROPERTY_RULES_MIN_VALUE, example = "0") + private Integer minValue; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/RelationDefinitionDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/RelationDefinitionDtoOut.java index fe5f39af..85bb6d15 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/RelationDefinitionDtoOut.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_template/RelationDefinitionDtoOut.java @@ -22,16 +22,16 @@ @Schema(description = "Output DTO for relation definition") public class RelationDefinitionDtoOut { - @Schema(description = FIELD_RELATION_NAME, example = "dependencies") - private String name; + @Schema(description = FIELD_RELATION_NAME, example = "dependencies") + private String name; - @Schema(description = FIELD_RELATION_TARGET_IDENTIFIER, example = "component-template") - private String targetTemplateIdentifier; + @Schema(description = FIELD_RELATION_TARGET_IDENTIFIER, example = "component-template") + private String targetTemplateIdentifier; - @Schema(description = FIELD_RELATION_REQUIRED, example = "false") - private boolean required; + @Schema(description = FIELD_RELATION_REQUIRED, example = "false") + private boolean required; - @Schema(description = FIELD_RELATION_TO_MANY, example = "true") - private boolean toMany; + @Schema(description = FIELD_RELATION_TO_MANY, example = "true") + private boolean toMany; } 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 33b33614..98579716 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 @@ -7,6 +7,9 @@ import java.util.regex.Pattern; import java.util.stream.Collectors; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; + import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; @@ -30,8 +33,6 @@ import com.decathlon.idp_core.domain.exception.entity_template.RelationTargetTemplateChangeException; import com.decathlon.idp_core.domain.exception.entity_template.TargetTemplateNotFoundException; -import jakarta.validation.ConstraintViolation; -import jakarta.validation.ConstraintViolationException; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -55,314 +56,333 @@ @ControllerAdvice public class ApiExceptionHandler { - private ApiExceptionHandler() { - } - - /// Handles domain exception when entity templates are not found. - /// - /// **HTTP mapping:** Maps domain EntityTemplateNotFoundException to HTTP 404 status - /// with business-meaningful error message for API consumers. - @ExceptionHandler(EntityTemplateNotFoundException.class) - public ResponseEntity handleTemplateNotFoundException(EntityTemplateNotFoundException ex) { - log.warn("Template not found: {}", ex.getMessage()); - ErrorResponse errorResponse = new ErrorResponse(NOT_FOUND.name(), ex.getMessage()); - return ResponseEntity.status(NOT_FOUND).body(errorResponse); - } - - /// Handles domain exception when entity templates already exist. - /// - /// **HTTP mapping:** Maps domain EntityTemplateAlreadyExistsException to HTTP 409 - /// status indicating business rule conflict for duplicate identifiers. - @ExceptionHandler(EntityTemplateAlreadyExistsException.class) - public ResponseEntity handleEntityTemplateAlreadyExistsException( - EntityTemplateAlreadyExistsException ex) { - log.warn("Entity template already exists: {}", ex.getMessage()); - ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); - return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); - } - - /// Handles domain exception when entity template names already exist. - /// - /// **HTTP mapping:** Maps domain EntityTemplateNameAlreadyExistsException to HTTP 409 - /// status indicating business rule conflict for duplicate template names. - @ExceptionHandler(EntityTemplateNameAlreadyExistsException.class) - public ResponseEntity handleEntityTemplateNameAlreadyExistsException( - EntityTemplateNameAlreadyExistsException ex) { - log.warn("Entity template name already exists: {}", ex.getMessage()); - ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); - return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); - } - - /// Handles domain exception when attempting to change an entity template identifier. - /// - /// **HTTP mapping:** Maps domain EntityTemplateIdentifierCannotChangeException to HTTP 400 - /// status indicating validation error for immutable identifier field. - @ExceptionHandler(EntityTemplateIdentifierCannotChangeException.class) - public ResponseEntity handleEntityTemplateIdentifierCannotChangeException( - EntityTemplateIdentifierCannotChangeException ex) { - log.warn("Entity template identifier cannot be changed: {}", ex.getMessage()); - ErrorResponse errorResponse = new ErrorResponse(HttpStatus.BAD_REQUEST.name(), ex.getMessage()); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); - } - - /// Handles domain exception for wrong entity template property rules. - /// - /// **HTTP mapping:** Maps domain PropertyDefinitionRulesConflictException to HTTP 400 - /// status indicating validation error for wrong property rules. - @ExceptionHandler(PropertyDefinitionRulesConflictException.class) - public ResponseEntity handleWrongPropertyRulesException( - PropertyDefinitionRulesConflictException ex) { - log.warn("Wrong Entity template property rules: {}", ex.getMessage()); - ErrorResponse errorResponse = new ErrorResponse(HttpStatus.BAD_REQUEST.name(), ex.getMessage()); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); - } - - /// Handles domain exception when property names are duplicated within a template. - /// - /// **HTTP mapping:** Maps domain PropertyNameAlreadyExistsException to HTTP 400 - /// status indicating validation error for duplicate property names. - @ExceptionHandler(PropertyNameAlreadyExistsException.class) - public ResponseEntity handlePropertyNameAlreadyExistsException( - PropertyNameAlreadyExistsException ex) { - log.warn("Duplicate property name: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + private ApiExceptionHandler() { + } + + /// Handles domain exception when entity templates are not found. + /// + /// **HTTP mapping:** Maps domain EntityTemplateNotFoundException to HTTP 404 + /// status + /// with business-meaningful error message for API consumers. + @ExceptionHandler(EntityTemplateNotFoundException.class) + public ResponseEntity handleTemplateNotFoundException( + EntityTemplateNotFoundException ex) { + log.warn("Template not found: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(NOT_FOUND.name(), ex.getMessage()); + return ResponseEntity.status(NOT_FOUND).body(errorResponse); + } + + /// Handles domain exception when entity templates already exist. + /// + /// **HTTP mapping:** Maps domain EntityTemplateAlreadyExistsException to HTTP + /// 409 + /// status indicating business rule conflict for duplicate identifiers. + @ExceptionHandler(EntityTemplateAlreadyExistsException.class) + public ResponseEntity handleEntityTemplateAlreadyExistsException( + EntityTemplateAlreadyExistsException ex) { + log.warn("Entity template already exists: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + } + + /// Handles domain exception when entity template names already exist. + /// + /// **HTTP mapping:** Maps domain EntityTemplateNameAlreadyExistsException to + /// HTTP 409 + /// status indicating business rule conflict for duplicate template names. + @ExceptionHandler(EntityTemplateNameAlreadyExistsException.class) + public ResponseEntity handleEntityTemplateNameAlreadyExistsException( + EntityTemplateNameAlreadyExistsException ex) { + log.warn("Entity template name already exists: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + } + + /// Handles domain exception when attempting to change an entity template + /// identifier. + /// + /// **HTTP mapping:** Maps domain EntityTemplateIdentifierCannotChangeException + /// to HTTP 400 + /// status indicating validation error for immutable identifier field. + @ExceptionHandler(EntityTemplateIdentifierCannotChangeException.class) + public ResponseEntity handleEntityTemplateIdentifierCannotChangeException( + EntityTemplateIdentifierCannotChangeException ex) { + log.warn("Entity template identifier cannot be changed: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.BAD_REQUEST.name(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + } + + /// Handles domain exception for wrong entity template property rules. + /// + /// **HTTP mapping:** Maps domain PropertyDefinitionRulesConflictException to + /// HTTP 400 + /// status indicating validation error for wrong property rules. + @ExceptionHandler(PropertyDefinitionRulesConflictException.class) + public ResponseEntity handleWrongPropertyRulesException( + PropertyDefinitionRulesConflictException ex) { + log.warn("Wrong Entity template property rules: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.BAD_REQUEST.name(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + } + + /// Handles domain exception when property names are duplicated within a + /// template. + /// + /// **HTTP mapping:** Maps domain PropertyNameAlreadyExistsException to HTTP 400 + /// status indicating validation error for duplicate property names. + @ExceptionHandler(PropertyNameAlreadyExistsException.class) + public ResponseEntity handlePropertyNameAlreadyExistsException( + PropertyNameAlreadyExistsException ex) { + log.warn("Duplicate property name: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + /// Handles domain exception when relation names are duplicated within a + /// template. + /// + /// **HTTP mapping:** Maps domain RelationNameAlreadyExistsException to HTTP 400 + /// status indicating validation error for duplicate relation names. + @ExceptionHandler(RelationNameAlreadyExistsException.class) + public ResponseEntity handleRelationNameAlreadyExistsException( + RelationNameAlreadyExistsException ex) { + log.warn("Duplicate relation name: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + /// Handles domain exception when a relation references a non-existent target + /// template. + /// + /// **HTTP mapping:** Maps domain TargetTemplateNotFoundException to HTTP 400 + /// status indicating validation error for missing target template. + @ExceptionHandler(TargetTemplateNotFoundException.class) + public ResponseEntity handleTargetTemplateNotFoundException( + TargetTemplateNotFoundException ex) { + log.warn("Target template not found: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + /// Handles domain exception when type changes are attempted. + /// + /// **HTTP mapping:** Maps domain PropertyTypeChangeException to HTTP 400 + /// status indicating validation error for type changes. + @ExceptionHandler(PropertyTypeChangeException.class) + public ResponseEntity handleTypeChangeException(PropertyTypeChangeException ex) { + log.warn("Type change error: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + /// Handles domain exception when relation target template changes are + /// attempted. + /// + /// **HTTP mapping:** Maps domain RelationTargetTemplateChangeException to HTTP + /// 400 + /// status indicating validation error for immutable target template field. + @ExceptionHandler(RelationTargetTemplateChangeException.class) + public ResponseEntity handleRelationTargetTemplateChangeException( + RelationTargetTemplateChangeException ex) { + log.warn("Relation target template change error: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + /// Handles domain exception when a relation's target template identifier is the + /// template itself. + /// + /// **HTTP mapping:** Maps domain RelationCannotTargetItselfException to HTTP + /// 400 + /// status indicating validation error for self-referential relations. + @ExceptionHandler(RelationCannotTargetItselfException.class) + public ResponseEntity handleRelationCannotTargetItselfException( + RelationCannotTargetItselfException ex) { + log.warn("Relation self-reference error: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + /// Handles Bean Validation constraint violations from domain model validation. + /// + /// **Error aggregation:** Combines multiple constraint violation messages into + /// single user-friendly response with HTTP 400 status for client correction. + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleConstraintViolationException( + ConstraintViolationException ex) { + log.warn("Validation constraint violation: {}", ex.getMessage()); + + String errorMessage = ex.getConstraintViolations().stream().map(ConstraintViolation::getMessage) + .collect(Collectors.joining(", ")); + return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage); + } + + /// Handles Spring MVC request body validation failures. + /// + /// **Field-level errors:** Extracts and aggregates field validation errors from + /// request body binding into comprehensive HTTP 400 error response. + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException( + MethodArgumentNotValidException ex) { + log.warn("Method argument validation error: {}", ex.getMessage()); + + String errorMessage = ex.getBindingResult().getFieldErrors().stream() + .map(org.springframework.context.MessageSourceResolvable::getDefaultMessage) + .collect(Collectors.joining(", ")); + + return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage); + } + + /// Handles JSON parsing and deserialization errors from request bodies. + /// + /// **User-friendly parsing:** Converts technical JSON parsing errors into + /// readable messages, especially for enum validation and format issues. + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleHttpMessageNotReadableException( + HttpMessageNotReadableException ex) { + log.warn("HTTP message not readable: {}", ex.getMessage()); + + String errorMessage = parseHttpMessageNotReadableError(ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage); + } + + /// Handles domain exception when entities are not found. + /// + /// **HTTP mapping:** Maps domain EntityNotFoundException to HTTP 404 status + /// with specific entity context for API consumers. + @ExceptionHandler(EntityNotFoundException.class) + public ResponseEntity handleEntityNotFoundException(EntityNotFoundException ex) { + ErrorResponse errorResponse = new ErrorResponse(NOT_FOUND.name(), ex.getMessage()); + return ResponseEntity.status(NOT_FOUND).body(errorResponse); + } + + /// Handles domain exception when entities already exist. + /// + /// **HTTP mapping:** Maps domain EntityAlreadyExistsException to HTTP 409 + /// status indicating business rule conflict for duplicate entities. + @ExceptionHandler(EntityAlreadyExistsException.class) + public ResponseEntity handleEntityAlreadyExistsException( + EntityAlreadyExistsException ex) { + log.warn("Entity already exists: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + } + + /// Handles domain exception when entity validation fails. + /// + /// **HTTP mapping:** Maps domain EntityValidationException to HTTP 400 status + /// with aggregated + /// validation error messages for client correction. + @ExceptionHandler(EntityValidationException.class) + public ResponseEntity handleEntityValidationException( + EntityValidationException ex) { + log.warn("Entity validation failed: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + @ExceptionHandler(NoHandlerFoundException.class) + public ResponseEntity handleNotFound(NoHandlerFoundException e) { + return createErrorResponse(NOT_FOUND, "Resource not found: " + e.getRequestURL()); + } + + private String parseHttpMessageNotReadableError(String originalMessage) { + if (originalMessage == null) { + return "Invalid request body format"; } - /// Handles domain exception when relation names are duplicated within a template. - /// - /// **HTTP mapping:** Maps domain RelationNameAlreadyExistsException to HTTP 400 - /// status indicating validation error for duplicate relation names. - @ExceptionHandler(RelationNameAlreadyExistsException.class) - public ResponseEntity handleRelationNameAlreadyExistsException( - RelationNameAlreadyExistsException ex) { - log.warn("Duplicate relation name: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + if (originalMessage.contains("Cannot deserialize value")) { + return parseDeserializationError(originalMessage); + } else if (originalMessage.contains("Required request body is missing")) { + return "Request body is required"; + } else if (originalMessage.contains("JSON parse error")) { + return "Invalid JSON format in request body"; } - /// Handles domain exception when a relation references a non-existent target template. - /// - /// **HTTP mapping:** Maps domain TargetTemplateNotFoundException to HTTP 400 - /// status indicating validation error for missing target template. - @ExceptionHandler(TargetTemplateNotFoundException.class) - public ResponseEntity handleTargetTemplateNotFoundException( - TargetTemplateNotFoundException ex) { - log.warn("Target template not found: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); - } + return "Invalid request body format"; + } - /// Handles domain exception when type changes are attempted. - /// - /// **HTTP mapping:** Maps domain PropertyTypeChangeException to HTTP 400 - /// status indicating validation error for type changes. - @ExceptionHandler(PropertyTypeChangeException.class) - public ResponseEntity handleTypeChangeException( - PropertyTypeChangeException ex) { - log.warn("Type change error: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + private String parseDeserializationError(String originalMessage) { + if (originalMessage.contains("not one of the values accepted for Enum class")) { + return parseEnumDeserializationError(originalMessage); } + return parseTypeDeserializationError(originalMessage); + } - /// Handles domain exception when relation target template changes are attempted. - /// - /// **HTTP mapping:** Maps domain RelationTargetTemplateChangeException to HTTP 400 - /// status indicating validation error for immutable target template field. - @ExceptionHandler(RelationTargetTemplateChangeException.class) - public ResponseEntity handleRelationTargetTemplateChangeException( - RelationTargetTemplateChangeException ex) { - log.warn("Relation target template change error: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); - } - - /// Handles domain exception when a relation's target template identifier is the template itself. - /// - /// **HTTP mapping:** Maps domain RelationCannotTargetItselfException to HTTP 400 - /// status indicating validation error for self-referential relations. - @ExceptionHandler(RelationCannotTargetItselfException.class) - public ResponseEntity handleRelationCannotTargetItselfException( - RelationCannotTargetItselfException ex) { - log.warn("Relation self-reference error: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); - } + private String parseTypeDeserializationError(String originalMessage) { + String targetType = extractTargetType(originalMessage); + String invalidValue = extractInvalidValueFromString(originalMessage); - /// Handles Bean Validation constraint violations from domain model validation. - /// - /// **Error aggregation:** Combines multiple constraint violation messages into - /// single user-friendly response with HTTP 400 status for client correction. - @ExceptionHandler(ConstraintViolationException.class) - public ResponseEntity handleConstraintViolationException(ConstraintViolationException ex) { - log.warn("Validation constraint violation: {}", ex.getMessage()); - - String errorMessage = ex.getConstraintViolations().stream() - .map(ConstraintViolation::getMessage) - .collect(Collectors.joining(", ")); - return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage); + if (!targetType.isEmpty() && !invalidValue.isEmpty()) { + return "Invalid value '" + invalidValue + "' for property, expected " + targetType; + } else if (!targetType.isEmpty()) { + return "Invalid type: expected " + targetType; } - - /// Handles Spring MVC request body validation failures. - /// - /// **Field-level errors:** Extracts and aggregates field validation errors from - /// request body binding into comprehensive HTTP 400 error response. - @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) { - log.warn("Method argument validation error: {}", ex.getMessage()); - - String errorMessage = ex.getBindingResult().getFieldErrors().stream() - .map(org.springframework.context.MessageSourceResolvable::getDefaultMessage) - .collect(Collectors.joining(", ")); - - return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage); + return "Cannot deserialize request body property"; + } + + private String extractTargetType(String message) { + Pattern typePattern = Pattern.compile("Cannot deserialize value of type `([^`]+)`"); + Matcher matcher = typePattern.matcher(message); + if (matcher.find()) { + String fullType = matcher.group(1); + return fullType.substring(fullType.lastIndexOf('.') + 1); } - - /// Handles JSON parsing and deserialization errors from request bodies. - /// - /// **User-friendly parsing:** Converts technical JSON parsing errors into - /// readable messages, especially for enum validation and format issues. - @ExceptionHandler(HttpMessageNotReadableException.class) - public ResponseEntity handleHttpMessageNotReadableException(HttpMessageNotReadableException ex) { - log.warn("HTTP message not readable: {}", ex.getMessage()); - - String errorMessage = parseHttpMessageNotReadableError(ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage); + return ""; + } + + private String extractInvalidValueFromString(String message) { + Pattern valuePattern = Pattern.compile("from String \"([^\"]+)\""); + Matcher matcher = valuePattern.matcher(message); + if (matcher.find()) { + return matcher.group(1); } + return ""; + } + private String parseEnumDeserializationError(String originalMessage) { + String enumTypeName = getPropertyNameFromEnumType(originalMessage); + String invalidValue = extractInvalidValueFromString(originalMessage); - /// Handles domain exception when entities are not found. - /// - /// **HTTP mapping:** Maps domain EntityNotFoundException to HTTP 404 status - /// with specific entity context for API consumers. - @ExceptionHandler(EntityNotFoundException.class) - public ResponseEntity handleEntityNotFoundException(EntityNotFoundException ex) { - ErrorResponse errorResponse = new ErrorResponse(NOT_FOUND.name(), ex.getMessage()); - return ResponseEntity.status(NOT_FOUND).body(errorResponse); + if (!enumTypeName.isEmpty() && !invalidValue.isEmpty()) { + return "Invalid value '" + invalidValue + "' for property '" + enumTypeName + "'"; + } else if (!enumTypeName.isEmpty()) { + return "Invalid value for property '" + enumTypeName + "'"; } + return "Invalid enum value in request body"; + } - /// Handles domain exception when entities already exist. - /// - /// **HTTP mapping:** Maps domain EntityAlreadyExistsException to HTTP 409 - /// status indicating business rule conflict for duplicate entities. - @ExceptionHandler(EntityAlreadyExistsException.class) - public ResponseEntity handleEntityAlreadyExistsException(EntityAlreadyExistsException ex) { - log.warn("Entity already exists: {}", ex.getMessage()); - ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); - return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); - } + private static final Map ENUM_TYPE_TO_PROPERTY = Map.of("PropertyType", "type", + "PropertyFormat", "format"); - /// Handles domain exception when entity validation fails. - /// - /// **HTTP mapping:** Maps domain EntityValidationException to HTTP 400 status with aggregated - /// validation error messages for client correction. - @ExceptionHandler(EntityValidationException.class) - public ResponseEntity handleEntityValidationException(EntityValidationException ex) { - log.warn("Entity validation failed: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); - } - - @ExceptionHandler(NoHandlerFoundException.class) - public ResponseEntity handleNotFound(NoHandlerFoundException e) { - return createErrorResponse(NOT_FOUND, "Resource not found: " + e.getRequestURL()); - } - - private String parseHttpMessageNotReadableError(String originalMessage) { - if (originalMessage == null) { - return "Invalid request body format"; - } - - if (originalMessage.contains("Cannot deserialize value")) { - return parseDeserializationError(originalMessage); - } else if (originalMessage.contains("Required request body is missing")) { - return "Request body is required"; - } else if (originalMessage.contains("JSON parse error")) { - return "Invalid JSON format in request body"; - } - - return "Invalid request body format"; - } - - private String parseDeserializationError(String originalMessage) { - if (originalMessage.contains("not one of the values accepted for Enum class")) { - return parseEnumDeserializationError(originalMessage); - } - return parseTypeDeserializationError(originalMessage); - } - - private String parseTypeDeserializationError(String originalMessage) { - String targetType = extractTargetType(originalMessage); - String invalidValue = extractInvalidValueFromString(originalMessage); - - if (!targetType.isEmpty() && !invalidValue.isEmpty()) { - return "Invalid value '" + invalidValue + "' for property, expected " + targetType; - } else if (!targetType.isEmpty()) { - return "Invalid type: expected " + targetType; - } - return "Cannot deserialize request body property"; - } - - private String extractTargetType(String message) { - Pattern typePattern = Pattern.compile("Cannot deserialize value of type `([^`]+)`"); - Matcher matcher = typePattern.matcher(message); - if (matcher.find()) { - String fullType = matcher.group(1); - return fullType.substring(fullType.lastIndexOf('.') + 1); - } - return ""; - } - - private String extractInvalidValueFromString(String message) { - Pattern valuePattern = Pattern.compile("from String \"([^\"]+)\""); - Matcher matcher = valuePattern.matcher(message); - if (matcher.find()) { - return matcher.group(1); - } - return ""; - } - - private String parseEnumDeserializationError(String originalMessage) { - String enumTypeName = getPropertyNameFromEnumType(originalMessage); - String invalidValue = extractInvalidValueFromString(originalMessage); - - if (!enumTypeName.isEmpty() && !invalidValue.isEmpty()) { - return "Invalid value '" + invalidValue + "' for property '" + enumTypeName + "'"; - } else if (!enumTypeName.isEmpty()) { - return "Invalid value for property '" + enumTypeName + "'"; - } - return "Invalid enum value in request body"; - } - - private static final Map ENUM_TYPE_TO_PROPERTY = Map.of( - "PropertyType", "type", - "PropertyFormat", "format"); - - private static final Pattern ENUM_CLASS_PATTERN = Pattern.compile("Cannot deserialize value of type `(?:[\\w.]+\\.)?(\\w+)`"); - - private String getPropertyNameFromEnumType(String message) { - Matcher matcher = ENUM_CLASS_PATTERN.matcher(message); - if (matcher.find()) { - String enumType = matcher.group(1); - return ENUM_TYPE_TO_PROPERTY.getOrDefault(enumType, ""); - } - return ""; - } - - /// Handles all unexpected exceptions as safety fallback. - /// - /// **Security consideration:** Returns generic error message to prevent information - /// leakage while logging full exception details for internal debugging. - @ExceptionHandler(Exception.class) - public ResponseEntity handleGenericException(Exception ex) { - log.error("Unexpected error occurred: {}", ex.getMessage(), ex); - - String errorMessage = "An unexpected error occurred. Please try again later."; - return createErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR, errorMessage); - } - - private static ResponseEntity createErrorResponse(HttpStatus httpStatus, String errorMessage) { - return new ResponseEntity<>(new ErrorResponse(httpStatus.name(), errorMessage), httpStatus); - } + private static final Pattern ENUM_CLASS_PATTERN = Pattern + .compile("Cannot deserialize value of type `(?:[\\w.]+\\.)?(\\w+)`"); - @Getter - @AllArgsConstructor - @NoArgsConstructor(force = true) - public static class ErrorResponse { - private String error; - private String errorDescription; + private String getPropertyNameFromEnumType(String message) { + Matcher matcher = ENUM_CLASS_PATTERN.matcher(message); + if (matcher.find()) { + String enumType = matcher.group(1); + return ENUM_TYPE_TO_PROPERTY.getOrDefault(enumType, ""); } + return ""; + } + + /// Handles all unexpected exceptions as safety fallback. + /// + /// **Security consideration:** Returns generic error message to prevent + /// information + /// leakage while logging full exception details for internal debugging. + @ExceptionHandler(Exception.class) + public ResponseEntity handleGenericException(Exception ex) { + log.error("Unexpected error occurred: {}", ex.getMessage(), ex); + + String errorMessage = "An unexpected error occurred. Please try again later."; + return createErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR, errorMessage); + } + + private static ResponseEntity createErrorResponse(HttpStatus httpStatus, + String errorMessage) { + return new ResponseEntity<>(new ErrorResponse(httpStatus.name(), errorMessage), httpStatus); + } + + @Getter + @AllArgsConstructor + @NoArgsConstructor(force = true) + public static class ErrorResponse { + private String error; + private String errorDescription; + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java index 5548ec05..9aed43db 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java @@ -4,7 +4,6 @@ import java.util.List; import java.util.Map; -import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import com.decathlon.idp_core.domain.model.entity.Entity; @@ -12,6 +11,8 @@ import com.decathlon.idp_core.domain.model.entity.Relation; import com.decathlon.idp_core.infrastructure.adapters.api.dto.in.EntityDtoIn; +import lombok.RequiredArgsConstructor; + /// Adapter mapper for converting API request DTOs to domain [Entity] objects. /// /// **Infrastructure mapping responsibilities:** @@ -31,39 +32,26 @@ @RequiredArgsConstructor public class EntityDtoInMapper { - /// Converts an entity creation request DTO to a domain entity. - /// - /// @param entityDtoIn the entity creation request payload - /// @param entityTemplateIdentifier the target template identifier - /// @return the mapped domain entity with audit fields populated - public Entity fromEntityDtoInToEntity(EntityDtoIn entityDtoIn, String entityTemplateIdentifier) { - - List properties = entityDtoIn.getProperties() == null ? Collections.emptyList() - : entityDtoIn.getProperties().entrySet().stream() - .map((Map.Entry entry) -> new Property( - null, - entry.getKey(), - entry.getValue() - )) - .toList(); - - List relations = entityDtoIn.getRelations() == null ? Collections.emptyList() - : entityDtoIn.getRelations().stream() - .map(relDto -> new Relation( - null, - relDto.getName(), - null, - relDto.getTargetEntityIdentifiers() - )) - .toList(); - - return new Entity( - null, - entityTemplateIdentifier, - entityDtoIn.getName(), - entityDtoIn.getIdentifier(), - properties, - relations - ); - } + /// Converts an entity creation request DTO to a domain entity. + /// + /// @param entityDtoIn the entity creation request payload + /// @param entityTemplateIdentifier the target template identifier + /// @return the mapped domain entity with audit fields populated + public Entity fromEntityDtoInToEntity(EntityDtoIn entityDtoIn, String entityTemplateIdentifier) { + + List properties = entityDtoIn.getProperties() == null + ? Collections.emptyList() + : entityDtoIn.getProperties().entrySet().stream() + .map((Map.Entry entry) -> new Property(null, entry.getKey(), + entry.getValue())) + .toList(); + + List relations = entityDtoIn.getRelations() == null + ? Collections.emptyList() + : entityDtoIn.getRelations().stream().map(relDto -> new Relation(null, relDto.getName(), + null, relDto.getTargetEntityIdentifiers())).toList(); + + return new Entity(null, entityTemplateIdentifier, entityDtoIn.getName(), + entityDtoIn.getIdentifier(), properties, relations); + } } 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 755d424b..3fab5fdd 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 @@ -49,269 +49,258 @@ @RequiredArgsConstructor public class EntityDtoOutMapper { - private final EntityTemplateService entityTemplateService; - private final EntityService entityService; - private final RelationService relationService; - - /// Maps a single domain entity to API DTO using template-based conversion. - /// - /// **Infrastructure mapping:** Resolves entity template dynamically and performs - /// complete domain-to-DTO transformation including properties and relationships. - /// - /// @param entity domain entity to convert for API response - /// @return fully mapped entity DTO with resolved template metadata - public EntityDtoOut fromEntity(Entity entity) { - EntityTemplate entityTemplate = entityTemplateService - .getEntityTemplateByIdentifier(entity.templateIdentifier()); - return fromEntityUsingEntityTemplate(entity, entityTemplate); - } - - /// Maps paginated domain entities to API DTOs with optimized bulk operations. - /// - /// **Performance optimization:** Batches template resolution and relationship lookups - /// to minimize database queries. Builds summary maps for efficient relationship - /// resolution across the entire page. - /// - /// @param entities paginated domain entities from repository layer - /// @param entityTemplateIdentifier template identifier for batch template resolution - /// @return paginated API DTOs with complete relationship data - public Page fromEntitiesPageToDtoPage(Page entities, - String entityTemplateIdentifier) { - - Map pageEntitiesSummaries = buildRelatedEntitiesSummaryMapByPage(entities); - Map> relationTargetOwnershipsMap = buildRelationsAsTargetSummaryMapByPage( - entities); - - EntityTemplate pageEntityTemplate = entityTemplateService - .getEntityTemplateByIdentifier(entityTemplateIdentifier); - return entities.map(entity -> fromEntityUsingEntityTemplateAndSummaryMap(entity, pageEntityTemplate, - pageEntitiesSummaries, relationTargetOwnershipsMap)); - } - - - /// Maps a single entity to its DTO using the provided entity template. - /// - /// @param entity the entity to map - /// @param entityTemplate the template for property type mapping - /// @return the mapped DTO - private EntityDtoOut fromEntityUsingEntityTemplate(Entity entity, EntityTemplate entityTemplate) { - Map props = mapPropertiesDto(entity, entityTemplate); - - List allTargetIdentifiers = getAllTargetIdentifiersFromEntityRelations(entity); - Map relatedEntitiesSummaryMap = buildEntitiesSummariesMap(allTargetIdentifiers); - Map> relationMap = mapRelationsDto(entity, relatedEntitiesSummaryMap); - Map> relatedEntitiesByTargetSummaryMap = buildRelationsAsTargetSummaryMapByEntity( - entity); - Map> relationAsTargetMap = mapRelationsAsTargetDto(entity, - relatedEntitiesByTargetSummaryMap); - - return EntityDtoOut.builder() - .templateIdentifier(entity.templateIdentifier()) - .name(entity.name()) - .identifier(entity.identifier()) - .properties(props) - .relations(relationMap) - .relationsAsTarget(relationAsTargetMap) - .build(); - } - - /// Maps a single entity to its DTO using pre-built summary and - /// relation-as-target maps. - /// - /// @param entity the entity to map - /// @param entityTemplate the template for property type mapping - /// @param relatedEntitiesSummaries map of entity summaries for relation - /// targets - /// @param relationTargetOwnershipsMap map of relations-as-target for the entity - /// @return the mapped DTO - private EntityDtoOut fromEntityUsingEntityTemplateAndSummaryMap(Entity entity, EntityTemplate entityTemplate, - Map relatedEntitiesSummaries, - Map> relationTargetOwnershipsMap) { - - Map props = mapPropertiesDto(entity, entityTemplate); - Map> relationMap = mapRelationsDto(entity, relatedEntitiesSummaries); - Map> relationAsTargetMap = mapRelationsAsTargetDto(entity, - relationTargetOwnershipsMap); - - return EntityDtoOut.builder() - .templateIdentifier(entity.templateIdentifier()) - .name(entity.name()) - .identifier(entity.identifier()) - .properties(props) - .relations(relationMap) - .relationsAsTarget(relationAsTargetMap) - .build(); - } - - /// Maps the properties of an entity to a map of property names to typed values, - /// using the entity template for type conversion. - /// - /// @param entity the entity whose properties to map - /// @param entityTemplate the template for property type mapping - /// @return a map of property names to typed values - private Map mapPropertiesDto(Entity entity, EntityTemplate entityTemplate) { - - if (entity.properties() == null) { - return Collections.emptyMap(); - } - - Map propertiesDefinitions = entityTemplate.propertiesDefinitions().stream() - .collect(Collectors.toMap(PropertyDefinition::name, Function.identity())); - - return entity.properties().stream() - .filter(prop -> prop.value() != null) - .collect(Collectors.toMap( - Property::name, - prop -> { - PropertyDefinition def = propertiesDefinitions.get(prop.name()); - Object rawValue = prop.value(); - if (def == null || rawValue == null) { - return rawValue; - } - String stringValue = String.valueOf(rawValue); - PropertyType type = def.type(); - if (PropertyType.NUMBER.equals(type)) { - try { - return Double.valueOf(stringValue); - } catch (NumberFormatException _) { - return null; - } - } else if (PropertyType.BOOLEAN.equals(type)) { - return Boolean.valueOf(stringValue); - } - return stringValue; - })); - } - - /// Maps the relations of an entity to a map of relation names to lists of target - /// entity summaries. - /// - /// @param entity the entity whose relations to map - /// @param relatedEntitiesSummaries map of entity summaries for relation targets - /// @return a map of relation names to lists of target entity summaries - private Map> mapRelationsDto(Entity entity, - Map relatedEntitiesSummaries) { - return entity.relations() == null - ? Collections.emptyMap() - : entity.relations().stream() - .collect(Collectors.groupingBy( - Relation::name, - Collectors.flatMapping(rel -> rel.targetEntityIdentifiers().stream() - .map(relatedEntitiesSummaries::get) - .filter(Objects::nonNull), - Collectors.toList()))); - } - - /// - /// Maps the relations-as-target for an entity to a map of relation names to - /// lists of source entity summaries. - /// - /// @param entity the entity whose relations-as-target to - /// map - /// @param relationTargetOwnershipsMap map of relations-as-target for the entity - /// @return a map of relation names to lists of source entity summaries - private Map> mapRelationsAsTargetDto(Entity entity, - Map> relationTargetOwnershipsMap) { - List relationAsTargetSummaries = relationTargetOwnershipsMap - .get(entity.identifier()); - if (relationAsTargetSummaries == null) { - return Collections.emptyMap(); - } - - return relationAsTargetSummaries.stream() - .collect(Collectors.groupingBy( - RelationAsTargetSummary::relationName, - Collectors.mapping( - r -> new EntitySummaryDto(r.sourceEntityIdentifier(), r.sourceEntityName()), - Collectors.toList()))); + private final EntityTemplateService entityTemplateService; + private final EntityService entityService; + private final RelationService relationService; + + /// Maps a single domain entity to API DTO using template-based conversion. + /// + /// **Infrastructure mapping:** Resolves entity template dynamically and + /// performs + /// complete domain-to-DTO transformation including properties and + /// relationships. + /// + /// @param entity domain entity to convert for API response + /// @return fully mapped entity DTO with resolved template metadata + public EntityDtoOut fromEntity(Entity entity) { + EntityTemplate entityTemplate = entityTemplateService + .getEntityTemplateByIdentifier(entity.templateIdentifier()); + return fromEntityUsingEntityTemplate(entity, entityTemplate); + } + + /// Maps paginated domain entities to API DTOs with optimized bulk operations. + /// + /// **Performance optimization:** Batches template resolution and relationship + /// lookups + /// to minimize database queries. Builds summary maps for efficient relationship + /// resolution across the entire page. + /// + /// @param entities paginated domain entities from repository layer + /// @param entityTemplateIdentifier template identifier for batch template + /// resolution + /// @return paginated API DTOs with complete relationship data + public Page fromEntitiesPageToDtoPage(Page entities, + String entityTemplateIdentifier) { + + Map pageEntitiesSummaries = buildRelatedEntitiesSummaryMapByPage( + entities); + Map> relationTargetOwnershipsMap = buildRelationsAsTargetSummaryMapByPage( + entities); + + EntityTemplate pageEntityTemplate = entityTemplateService + .getEntityTemplateByIdentifier(entityTemplateIdentifier); + return entities.map(entity -> fromEntityUsingEntityTemplateAndSummaryMap(entity, + pageEntityTemplate, pageEntitiesSummaries, relationTargetOwnershipsMap)); + } + + /// Maps a single entity to its DTO using the provided entity template. + /// + /// @param entity the entity to map + /// @param entityTemplate the template for property type mapping + /// @return the mapped DTO + private EntityDtoOut fromEntityUsingEntityTemplate(Entity entity, EntityTemplate entityTemplate) { + Map props = mapPropertiesDto(entity, entityTemplate); + + List allTargetIdentifiers = getAllTargetIdentifiersFromEntityRelations(entity); + Map relatedEntitiesSummaryMap = buildEntitiesSummariesMap( + allTargetIdentifiers); + Map> relationMap = mapRelationsDto(entity, + relatedEntitiesSummaryMap); + Map> relatedEntitiesByTargetSummaryMap = buildRelationsAsTargetSummaryMapByEntity( + entity); + Map> relationAsTargetMap = mapRelationsAsTargetDto(entity, + relatedEntitiesByTargetSummaryMap); + + return EntityDtoOut.builder().templateIdentifier(entity.templateIdentifier()) + .name(entity.name()).identifier(entity.identifier()).properties(props) + .relations(relationMap).relationsAsTarget(relationAsTargetMap).build(); + } + + /// Maps a single entity to its DTO using pre-built summary and + /// relation-as-target maps. + /// + /// @param entity the entity to map + /// @param entityTemplate the template for property type mapping + /// @param relatedEntitiesSummaries map of entity summaries for relation + /// targets + /// @param relationTargetOwnershipsMap map of relations-as-target for the entity + /// @return the mapped DTO + private EntityDtoOut fromEntityUsingEntityTemplateAndSummaryMap(Entity entity, + EntityTemplate entityTemplate, Map relatedEntitiesSummaries, + Map> relationTargetOwnershipsMap) { + + Map props = mapPropertiesDto(entity, entityTemplate); + Map> relationMap = mapRelationsDto(entity, + relatedEntitiesSummaries); + Map> relationAsTargetMap = mapRelationsAsTargetDto(entity, + relationTargetOwnershipsMap); + + return EntityDtoOut.builder().templateIdentifier(entity.templateIdentifier()) + .name(entity.name()).identifier(entity.identifier()).properties(props) + .relations(relationMap).relationsAsTarget(relationAsTargetMap).build(); + } + + /// Maps the properties of an entity to a map of property names to typed values, + /// using the entity template for type conversion. + /// + /// @param entity the entity whose properties to map + /// @param entityTemplate the template for property type mapping + /// @return a map of property names to typed values + private Map mapPropertiesDto(Entity entity, EntityTemplate entityTemplate) { + + if (entity.properties() == null) { + return Collections.emptyMap(); } - /// Builds a map of relation target ownerships for a list of entities, grouping - /// by target entity identifier. - /// - /// @param entitiesPage the list of entities to analyze - /// @return a map from target entity identifier to list of relation-as-target summaries - private Map> buildRelationsAsTargetSummaryMapByPage( - Page entitiesPage) { - if (entitiesPage == null || entitiesPage.getContent().isEmpty()) { - return Collections.emptyMap(); - } - List entitiesIdentifiers = entitiesPage.getContent().stream().map(Entity::identifier) - .filter(Objects::nonNull).toList(); - List relationTargetOwnerships = relationService - .findRelationsSummariesByTargetEntityIdentifiers(entitiesIdentifiers); - return relationTargetOwnerships.stream() - .collect(Collectors.groupingBy(RelationAsTargetSummary::targetEntityIdentifier)); + Map propertiesDefinitions = entityTemplate.propertiesDefinitions() + .stream().collect(Collectors.toMap(PropertyDefinition::name, Function.identity())); + + return entity.properties().stream().filter(prop -> prop.value() != null) + .collect(Collectors.toMap(Property::name, prop -> { + PropertyDefinition def = propertiesDefinitions.get(prop.name()); + Object rawValue = prop.value(); + if (def == null || rawValue == null) { + return rawValue; + } + String stringValue = String.valueOf(rawValue); + PropertyType type = def.type(); + if (PropertyType.NUMBER.equals(type)) { + try { + return Double.valueOf(stringValue); + } catch (NumberFormatException _) { + return null; + } + } else if (PropertyType.BOOLEAN.equals(type)) { + return Boolean.valueOf(stringValue); + } + return stringValue; + })); + } + + /// Maps the relations of an entity to a map of relation names to lists of + /// target + /// entity summaries. + /// + /// @param entity the entity whose relations to map + /// @param relatedEntitiesSummaries map of entity summaries for relation targets + /// @return a map of relation names to lists of target entity summaries + private Map> mapRelationsDto(Entity entity, + Map relatedEntitiesSummaries) { + return entity.relations() == null + ? Collections.emptyMap() + : entity.relations().stream().collect(Collectors.groupingBy(Relation::name, + Collectors.flatMapping(rel -> rel.targetEntityIdentifiers().stream() + .map(relatedEntitiesSummaries::get).filter(Objects::nonNull), + Collectors.toList()))); + } + + /// + /// Maps the relations-as-target for an entity to a map of relation names to + /// lists of source entity summaries. + /// + /// @param entity the entity whose relations-as-target to + /// map + /// @param relationTargetOwnershipsMap map of relations-as-target for the entity + /// @return a map of relation names to lists of source entity summaries + private Map> mapRelationsAsTargetDto(Entity entity, + Map> relationTargetOwnershipsMap) { + List relationAsTargetSummaries = relationTargetOwnershipsMap + .get(entity.identifier()); + if (relationAsTargetSummaries == null) { + return Collections.emptyMap(); } - /// - /// Builds a map of relation target ownerships for a single entity, grouping by - /// target entity identifier. - /// - /// @param entity the entity to analyze - /// @return a map from target entity identifier to list of relation-as-target - /// summaries - private Map> buildRelationsAsTargetSummaryMapByEntity(Entity entity) { - if (entity == null || entity.identifier() == null) { - return Collections.emptyMap(); - } - List relationTargetOwnerships = relationService - .findRelationsSummariesByTargetEntityIdentifiers(List.of(entity.identifier())); - return relationTargetOwnerships.stream() - .collect(Collectors.groupingBy(RelationAsTargetSummary::targetEntityIdentifier)); + return relationAsTargetSummaries.stream() + .collect(Collectors.groupingBy(RelationAsTargetSummary::relationName, + Collectors.mapping( + r -> new EntitySummaryDto(r.sourceEntityIdentifier(), r.sourceEntityName()), + Collectors.toList()))); + } + + /// Builds a map of relation target ownerships for a list of entities, grouping + /// by target entity identifier. + /// + /// @param entitiesPage the list of entities to analyze + /// @return a map from target entity identifier to list of relation-as-target + /// summaries + private Map> buildRelationsAsTargetSummaryMapByPage( + Page entitiesPage) { + if (entitiesPage == null || entitiesPage.getContent().isEmpty()) { + return Collections.emptyMap(); } - - /// Gets all unique target entity identifiers from the relations of a single - /// entity. - /// - /// @param entity the entity to analyze - /// @return a list of unique target entity identifiers - private List getAllTargetIdentifiersFromEntityRelations(Entity entity) { - return entity.relations() == null - ? Collections.emptyList() - : new ArrayList<>(entity.relations().stream() - .flatMap(rel -> rel.targetEntityIdentifiers().stream()) - .collect(Collectors.toSet())); - } - - /// - /// Gets all unique target entity identifiers from the relations of all entities - /// in a page. - /// - /// @param entities the page of entities to analyze - /// @return a list of unique target entity identifiers - private List getUniqueTargetIdentifiersInPage(Page entities) { - return new ArrayList<>(entities.stream() - .flatMap(entity -> entity.relations() == null - ? Stream.empty() - : entity.relations().stream() - .flatMap(rel -> rel.targetEntityIdentifiers().stream())) - .collect(Collectors.toSet())); - - } - - /// Builds a map of entity summaries for all unique target identifiers in a page - /// of entities. - /// - /// @param entities the page of entities - /// @return a map from entity identifier to summary DTO - private Map buildRelatedEntitiesSummaryMapByPage(Page entities) { - return buildEntitiesSummariesMap( - getUniqueTargetIdentifiersInPage(entities)); - } - - /// Builds a map of entity summaries for a list of target identifiers. - /// - /// @param targetIdentifiers the list of target entity identifiers - /// @return a map from entity identifier to summary DTO - private Map buildEntitiesSummariesMap(List targetIdentifiers) { - return targetIdentifiers.isEmpty() - ? Collections.emptyMap() - : entityService.getEntitiesSummariesByIndentifiers(targetIdentifiers) - .stream() - .collect(Collectors.toMap( - EntitySummary::identifier, - es -> new EntitySummaryDto(es.identifier(), es.name()))); + List entitiesIdentifiers = entitiesPage.getContent().stream().map(Entity::identifier) + .filter(Objects::nonNull).toList(); + List relationTargetOwnerships = relationService + .findRelationsSummariesByTargetEntityIdentifiers(entitiesIdentifiers); + return relationTargetOwnerships.stream() + .collect(Collectors.groupingBy(RelationAsTargetSummary::targetEntityIdentifier)); + } + + /// + /// Builds a map of relation target ownerships for a single entity, grouping by + /// target entity identifier. + /// + /// @param entity the entity to analyze + /// @return a map from target entity identifier to list of relation-as-target + /// summaries + private Map> buildRelationsAsTargetSummaryMapByEntity( + Entity entity) { + if (entity == null || entity.identifier() == null) { + return Collections.emptyMap(); } + List relationTargetOwnerships = relationService + .findRelationsSummariesByTargetEntityIdentifiers(List.of(entity.identifier())); + return relationTargetOwnerships.stream() + .collect(Collectors.groupingBy(RelationAsTargetSummary::targetEntityIdentifier)); + } + + /// Gets all unique target entity identifiers from the relations of a single + /// entity. + /// + /// @param entity the entity to analyze + /// @return a list of unique target entity identifiers + private List getAllTargetIdentifiersFromEntityRelations(Entity entity) { + return entity.relations() == null + ? Collections.emptyList() + : new ArrayList<>(entity.relations().stream() + .flatMap(rel -> rel.targetEntityIdentifiers().stream()).collect(Collectors.toSet())); + } + + /// + /// Gets all unique target entity identifiers from the relations of all entities + /// in a page. + /// + /// @param entities the page of entities to analyze + /// @return a list of unique target entity identifiers + private List getUniqueTargetIdentifiersInPage(Page entities) { + return new ArrayList<>(entities.stream() + .flatMap(entity -> entity.relations() == null + ? Stream.empty() + : entity.relations().stream().flatMap(rel -> rel.targetEntityIdentifiers().stream())) + .collect(Collectors.toSet())); + + } + + /// Builds a map of entity summaries for all unique target identifiers in a page + /// of entities. + /// + /// @param entities the page of entities + /// @return a map from entity identifier to summary DTO + private Map buildRelatedEntitiesSummaryMapByPage( + Page entities) { + return buildEntitiesSummariesMap(getUniqueTargetIdentifiersInPage(entities)); + } + + /// Builds a map of entity summaries for a list of target identifiers. + /// + /// @param targetIdentifiers the list of target entity identifiers + /// @return a map from entity identifier to summary DTO + private Map buildEntitiesSummariesMap(List targetIdentifiers) { + return targetIdentifiers.isEmpty() + ? Collections.emptyMap() + : entityService.getEntitiesSummariesByIndentifiers(targetIdentifiers).stream() + .collect(Collectors.toMap(EntitySummary::identifier, + es -> new EntitySummaryDto(es.identifier(), es.name()))); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java index 8b90f8bc..fd96646e 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java @@ -32,149 +32,150 @@ /// otherwise be emitted twice when both sides of a relation are traversed. public final class EntityGraphFlatDtoOutMapper { - private EntityGraphFlatDtoOutMapper() { - // Utility class — not instantiable + private EntityGraphFlatDtoOutMapper() { + // Utility class — not instantiable + } + + /// Groups mutable traversal accumulators to stay within the method-parameter + /// limit + /// and keep the traversal signature readable. + private record TraversalState(SequencedSet nodes, + List edges, Set visitedNodeIds, + Set emittedEdgeSignatures, AtomicInteger edgeCounter) { + } + + /// Maps a domain graph node tree to a flat [EntityGraphFlatDtoOut]. + /// + /// @param root the root [EntityGraphNode] returned by the domain service + /// @param relationFilter when non-empty, only edges whose type is in this set + /// are emitted, + /// and nodes not referenced by any remaining edge are pruned; + /// an empty set means no filter — all edge types and nodes are emitted + /// @param propertyFilter when non-empty, only properties whose name is in this + /// set appear + /// in each node's `data` field; + /// an empty set means no filter — all properties are included + /// @return flat DTO with deduplicated nodes and directed edges + public static EntityGraphFlatDtoOut toFlatDto(EntityGraphNode root, Set relationFilter, + Set propertyFilter) { + if (root == null) { + return new EntityGraphFlatDtoOut(List.of(), List.of()); } - /// Groups mutable traversal accumulators to stay within the method-parameter limit - /// and keep the traversal signature readable. - private record TraversalState( - SequencedSet nodes, - List edges, - Set visitedNodeIds, - Set emittedEdgeSignatures, - AtomicInteger edgeCounter) { + var state = new TraversalState(new LinkedHashSet<>(), // nodes — insertion-ordered, deduplicated + new ArrayList<>(), // edges + new HashSet<>(), // visitedNodeIds — prevents infinite loops in cyclic graphs + new HashSet<>(), // emittedEdgeSignatures — prevents duplicate edges + new AtomicInteger(0)); // edgeCounter + + traverse(root, state, relationFilter, propertyFilter); + + // When a relation filter is active, prune nodes that are not connected to any + // remaining edge. Without this step, nodes reachable via non-filtered edges + // would + // appear in the node list despite having no visible edges. + List finalNodes; + if (relationFilter.isEmpty()) { + finalNodes = List.copyOf(state.nodes()); + } else { + // Collect all node IDs referenced by the filtered edges only. + // The root receives no special treatment: if it has no matching edges + // it is pruned just like any other disconnected node. + Set referencedNodeIds = new HashSet<>(); + for (var edge : state.edges()) { + referencedNodeIds.add(edge.source()); + referencedNodeIds.add(edge.target()); + } + finalNodes = state.nodes().stream().filter(n -> referencedNodeIds.contains(n.id())).toList(); } - /// Maps a domain graph node tree to a flat [EntityGraphFlatDtoOut]. - /// - /// @param root the root [EntityGraphNode] returned by the domain service - /// @param relationFilter when non-empty, only edges whose type is in this set are emitted, - /// and nodes not referenced by any remaining edge are pruned; - /// an empty set means no filter — all edge types and nodes are emitted - /// @param propertyFilter when non-empty, only properties whose name is in this set appear - /// in each node's `data` field; - /// an empty set means no filter — all properties are included - /// @return flat DTO with deduplicated nodes and directed edges - public static EntityGraphFlatDtoOut toFlatDto(EntityGraphNode root, Set relationFilter, - Set propertyFilter) { - if (root == null) { - return new EntityGraphFlatDtoOut(List.of(), List.of()); - } - - var state = new TraversalState( - new LinkedHashSet<>(), // nodes — insertion-ordered, deduplicated - new ArrayList<>(), // edges - new HashSet<>(), // visitedNodeIds — prevents infinite loops in cyclic graphs - new HashSet<>(), // emittedEdgeSignatures — prevents duplicate edges - new AtomicInteger(0)); // edgeCounter - - traverse(root, state, relationFilter, propertyFilter); - - // When a relation filter is active, prune nodes that are not connected to any - // remaining edge. Without this step, nodes reachable via non-filtered edges would - // appear in the node list despite having no visible edges. - List finalNodes; - if (relationFilter.isEmpty()) { - finalNodes = List.copyOf(state.nodes()); - } else { - // Collect all node IDs referenced by the filtered edges only. - // The root receives no special treatment: if it has no matching edges - // it is pruned just like any other disconnected node. - Set referencedNodeIds = new HashSet<>(); - for (var edge : state.edges()) { - referencedNodeIds.add(edge.source()); - referencedNodeIds.add(edge.target()); - } - finalNodes = state.nodes().stream() - .filter(n -> referencedNodeIds.contains(n.id())) - .toList(); - } - - return new EntityGraphFlatDtoOut(finalNodes, List.copyOf(state.edges())); - } - - private static void traverse( - EntityGraphNode node, - TraversalState state, - Set relationFilter, - Set propertyFilter) { + return new EntityGraphFlatDtoOut(finalNodes, List.copyOf(state.edges())); + } - var nodeId = nodeId(node.templateIdentifier(), node.identifier()); + private static void traverse(EntityGraphNode node, TraversalState state, + Set relationFilter, Set propertyFilter) { - // Skip this node if already visited to prevent infinite loops in cyclic graphs - if (!state.visitedNodeIds().add(nodeId)) { - return; - } + var nodeId = nodeId(node.templateIdentifier(), node.identifier()); - state.nodes().add(new EntityGraphNodeFlatDtoOut( - nodeId, node.name(), node.templateIdentifier(), node.identifier(), - toDataMap(node, propertyFilter))); - - // Traverse outbound relations: emit edge from currentNode → target only when the - // relation type matches the filter (or no filter is active). Nodes are always - // traversed so that deeper nodes remain reachable regardless of edge visibility. - for (EntityGraphRelation relation : node.relations()) { - for (EntityGraphNode target : relation.targets()) { - var targetId = nodeId(target.templateIdentifier(), target.identifier()); - if (relationFilter.isEmpty() || relationFilter.contains(relation.name())) { - addEdge(state, nodeId, targetId, relation.name()); - } - traverse(target, state, relationFilter, propertyFilter); - } - } + // Skip this node if already visited to prevent infinite loops in cyclic graphs + if (!state.visitedNodeIds().add(nodeId)) { + return; + } - // Traverse inbound relations: emit edge from source → currentNode. - // This is essential when the root entity has no outbound relations and is only - // reachable as a target. Without this, traversal would stop at the root with no edges. - for (EntityGraphRelation relation : node.relationsAsTarget()) { - for (EntityGraphNode source : relation.targets()) { - var sourceId = nodeId(source.templateIdentifier(), source.identifier()); - if (relationFilter.isEmpty() || relationFilter.contains(relation.name())) { - addEdge(state, sourceId, nodeId, relation.name()); - } - traverse(source, state, relationFilter, propertyFilter); - } + state.nodes().add(new EntityGraphNodeFlatDtoOut(nodeId, node.name(), node.templateIdentifier(), + node.identifier(), toDataMap(node, propertyFilter))); + + // Traverse outbound relations: emit edge from currentNode → target only when + // the + // relation type matches the filter (or no filter is active). Nodes are always + // traversed so that deeper nodes remain reachable regardless of edge + // visibility. + for (EntityGraphRelation relation : node.relations()) { + for (EntityGraphNode target : relation.targets()) { + var targetId = nodeId(target.templateIdentifier(), target.identifier()); + if (relationFilter.isEmpty() || relationFilter.contains(relation.name())) { + addEdge(state, nodeId, targetId, relation.name()); } + traverse(target, state, relationFilter, propertyFilter); + } } - /// Adds a directed edge only if it has not been emitted before, preventing duplicates - /// that arise when the same relation is encountered from both the source and the target - /// during depth-first traversal. - private static void addEdge( - TraversalState state, - String sourceId, - String targetId, - String label) { - - var signature = sourceId + "|" + targetId + "|" + label; - if (state.emittedEdgeSignatures().add(signature)) { - state.edges().add(new EntityGraphEdgeDtoOut( - "e" + state.edgeCounter().incrementAndGet(), sourceId, targetId, label)); + // Traverse inbound relations: emit edge from source → currentNode. + // This is essential when the root entity has no outbound relations and is only + // reachable as a target. Without this, traversal would stop at the root with no + // edges. + for (EntityGraphRelation relation : node.relationsAsTarget()) { + for (EntityGraphNode source : relation.targets()) { + var sourceId = nodeId(source.templateIdentifier(), source.identifier()); + if (relationFilter.isEmpty() || relationFilter.contains(relation.name())) { + addEdge(state, sourceId, nodeId, relation.name()); } + traverse(source, state, relationFilter, propertyFilter); + } } - - /// Builds the unique node identifier from the entity's composite key. - /// Format: "templateIdentifier:identifier" — mirrors EntityCompositeKey.toString(). - private static String nodeId(String templateIdentifier, String identifier) { - return templateIdentifier + ":" + identifier; + } + + /// Adds a directed edge only if it has not been emitted before, preventing + /// duplicates + /// that arise when the same relation is encountered from both the source and + /// the target + /// during depth-first traversal. + private static void addEdge(TraversalState state, String sourceId, String targetId, + String label) { + + var signature = sourceId + "|" + targetId + "|" + label; + if (state.emittedEdgeSignatures().add(signature)) { + state.edges().add(new EntityGraphEdgeDtoOut("e" + state.edgeCounter().incrementAndGet(), + sourceId, targetId, label)); } - - /// Converts a node's property list to a name→value map for the `data` field. - /// - /// When [propertyFilter] is non-empty, only entries whose name is contained in the - /// filter are included. Returns an empty map when there are no matching properties; - /// the DTO's @JsonInclude(NON_EMPTY) annotation ensures an empty map is omitted from - /// the JSON output. - /// - /// @param node the graph node whose properties are converted - /// @param propertyFilter when non-empty, restricts which properties appear in the map; - /// an empty set means all properties are included - private static Map toDataMap(EntityGraphNode node, Set propertyFilter) { - var stream = node.properties().stream(); - if (!propertyFilter.isEmpty()) { - stream = stream.filter(p -> propertyFilter.contains(p.name())); - } - return stream.collect(Collectors.toMap(p -> p.name(), p -> p.value())); + } + + /// Builds the unique node identifier from the entity's composite key. + /// Format: "templateIdentifier:identifier" — mirrors + /// EntityCompositeKey.toString(). + private static String nodeId(String templateIdentifier, String identifier) { + return templateIdentifier + ":" + identifier; + } + + /// Converts a node's property list to a name→value map for the `data` field. + /// + /// When [propertyFilter] is non-empty, only entries whose name is contained in + /// the + /// filter are included. Returns an empty map when there are no matching + /// properties; + /// the DTO's @JsonInclude(NON_EMPTY) annotation ensures an empty map is omitted + /// from + /// the JSON output. + /// + /// @param node the graph node whose properties are converted + /// @param propertyFilter when non-empty, restricts which properties appear in + /// the map; + /// an empty set means all properties are included + private static Map toDataMap(EntityGraphNode node, Set propertyFilter) { + var stream = node.properties().stream(); + if (!propertyFilter.isEmpty()) { + stream = stream.filter(p -> propertyFilter.contains(p.name())); } + return stream.collect(Collectors.toMap(p -> p.name(), p -> p.value())); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity_template/EntityTemplateMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity_template/EntityTemplateMapper.java index 3dac2790..d64da394 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity_template/EntityTemplateMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity_template/EntityTemplateMapper.java @@ -37,234 +37,191 @@ @Component public class EntityTemplateMapper { - /// - /// Converts an EntityTemplate input DTO to a domain entity. - /// This method maps all fields from the input DTO to create a new EntityTemplate - /// domain entity. - /// Nested collections (properties and relations) are recursively converted using - /// their - /// respective mapping methods. - /// - /// @param dto the input DTO to convert, may be null - /// @return the converted EntityTemplate domain entity, or null if input is null - public EntityTemplate fromDtoToEntityTemplate(EntityTemplateCreateDtoIn dto) { - if (dto == null || dto.getCommonFields() == null) { - return null; - } - - return new EntityTemplate( - null, - dto.getIdentifier(), - dto.getCommonFields().getName(), - dto.getCommonFields().getDescription(), - toPropertyDefinitionEntities(dto.getCommonFields().getPropertiesDefinitions()), - toRelationDefinitionEntities(dto.getCommonFields().getRelationsDefinitions()) - ); + /// + /// Converts an EntityTemplate input DTO to a domain entity. + /// This method maps all fields from the input DTO to create a new + /// EntityTemplate + /// domain entity. + /// Nested collections (properties and relations) are recursively converted + /// using + /// their + /// respective mapping methods. + /// + /// @param dto the input DTO to convert, may be null + /// @return the converted EntityTemplate domain entity, or null if input is null + public EntityTemplate fromDtoToEntityTemplate(EntityTemplateCreateDtoIn dto) { + if (dto == null || dto.getCommonFields() == null) { + return null; } - /// - /// Converts an EntityTemplate PUT input DTO to a domain entity. - /// This method maps all fields from the PUT DTO to create a new EntityTemplate - /// domain entity, using the provided identifier from the path parameter. - /// Nested collections (properties and relations) are recursively converted using - /// their respective mapping methods. - /// - /// @param identifier the entity identifier from the path parameter - /// @param dto the input DTO to convert, may be null - /// @return the converted EntityTemplate domain entity, or null if input is null - public EntityTemplate fromPutDtoToEntityTemplate(String identifier, EntityTemplateUpdateDtoIn dto) { - if (dto == null || dto.getCommonFields() == null) { - return null; - } - - return new EntityTemplate( - null, - identifier, - dto.getCommonFields().getName(), - dto.getCommonFields().getDescription(), - toPropertyDefinitionEntities(dto.getCommonFields().getPropertiesDefinitions()), - toRelationDefinitionEntities(dto.getCommonFields().getRelationsDefinitions()) - ); + return new EntityTemplate(null, dto.getIdentifier(), dto.getCommonFields().getName(), + dto.getCommonFields().getDescription(), + toPropertyDefinitionEntities(dto.getCommonFields().getPropertiesDefinitions()), + toRelationDefinitionEntities(dto.getCommonFields().getRelationsDefinitions())); + } + + /// + /// Converts an EntityTemplate PUT input DTO to a domain entity. + /// This method maps all fields from the PUT DTO to create a new EntityTemplate + /// domain entity, using the provided identifier from the path parameter. + /// Nested collections (properties and relations) are recursively converted + /// using + /// their respective mapping methods. + /// + /// @param identifier the entity identifier from the path parameter + /// @param dto the input DTO to convert, may be null + /// @return the converted EntityTemplate domain entity, or null if input is null + public EntityTemplate fromPutDtoToEntityTemplate(String identifier, + EntityTemplateUpdateDtoIn dto) { + if (dto == null || dto.getCommonFields() == null) { + return null; } - /// - /// Converts an EntityTemplate domain entity to an output DTO. - /// This method maps all fields from the domain entity to create a new output DTO - /// for API responses. - /// The conversion includes the entity's UUID ID and all nested collections are - /// recursively - /// converted to their respective DTO representations. - /// - /// @param entity the domain entity to convert, may be null - /// @return the converted EntityTemplateDtoOut, or null if input is null - public EntityTemplateDtoOut fromEntityTemplatetoDto(EntityTemplate entity) { - if (entity == null) { - return null; - } - - return EntityTemplateDtoOut.builder() - .identifier(entity.identifier()) - .name(entity.name()) - .description(entity.description()) - .propertiesDefinitions(toPropertyDefinitionDtos(entity.propertiesDefinitions())) - .relationsDefinitions(toRelationDefinitionDtos(entity.relationsDefinitions())) - .build(); + return new EntityTemplate(null, identifier, dto.getCommonFields().getName(), + dto.getCommonFields().getDescription(), + toPropertyDefinitionEntities(dto.getCommonFields().getPropertiesDefinitions()), + toRelationDefinitionEntities(dto.getCommonFields().getRelationsDefinitions())); + } + + /// + /// Converts an EntityTemplate domain entity to an output DTO. + /// This method maps all fields from the domain entity to create a new output + /// DTO + /// for API responses. + /// The conversion includes the entity's UUID ID and all nested collections are + /// recursively + /// converted to their respective DTO representations. + /// + /// @param entity the domain entity to convert, may be null + /// @return the converted EntityTemplateDtoOut, or null if input is null + public EntityTemplateDtoOut fromEntityTemplatetoDto(EntityTemplate entity) { + if (entity == null) { + return null; } - /// - /// Converts a list of EntityTemplate domain entities to a list of output DTOs. - /// This is a convenience method for bulk conversion operations, particularly - /// useful - /// for paginated results and list endpoints. - ///

- /// - /// @param entities the list of domain entities to convert, may be null - /// @return a list of converted EntityTemplateDtoOut objects, empty list if input - /// is null - public List fromEntityTemplatesToDtos(List entities) { - if (entities == null) { - return List.of(); - } - return entities.stream() - .map(this::fromEntityTemplatetoDto) - .toList(); + return EntityTemplateDtoOut.builder().identifier(entity.identifier()).name(entity.name()) + .description(entity.description()) + .propertiesDefinitions(toPropertyDefinitionDtos(entity.propertiesDefinitions())) + .relationsDefinitions(toRelationDefinitionDtos(entity.relationsDefinitions())).build(); + } + + /// + /// Converts a list of EntityTemplate domain entities to a list of output DTOs. + /// This is a convenience method for bulk conversion operations, particularly + /// useful + /// for paginated results and list endpoints. + ///

+ /// + /// @param entities the list of domain entities to convert, may be null + /// @return a list of converted EntityTemplateDtoOut objects, empty list if + /// input + /// is null + public List fromEntityTemplatesToDtos(List entities) { + if (entities == null) { + return List.of(); + } + return entities.stream().map(this::fromEntityTemplatetoDto).toList(); + } + + /// + /// Converts a PropertyDefinition input DTO to a domain entity. + /// + /// @param dto the input DTO to convert, may be null + /// @return the converted PropertyDefinition domain entity, or null if input is + /// null + public PropertyDefinition toToPropertyDefinition(PropertyDefinitionDtoIn dto) { + if (dto == null) { + return null; } - /// - /// Converts a PropertyDefinition input DTO to a domain entity. - /// - /// @param dto the input DTO to convert, may be null - /// @return the converted PropertyDefinition domain entity, or null if input is - /// null - public PropertyDefinition toToPropertyDefinition(PropertyDefinitionDtoIn dto) { - if (dto == null) { - return null; - } - - return new PropertyDefinition( - null, - dto.getName(), - dto.getDescription(), - dto.getType(), - dto.isRequired(), - toPropertyRules(dto.getRules()) - ); + return new PropertyDefinition(null, dto.getName(), dto.getDescription(), dto.getType(), + dto.isRequired(), toPropertyRules(dto.getRules())); + } + + /// + /// Converts a PropertyDefinition domain entity to an output DTO. + /// + /// @param entity the domain entity to convert, may be null + /// @return the converted PropertyDefinitionDtoOut, or null if input is null + public PropertyDefinitionDtoOut toDto(PropertyDefinition entity) { + if (entity == null) { + return null; } - /// - /// Converts a PropertyDefinition domain entity to an output DTO. - /// - /// @param entity the domain entity to convert, may be null - /// @return the converted PropertyDefinitionDtoOut, or null if input is null - public PropertyDefinitionDtoOut toDto(PropertyDefinition entity) { - if (entity == null) { - return null; - } + return PropertyDefinitionDtoOut.builder().name(entity.name()).description(entity.description()) + .type(entity.type()).required(entity.required()).rules(toDto(entity.rules())).build(); + } - return PropertyDefinitionDtoOut.builder() - .name(entity.name()) - .description(entity.description()) - .type(entity.type()) - .required(entity.required()) - .rules(toDto(entity.rules())) - .build(); + public List toPropertyDefinitionEntities(List dtos) { + if (dtos == null) { + return List.of(); } + return dtos.stream().map(this::toToPropertyDefinition).toList(); + } - public List toPropertyDefinitionEntities(List dtos) { - if (dtos == null) { - return List.of(); - } - return dtos.stream() - .map(this::toToPropertyDefinition) - .toList(); + public List toPropertyDefinitionDtos( + List entities) { + if (entities == null) { + return List.of(); } + return entities.stream().map(this::toDto).toList(); + } - public List toPropertyDefinitionDtos(List entities) { - if (entities == null) { - return List.of(); - } - return entities.stream() - .map(this::toDto) - .toList(); + public PropertyRules toPropertyRules(PropertyRulesDtoIn dto) { + if (dto == null) { + return null; } - public PropertyRules toPropertyRules(PropertyRulesDtoIn dto) { - if (dto == null) { - return null; - } - - return new PropertyRules( - null, - dto.getFormat(), - dto.getEnumValues() != null - ? List.of(dto.getEnumValues()).stream().map(String::toUpperCase).toList() - : null, - dto.getRegex(), - dto.getMaxLength(), - dto.getMinLength(), - dto.getMaxValue(), - dto.getMinValue() - ); + return new PropertyRules(null, dto.getFormat(), + dto.getEnumValues() != null + ? List.of(dto.getEnumValues()).stream().map(String::toUpperCase).toList() + : null, + dto.getRegex(), dto.getMaxLength(), dto.getMinLength(), dto.getMaxValue(), + dto.getMinValue()); + } + + public PropertyRulesDtoOut toDto(PropertyRules entity) { + if (entity == null) { + return null; } - public PropertyRulesDtoOut toDto(PropertyRules entity) { - if (entity == null) { - return null; - } + return PropertyRulesDtoOut.builder().format(entity.format()) + .enumValues(entity.enumValues() != null ? entity.enumValues().toArray(new String[0]) : null) + .regex(entity.regex()).maxLength(entity.maxLength()).minLength(entity.minLength()) + .maxValue(entity.maxValue()).minValue(entity.minValue()).build(); + } - return PropertyRulesDtoOut.builder() - .format(entity.format()) - .enumValues(entity.enumValues() != null ? entity.enumValues().toArray(new String[0]) : null) - .regex(entity.regex()) - .maxLength(entity.maxLength()) - .minLength(entity.minLength()) - .maxValue(entity.maxValue()) - .minValue(entity.minValue()) - .build(); + public RelationDefinition toRelationDefinition(RelationDefinitionDtoIn dto) { + if (dto == null) { + return null; } - public RelationDefinition toRelationDefinition(RelationDefinitionDtoIn dto) { - if (dto == null) { - return null; - } + return new RelationDefinition(null, dto.getName(), dto.getTargetTemplateIdentifier(), + dto.isRequired(), dto.isToMany()); + } - return new RelationDefinition( - null, - dto.getName(), - dto.getTargetTemplateIdentifier(), - dto.isRequired(), - dto.isToMany() - ); + public RelationDefinitionDtoOut toDto(RelationDefinition entity) { + if (entity == null) { + return null; } - public RelationDefinitionDtoOut toDto(RelationDefinition entity) { - if (entity == null) { - return null; - } - - return RelationDefinitionDtoOut.builder() - .name(entity.name()) - .targetTemplateIdentifier(entity.targetTemplateIdentifier()) - .required(entity.required()) - .toMany(entity.toMany()) - .build(); - } + return RelationDefinitionDtoOut.builder().name(entity.name()) + .targetTemplateIdentifier(entity.targetTemplateIdentifier()).required(entity.required()) + .toMany(entity.toMany()).build(); + } - public List toRelationDefinitionEntities(List dtos) { - if (dtos == null) { - return List.of(); - } - return dtos.stream() - .map(this::toRelationDefinition) - .toList(); + public List toRelationDefinitionEntities(List dtos) { + if (dtos == null) { + return List.of(); } + return dtos.stream().map(this::toRelationDefinition).toList(); + } - public List toRelationDefinitionDtos(List entities) { - if (entities == null) { - return List.of(); - } - return entities.stream() - .map(this::toDto) - .toList(); + public List toRelationDefinitionDtos( + List entities) { + if (entities == null) { + return List.of(); } + return entities.stream().map(this::toDto).toList(); + } } 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 2a877eee..faeb8e76 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 @@ -21,54 +21,60 @@ @RequiredArgsConstructor public class PostgresEntityAdapter implements EntityRepositoryPort { - private final JpaEntityRepository jpaEntityRepository; - private final EntityPersistenceMapper mapper; + private final JpaEntityRepository jpaEntityRepository; + private final EntityPersistenceMapper mapper; - @Override - public Entity save(Entity entity) { - return mapper.toDomain(jpaEntityRepository.save(mapper.toJpa(entity))); - } + @Override + public Entity save(Entity entity) { + return mapper.toDomain(jpaEntityRepository.save(mapper.toJpa(entity))); + } - @Override - public Optional findById(UUID id) { - return jpaEntityRepository.findById(id).map(mapper::toDomain); - } + @Override + public Optional findById(UUID id) { + return jpaEntityRepository.findById(id).map(mapper::toDomain); + } - @Override - public Optional findByTemplateIdentifierAndIdentifier(String templateIdentifier, String identifier) { - return jpaEntityRepository.findByTemplateIdentifierAndIdentifier(templateIdentifier, identifier) - .map(mapper::toDomain); - } + @Override + public Optional findByTemplateIdentifierAndIdentifier(String templateIdentifier, + String identifier) { + return jpaEntityRepository.findByTemplateIdentifierAndIdentifier(templateIdentifier, identifier) + .map(mapper::toDomain); + } - @Override - public Optional findByTemplateIdentifierAndName(String templateIdentifier, String entityName) { - return jpaEntityRepository.findByTemplateIdentifierAndName(templateIdentifier, entityName) - .map(mapper::toDomain); - } + @Override + public Optional findByTemplateIdentifierAndName(String templateIdentifier, + String entityName) { + return jpaEntityRepository.findByTemplateIdentifierAndName(templateIdentifier, entityName) + .map(mapper::toDomain); + } - @Override - public Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable) { - var pageableEntity = jpaEntityRepository.findByTemplateIdentifier(templateIdentifier, pageable); - return pageableEntity.map(mapper::toDomain); - } + @Override + public Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable) { + var pageableEntity = jpaEntityRepository.findByTemplateIdentifier(templateIdentifier, pageable); + return pageableEntity.map(mapper::toDomain); + } - @Override - public List findByIdentifierIn(List identifiers) { - return jpaEntityRepository.findByIdentifierIn(identifiers); - } + @Override + public List findByIdentifierIn(List identifiers) { + return jpaEntityRepository.findByIdentifierIn(identifiers); + } - @Override - public List findByRelationIdIn(List relationIds) { - return jpaEntityRepository.findByRelationIdIn(relationIds); - } + @Override + public List findByRelationIdIn(List relationIds) { + return jpaEntityRepository.findByRelationIdIn(relationIds); + } - @Override - public void deletePropertiesByTemplateIdentifierAndPropertyName(String templateIdentifier, Collection propertyNames) { - jpaEntityRepository.deletePropertiesByTemplateIdentifierAndPropertyName(templateIdentifier, propertyNames); - } + @Override + public void deletePropertiesByTemplateIdentifierAndPropertyName(String templateIdentifier, + Collection propertyNames) { + jpaEntityRepository.deletePropertiesByTemplateIdentifierAndPropertyName(templateIdentifier, + propertyNames); + } - @Override - public void deleteRelationsByTemplateIdentifierAndRelationName(String templateIdentifier, Collection relationNames) { - jpaEntityRepository.deleteRelationsByTemplateIdentifierAndRelationName(templateIdentifier, relationNames); - } + @Override + public void deleteRelationsByTemplateIdentifierAndRelationName(String templateIdentifier, + Collection relationNames) { + jpaEntityRepository.deleteRelationsByTemplateIdentifierAndRelationName(templateIdentifier, + relationNames); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java index d48828c7..c84c1ac7 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java @@ -31,49 +31,44 @@ @RequiredArgsConstructor public class PostgresEntityGraphAdapter implements EntityGraphRepositoryPort { - private final JpaEntityRepository jpaEntityRepository; - private final EntityPersistenceMapper mapper; + private final JpaEntityRepository jpaEntityRepository; + private final EntityPersistenceMapper mapper; - @Override - @Transactional(readOnly = true) - public Map findEntityGraph( - String templateIdentifier, - String entityIdentifier, - int depth, - boolean includeProperties) { - // Step 1: collect all (identifier, template_identifier) pairs via recursive CTE. - // The CTE always traverses ALL relation types to discover all reachable nodes. - // Relation name filtering is applied at the service level when building edges, - // so nodes reachable via any path are included even if the filter only matches - // edges at deeper levels (e.g. filtering "owns" still returns B→C when A→B→C). - List graphPairs = jpaEntityRepository.findEntityGraphIdentifiers( - templateIdentifier, entityIdentifier, depth); + @Override + @Transactional(readOnly = true) + public Map findEntityGraph(String templateIdentifier, + String entityIdentifier, int depth, boolean includeProperties) { + // Step 1: collect all (identifier, template_identifier) pairs via recursive + // CTE. + // The CTE always traverses ALL relation types to discover all reachable nodes. + // Relation name filtering is applied at the service level when building edges, + // so nodes reachable via any path are included even if the filter only matches + // edges at deeper levels (e.g. filtering "owns" still returns B→C when A→B→C). + List graphPairs = jpaEntityRepository.findEntityGraphIdentifiers(templateIdentifier, + entityIdentifier, depth); - if (graphPairs.isEmpty()) { - return Map.of(); - } - - // Step 2: extract unique identifiers for batch loading - List identifiers = graphPairs.stream() - .map(pair -> (String) pair[0]) - .distinct() - .toList(); + if (graphPairs.isEmpty()) { + return Map.of(); + } - // Step 3: batch-load entities with relations, then optionally properties in a separate - // query. Properties are skipped when not requested to avoid the extra round-trip and - // keep payloads lean. The two-query split also avoids Hibernate's MultipleBagFetchException. - List jpaEntities = - jpaEntityRepository.findAllByIdentifierInWithRelations(identifiers); - if (includeProperties) { - jpaEntityRepository.findAllByIdentifierInWithProperties(identifiers); - } + // Step 2: extract unique identifiers for batch loading + List identifiers = graphPairs.stream().map(pair -> (String) pair[0]).distinct() + .toList(); - // Step 4: map to domain and key by composite key for O(1) lookup - return jpaEntities.stream() - .map(mapper::toDomain) - .collect(Collectors.toMap( - e -> new EntityCompositeKey(e.templateIdentifier(), e.identifier()), - Function.identity() - )); + // Step 3: batch-load entities with relations, then optionally properties in a + // separate + // query. Properties are skipped when not requested to avoid the extra + // round-trip and + // keep payloads lean. The two-query split also avoids Hibernate's + // MultipleBagFetchException. + List jpaEntities = jpaEntityRepository + .findAllByIdentifierInWithRelations(identifiers); + if (includeProperties) { + jpaEntityRepository.findAllByIdentifierInWithProperties(identifiers); } + + // Step 4: map to domain and key by composite key for O(1) lookup + return jpaEntities.stream().map(mapper::toDomain).collect(Collectors.toMap( + e -> new EntityCompositeKey(e.templateIdentifier(), e.identifier()), Function.identity())); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityTemplateAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityTemplateAdapter.java index 1a88a5e5..af72cfd0 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityTemplateAdapter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityTemplateAdapter.java @@ -39,180 +39,172 @@ @Component @RequiredArgsConstructor public class PostgresEntityTemplateAdapter implements EntityTemplateRepositoryPort { -/// - Entity graphs fetch properties and relations in single query -/// - Bulk operations minimize database round trips -/// - Lazy loading configured appropriately for relationship navigation - - private final JpaEntityTemplateRepository jpaEntityTemplateRepository; - private final EntityTemplatePersistenceMapper mapper; - - @Override - public Optional findByIdentifier(String templateIdentifier) { - return jpaEntityTemplateRepository.findByIdentifier(templateIdentifier).map(mapper::toDomain); + /// - Entity graphs fetch properties and relations in single query + /// - Bulk operations minimize database round trips + /// - Lazy loading configured appropriately for relationship navigation + + private final JpaEntityTemplateRepository jpaEntityTemplateRepository; + private final EntityTemplatePersistenceMapper mapper; + + @Override + public Optional findByIdentifier(String templateIdentifier) { + return jpaEntityTemplateRepository.findByIdentifier(templateIdentifier).map(mapper::toDomain); + } + + @Override + public Optional findById(UUID id) { + return jpaEntityTemplateRepository.findById(id).map(mapper::toDomain); + } + + @Override + public Page findAll(Pageable pageable) { + return jpaEntityTemplateRepository.findAll(pageable).map(mapper::toDomain); + } + + @Override + public boolean existsByIdentifier(String identifier) { + return jpaEntityTemplateRepository.existsByIdentifier(identifier); + } + + @Override + public boolean existsByName(String name) { + return jpaEntityTemplateRepository.existsByName(name); + } + + @Override + public EntityTemplate save(EntityTemplate entityTemplate) { + EntityTemplateJpaEntity jpaEntity; + if (entityTemplate.id() != null) { + // Update: fetch the managed JPA entity and merge in-place + jpaEntity = jpaEntityTemplateRepository.findById(entityTemplate.id()) + .orElseGet(() -> mapper.toJpa(entityTemplate)); + mergeIntoExisting(jpaEntity, entityTemplate); + } else { + jpaEntity = mapper.toJpa(entityTemplate); } - - @Override - public Optional findById(UUID id) { - return jpaEntityTemplateRepository.findById(id).map(mapper::toDomain); + return mapper.toDomain(jpaEntityTemplateRepository.save(jpaEntity)); + } + + @Override + public void deleteByIdentifier(String identifier) { + jpaEntityTemplateRepository.deleteByIdentifier(identifier); + } + + // ── Merge helpers to update a managed JPA entity from domain values ── + + private void mergeIntoExisting(EntityTemplateJpaEntity jpa, EntityTemplate domain) { + jpa.setIdentifier(domain.identifier()); + jpa.setName(domain.name()); + jpa.setDescription(domain.description()); + mergePropertyDefinitions(jpa, domain); + mergeRelationDefinitions(jpa, domain); + } + + private void mergePropertyDefinitions(EntityTemplateJpaEntity jpa, EntityTemplate domain) { + // Work on a mutable copy — getter returns an unmodifiable view + Set existing = new LinkedHashSet<>(jpa.getPropertiesDefinitions()); + + if (domain.propertiesDefinitions() == null) { + jpa.setPropertiesDefinitions(new LinkedHashSet<>()); + return; } - @Override - public Page findAll(Pageable pageable) { - return jpaEntityTemplateRepository.findAll(pageable).map(mapper::toDomain); + Map existingByName = existing.stream() + .collect(Collectors.toMap(PropertyDefinitionJpaEntity::getName, Function.identity())); + + Set updatedNames = domain.propertiesDefinitions().stream().map(p -> p.name()) + .collect(Collectors.toSet()); + + // Remove properties no longer present + existing.removeIf(p -> !updatedNames.contains(p.getName())); + + // Update existing or add new + for (var domProp : domain.propertiesDefinitions()) { + PropertyDefinitionJpaEntity ex = existingByName.get(domProp.name()); + if (ex != null) { + ex.setDescription(domProp.description()); + ex.setType(domProp.type()); + ex.setRequired(domProp.required()); + mergeRules(ex, domProp.rules()); + } else { + PropertyDefinitionJpaEntity newProp = PropertyDefinitionJpaEntity.builder().id(domProp.id()) + .name(domProp.name()).description(domProp.description()).type(domProp.type()) + .required(domProp.required()) + .rules(domProp.rules() != null ? toRulesJpa(domProp.rules()) : null).build(); + existing.add(newProp); + } } - @Override - public boolean existsByIdentifier(String identifier) { - return jpaEntityTemplateRepository.existsByIdentifier(identifier); - } + // Push the mutated copy back through the defensive setter + jpa.setPropertiesDefinitions(existing); + } - @Override - public boolean existsByName(String name) { - return jpaEntityTemplateRepository.existsByName(name); + private void mergeRules(PropertyDefinitionJpaEntity jpaProp, + com.decathlon.idp_core.domain.model.entity_template.PropertyRules domRules) { + if (domRules == null) { + // No rules in the updated domain – leave existing rules unchanged + return; } - - @Override - public EntityTemplate save(EntityTemplate entityTemplate) { - EntityTemplateJpaEntity jpaEntity; - if (entityTemplate.id() != null) { - // Update: fetch the managed JPA entity and merge in-place - jpaEntity = jpaEntityTemplateRepository.findById(entityTemplate.id()) - .orElseGet(() -> mapper.toJpa(entityTemplate)); - mergeIntoExisting(jpaEntity, entityTemplate); - } else { - jpaEntity = mapper.toJpa(entityTemplate); - } - return mapper.toDomain(jpaEntityTemplateRepository.save(jpaEntity)); + PropertyRulesJpaEntity ex = jpaProp.getRules(); + if (ex != null) { + // Update the managed entity in-place — Hibernate tracks the dirty fields + ex.setFormat(domRules.format()); + ex.setEnumValues( + domRules.enumValues() != null ? domRules.enumValues().toArray(new String[0]) : null); + ex.setRegex(domRules.regex()); + ex.setMaxLength(domRules.maxLength()); + ex.setMinLength(domRules.minLength()); + ex.setMaxValue(domRules.maxValue()); + ex.setMinValue(domRules.minValue()); + // Re-set the reference so Hibernate detects the association as dirty + jpaProp.setRules(ex); + } else { + jpaProp.setRules(toRulesJpa(domRules)); } - - @Override - public void deleteByIdentifier(String identifier) { - jpaEntityTemplateRepository.deleteByIdentifier(identifier); + } + + private PropertyRulesJpaEntity toRulesJpa( + com.decathlon.idp_core.domain.model.entity_template.PropertyRules d) { + return PropertyRulesJpaEntity.builder().id(d.id()).format(d.format()) + .enumValues(d.enumValues() != null ? d.enumValues().toArray(new String[0]) : null) + .regex(d.regex()).maxLength(d.maxLength()).minLength(d.minLength()).maxValue(d.maxValue()) + .minValue(d.minValue()).build(); + } + + private void mergeRelationDefinitions(EntityTemplateJpaEntity jpa, EntityTemplate domain) { + // Work on a mutable copy — getter returns an unmodifiable view + Set existing = new LinkedHashSet<>(jpa.getRelationsDefinitions()); + + if (domain.relationsDefinitions() == null) { + jpa.setRelationsDefinitions(new LinkedHashSet<>()); + return; } - // ── Merge helpers to update a managed JPA entity from domain values ── - - private void mergeIntoExisting(EntityTemplateJpaEntity jpa, EntityTemplate domain) { - jpa.setIdentifier(domain.identifier()); - jpa.setName(domain.name()); - jpa.setDescription(domain.description()); - mergePropertyDefinitions(jpa, domain); - mergeRelationDefinitions(jpa, domain); - } - - private void mergePropertyDefinitions(EntityTemplateJpaEntity jpa, EntityTemplate domain) { - // Work on a mutable copy — getter returns an unmodifiable view - Set existing = new LinkedHashSet<>(jpa.getPropertiesDefinitions()); - - if (domain.propertiesDefinitions() == null) { - jpa.setPropertiesDefinitions(new LinkedHashSet<>()); - return; - } - - Map existingByName = existing.stream() - .collect(Collectors.toMap(PropertyDefinitionJpaEntity::getName, Function.identity())); - - Set updatedNames = domain.propertiesDefinitions().stream() - .map(p -> p.name()) - .collect(Collectors.toSet()); - - // Remove properties no longer present - existing.removeIf(p -> !updatedNames.contains(p.getName())); - - // Update existing or add new - for (var domProp : domain.propertiesDefinitions()) { - PropertyDefinitionJpaEntity ex = existingByName.get(domProp.name()); - if (ex != null) { - ex.setDescription(domProp.description()); - ex.setType(domProp.type()); - ex.setRequired(domProp.required()); - mergeRules(ex, domProp.rules()); - } else { - PropertyDefinitionJpaEntity newProp = PropertyDefinitionJpaEntity.builder() - .id(domProp.id()) - .name(domProp.name()) - .description(domProp.description()) - .type(domProp.type()) - .required(domProp.required()) - .rules(domProp.rules() != null ? toRulesJpa(domProp.rules()) : null) - .build(); - existing.add(newProp); - } - } - - // Push the mutated copy back through the defensive setter - jpa.setPropertiesDefinitions(existing); - } - - private void mergeRules(PropertyDefinitionJpaEntity jpaProp, - com.decathlon.idp_core.domain.model.entity_template.PropertyRules domRules) { - if (domRules == null) { - // No rules in the updated domain – leave existing rules unchanged - return; - } - PropertyRulesJpaEntity ex = jpaProp.getRules(); - if (ex != null) { - // Update the managed entity in-place — Hibernate tracks the dirty fields - ex.setFormat(domRules.format()); - ex.setEnumValues(domRules.enumValues() != null ? domRules.enumValues().toArray(new String[0]) : null); - ex.setRegex(domRules.regex()); - ex.setMaxLength(domRules.maxLength()); - ex.setMinLength(domRules.minLength()); - ex.setMaxValue(domRules.maxValue()); - ex.setMinValue(domRules.minValue()); - // Re-set the reference so Hibernate detects the association as dirty - jpaProp.setRules(ex); - } else { - jpaProp.setRules(toRulesJpa(domRules)); - } - } - - private PropertyRulesJpaEntity toRulesJpa(com.decathlon.idp_core.domain.model.entity_template.PropertyRules d) { - return PropertyRulesJpaEntity.builder() - .id(d.id()).format(d.format()) - .enumValues(d.enumValues() != null ? d.enumValues().toArray(new String[0]) : null) - .regex(d.regex()).maxLength(d.maxLength()).minLength(d.minLength()) - .maxValue(d.maxValue()).minValue(d.minValue()).build(); + Map existingByName = existing.stream() + .collect(Collectors.toMap(RelationDefinitionJpaEntity::getName, Function.identity())); + + Set updatedNames = domain.relationsDefinitions().stream().map(r -> r.name()) + .collect(Collectors.toSet()); + + // Remove relations no longer present + existing.removeIf(r -> !updatedNames.contains(r.getName())); + + // Update existing or add new + for (var domRel : domain.relationsDefinitions()) { + RelationDefinitionJpaEntity ex = existingByName.get(domRel.name()); + if (ex != null) { + ex.setTargetTemplateIdentifier(domRel.targetTemplateIdentifier()); + ex.setRequired(domRel.required()); + ex.setToMany(domRel.toMany()); + } else { + RelationDefinitionJpaEntity newRel = RelationDefinitionJpaEntity.builder() + .name(domRel.name()).targetTemplateIdentifier(domRel.targetTemplateIdentifier()) + .required(domRel.required()).toMany(domRel.toMany()).build(); + existing.add(newRel); + } } - private void mergeRelationDefinitions(EntityTemplateJpaEntity jpa, EntityTemplate domain) { - // Work on a mutable copy — getter returns an unmodifiable view - Set existing = new LinkedHashSet<>(jpa.getRelationsDefinitions()); - - if (domain.relationsDefinitions() == null) { - jpa.setRelationsDefinitions(new LinkedHashSet<>()); - return; - } - - Map existingByName = existing.stream() - .collect(Collectors.toMap(RelationDefinitionJpaEntity::getName, Function.identity())); - - Set updatedNames = domain.relationsDefinitions().stream() - .map(r -> r.name()) - .collect(Collectors.toSet()); - - // Remove relations no longer present - existing.removeIf(r -> !updatedNames.contains(r.getName())); - - // Update existing or add new - for (var domRel : domain.relationsDefinitions()) { - RelationDefinitionJpaEntity ex = existingByName.get(domRel.name()); - if (ex != null) { - ex.setTargetTemplateIdentifier(domRel.targetTemplateIdentifier()); - ex.setRequired(domRel.required()); - ex.setToMany(domRel.toMany()); - } else { - RelationDefinitionJpaEntity newRel = RelationDefinitionJpaEntity.builder() - .name(domRel.name()) - .targetTemplateIdentifier(domRel.targetTemplateIdentifier()) - .required(domRel.required()) - .toMany(domRel.toMany()) - .build(); - existing.add(newRel); - } - } - - // Push the mutated copy back through the defensive setter - jpa.setRelationsDefinitions(existing); - } + // Push the mutated copy back through the defensive setter + jpa.setRelationsDefinitions(existing); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresRelationAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresRelationAdapter.java index 42728f13..53e5571e 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresRelationAdapter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresRelationAdapter.java @@ -14,11 +14,12 @@ @RequiredArgsConstructor public class PostgresRelationAdapter implements RelationRepositoryPort { - private final JpaRelationRepository jpaRelationRepository; + private final JpaRelationRepository jpaRelationRepository; - @Override - public List findRelationsSummariesByTargetEntityIdentifiers( - List targetEntityIdentifiers) { - return jpaRelationRepository.findRelationsSummariesByTargetEntityIdentifiers(targetEntityIdentifiers); - } + @Override + public List findRelationsSummariesByTargetEntityIdentifiers( + List targetEntityIdentifiers) { + return jpaRelationRepository + .findRelationsSummariesByTargetEntityIdentifiers(targetEntityIdentifiers); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java index cc7edac1..ba0e8ebb 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java @@ -15,15 +15,15 @@ @Mapper(componentModel = MappingConstants.ComponentModel.SPRING) public interface EntityPersistenceMapper { - Entity toDomain(EntityJpaEntity jpa); + Entity toDomain(EntityJpaEntity jpa); - EntityJpaEntity toJpa(Entity domain); + EntityJpaEntity toJpa(Entity domain); - Property toDomain(PropertyJpaEntity jpa); + Property toDomain(PropertyJpaEntity jpa); - PropertyJpaEntity toJpa(Property domain); + PropertyJpaEntity toJpa(Property domain); - Relation toDomain(RelationJpaEntity jpa); + Relation toDomain(RelationJpaEntity jpa); - RelationJpaEntity toJpa(Relation domain); + RelationJpaEntity toJpa(Relation domain); } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityTemplatePersistenceMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityTemplatePersistenceMapper.java index 33b1990b..a7c032e2 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityTemplatePersistenceMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityTemplatePersistenceMapper.java @@ -13,25 +13,22 @@ import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity_template.PropertyDefinitionJpaEntity; import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity_template.RelationDefinitionJpaEntity; -@Mapper( - componentModel = MappingConstants.ComponentModel.SPRING, - collectionMappingStrategy = CollectionMappingStrategy.ADDER_PREFERRED -) +@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, collectionMappingStrategy = CollectionMappingStrategy.ADDER_PREFERRED) public interface EntityTemplatePersistenceMapper { - EntityTemplate toDomain(EntityTemplateJpaEntity jpa); + EntityTemplate toDomain(EntityTemplateJpaEntity jpa); - EntityTemplateJpaEntity toJpa(EntityTemplate domain); + EntityTemplateJpaEntity toJpa(EntityTemplate domain); - PropertyDefinition toDomain(PropertyDefinitionJpaEntity jpa); + PropertyDefinition toDomain(PropertyDefinitionJpaEntity jpa); - PropertyDefinitionJpaEntity toJpa(PropertyDefinition domain); + PropertyDefinitionJpaEntity toJpa(PropertyDefinition domain); - PropertyRules toDomain(PropertyRulesJpaEntity jpa); + PropertyRules toDomain(PropertyRulesJpaEntity jpa); - PropertyRulesJpaEntity toJpa(PropertyRules domain); + PropertyRulesJpaEntity toJpa(PropertyRules domain); - RelationDefinition toDomain(RelationDefinitionJpaEntity jpa); + RelationDefinition toDomain(RelationDefinitionJpaEntity jpa); - RelationDefinitionJpaEntity toJpa(RelationDefinition domain); + RelationDefinitionJpaEntity toJpa(RelationDefinition domain); } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/EntityJpaEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/EntityJpaEntity.java index 848693df..72aea570 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/EntityJpaEntity.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/EntityJpaEntity.java @@ -14,6 +14,7 @@ import jakarta.persistence.OneToMany; import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -22,37 +23,30 @@ @jakarta.persistence.Entity @Data @Table(name = "entity", uniqueConstraints = { - @UniqueConstraint(columnNames = {"identifier", "template_identifier"}) -}) + @UniqueConstraint(columnNames = {"identifier", "template_identifier"})}) @Builder @NoArgsConstructor @AllArgsConstructor public class EntityJpaEntity { - @Id - @GeneratedValue(strategy = GenerationType.UUID) - private UUID id; + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; - @Column(name = "template_identifier") - private String templateIdentifier; + @Column(name = "template_identifier") + private String templateIdentifier; - private String name; + private String name; - private String identifier; + private String identifier; - @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) - @JoinTable(name = "entity_properties", - joinColumns = @JoinColumn(name = "entity_id"), - inverseJoinColumns = @JoinColumn(name = "property_id"), - uniqueConstraints = @UniqueConstraint(columnNames = {"entity_id", "property_id"}), - indexes = @Index(columnList = "entity_id")) - private List properties; + @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) + @JoinTable(name = "entity_properties", joinColumns = @JoinColumn(name = "entity_id"), inverseJoinColumns = @JoinColumn(name = "property_id"), uniqueConstraints = @UniqueConstraint(columnNames = { + "entity_id", "property_id"}), indexes = @Index(columnList = "entity_id")) + private List properties; - @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) - @JoinTable(name = "entity_relations", - joinColumns = @JoinColumn(name = "entity_id"), - inverseJoinColumns = @JoinColumn(name = "relation_id"), - uniqueConstraints = @UniqueConstraint(columnNames = {"entity_id", "relation_id"}), - indexes = @Index(columnList = "entity_id")) - private List relations; + @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) + @JoinTable(name = "entity_relations", joinColumns = @JoinColumn(name = "entity_id"), inverseJoinColumns = @JoinColumn(name = "relation_id"), uniqueConstraints = @UniqueConstraint(columnNames = { + "entity_id", "relation_id"}), indexes = @Index(columnList = "entity_id")) + private List relations; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/PropertyJpaEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/PropertyJpaEntity.java index a66be8de..961ac6d6 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/PropertyJpaEntity.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/PropertyJpaEntity.java @@ -8,6 +8,7 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -21,13 +22,13 @@ @AllArgsConstructor public class PropertyJpaEntity { - @Id - @GeneratedValue(strategy = GenerationType.UUID) - private UUID id; + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; - @Column(nullable = false) - private String name; + @Column(nullable = false) + private String name; - @Column - private String value; + @Column + private String value; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/PropertyRulesJpaEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/PropertyRulesJpaEntity.java index aae2d253..4e0663a7 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/PropertyRulesJpaEntity.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/PropertyRulesJpaEntity.java @@ -2,8 +2,6 @@ import java.util.UUID; -import com.decathlon.idp_core.domain.model.enums.PropertyFormat; - import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; @@ -11,6 +9,9 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; + +import com.decathlon.idp_core.domain.model.enums.PropertyFormat; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -24,17 +25,17 @@ @AllArgsConstructor public class PropertyRulesJpaEntity { - @Id - @GeneratedValue(strategy = GenerationType.UUID) - private UUID id; + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; - @Enumerated(EnumType.STRING) - private PropertyFormat format; + @Enumerated(EnumType.STRING) + private PropertyFormat format; - private String[] enumValues; - private String regex; - private Integer maxLength; - private Integer minLength; - private Integer maxValue; - private Integer minValue; + private String[] enumValues; + private String regex; + private Integer maxLength; + private Integer minLength; + private Integer maxValue; + private Integer minValue; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/RelationJpaEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/RelationJpaEntity.java index a4e9176f..d135f1f2 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/RelationJpaEntity.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/RelationJpaEntity.java @@ -13,6 +13,7 @@ import jakarta.persistence.Index; import jakarta.persistence.JoinColumn; import jakarta.persistence.Table; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -26,20 +27,18 @@ @AllArgsConstructor public class RelationJpaEntity { - @Id - @GeneratedValue(strategy = GenerationType.UUID) - private UUID id; + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; - @Column(nullable = false) - private String name; + @Column(nullable = false) + private String name; - @Column(name = "target_template_identifier", nullable = false) - private String targetTemplateIdentifier; + @Column(name = "target_template_identifier", nullable = false) + private String targetTemplateIdentifier; - @ElementCollection - @CollectionTable(name = "relation_target_entities", - joinColumns = @JoinColumn(name = "relation_id"), - indexes = @Index(columnList = "relation_id")) - @Column(name = "target_entity_identifier") - private List targetEntityIdentifiers; + @ElementCollection + @CollectionTable(name = "relation_target_entities", joinColumns = @JoinColumn(name = "relation_id"), indexes = @Index(columnList = "relation_id")) + @Column(name = "target_entity_identifier") + private List targetEntityIdentifiers; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_template/EntityTemplateJpaEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_template/EntityTemplateJpaEntity.java index ecff6d3d..9588fc23 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_template/EntityTemplateJpaEntity.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_template/EntityTemplateJpaEntity.java @@ -16,6 +16,7 @@ import jakarta.persistence.OneToMany; import jakarta.persistence.OrderBy; import jakarta.persistence.Table; + import lombok.AllArgsConstructor; import lombok.EqualsAndHashCode; import lombok.Getter; @@ -33,59 +34,59 @@ @AllArgsConstructor public class EntityTemplateJpaEntity { - @Id - @GeneratedValue(strategy = GenerationType.UUID) - private UUID id; + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; - @Column(nullable = false, unique = true) - private String identifier; + @Column(nullable = false, unique = true) + private String identifier; - @Column(nullable = false, unique = true) - private String name; + @Column(nullable = false, unique = true) + private String name; - private String description; + private String description; - @Getter(lombok.AccessLevel.NONE) - @Setter(lombok.AccessLevel.NONE) - @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) - @JoinTable(name = "entity_template_properties_definitions", - joinColumns = @JoinColumn(name = "entity_template_id"), - inverseJoinColumns = @JoinColumn(name = "properties_definitions_id")) - @OrderBy("name ASC") - private Set propertiesDefinitions = new LinkedHashSet<>(); + @Getter(lombok.AccessLevel.NONE) + @Setter(lombok.AccessLevel.NONE) + @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) + @JoinTable(name = "entity_template_properties_definitions", joinColumns = @JoinColumn(name = "entity_template_id"), inverseJoinColumns = @JoinColumn(name = "properties_definitions_id")) + @OrderBy("name ASC") + private Set propertiesDefinitions = new LinkedHashSet<>(); - @Getter(lombok.AccessLevel.NONE) - @Setter(lombok.AccessLevel.NONE) - @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) - @JoinTable(name = "entity_template_relations_definitions", - joinColumns = @JoinColumn(name = "entity_template_id"), - inverseJoinColumns = @JoinColumn(name = "relations_definitions_id")) - @OrderBy("name ASC") - private Set relationsDefinitions = new LinkedHashSet<>(); + @Getter(lombok.AccessLevel.NONE) + @Setter(lombok.AccessLevel.NONE) + @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) + @JoinTable(name = "entity_template_relations_definitions", joinColumns = @JoinColumn(name = "entity_template_id"), inverseJoinColumns = @JoinColumn(name = "relations_definitions_id")) + @OrderBy("name ASC") + private Set relationsDefinitions = new LinkedHashSet<>(); - /// Returns an unmodifiable view of the internal collection to prevent external mutation. - public Set getPropertiesDefinitions() { - return Collections.unmodifiableSet(propertiesDefinitions); - } + /// Returns an unmodifiable view of the internal collection to prevent external + /// mutation. + public Set getPropertiesDefinitions() { + return Collections.unmodifiableSet(propertiesDefinitions); + } - /// Defensive copy setter to prevent external mutation of the internal collection. - public void setPropertiesDefinitions(Set propertiesDefinitions) { - this.propertiesDefinitions.clear(); - if (propertiesDefinitions != null) { - this.propertiesDefinitions.addAll(propertiesDefinitions); - } + /// Defensive copy setter to prevent external mutation of the internal + /// collection. + public void setPropertiesDefinitions(Set propertiesDefinitions) { + this.propertiesDefinitions.clear(); + if (propertiesDefinitions != null) { + this.propertiesDefinitions.addAll(propertiesDefinitions); } + } - /// Returns an unmodifiable view of the internal collection to prevent external mutation. - public Set getRelationsDefinitions() { - return Collections.unmodifiableSet(relationsDefinitions); - } + /// Returns an unmodifiable view of the internal collection to prevent external + /// mutation. + public Set getRelationsDefinitions() { + return Collections.unmodifiableSet(relationsDefinitions); + } - /// Defensive copy setter to prevent external mutation of the internal collection. - public void setRelationsDefinitions(Set relationsDefinitions) { - this.relationsDefinitions.clear(); - if (relationsDefinitions != null) { - this.relationsDefinitions.addAll(relationsDefinitions); - } + /// Defensive copy setter to prevent external mutation of the internal + /// collection. + public void setRelationsDefinitions(Set relationsDefinitions) { + this.relationsDefinitions.clear(); + if (relationsDefinitions != null) { + this.relationsDefinitions.addAll(relationsDefinitions); } + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_template/PropertyDefinitionJpaEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_template/PropertyDefinitionJpaEntity.java index 5e65e34f..c11cfbb3 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_template/PropertyDefinitionJpaEntity.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_template/PropertyDefinitionJpaEntity.java @@ -1,9 +1,6 @@ package com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity_template; import java.util.UUID; -import com.decathlon.idp_core.domain.model.enums.PropertyType; -import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.PropertyRulesJpaEntity; - import jakarta.persistence.CascadeType; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -13,6 +10,10 @@ import jakarta.persistence.Id; import jakarta.persistence.OneToOne; import jakarta.persistence.Table; + +import com.decathlon.idp_core.domain.model.enums.PropertyType; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.PropertyRulesJpaEntity; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -28,20 +29,20 @@ @AllArgsConstructor public class PropertyDefinitionJpaEntity { - @Id - @GeneratedValue(strategy = GenerationType.UUID) - private UUID id; + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; - @EqualsAndHashCode.Include - private String name; - private String description; + @EqualsAndHashCode.Include + private String name; + private String description; - @Enumerated(EnumType.STRING) - private PropertyType type; + @Enumerated(EnumType.STRING) + private PropertyType type; - @Builder.Default - private boolean required = false; + @Builder.Default + private boolean required = false; - @OneToOne(cascade = CascadeType.ALL) - private PropertyRulesJpaEntity rules; + @OneToOne(cascade = CascadeType.ALL) + private PropertyRulesJpaEntity rules; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_template/RelationDefinitionJpaEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_template/RelationDefinitionJpaEntity.java index 219c3b97..6310fb2e 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_template/RelationDefinitionJpaEntity.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity_template/RelationDefinitionJpaEntity.java @@ -7,6 +7,7 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -22,18 +23,18 @@ @AllArgsConstructor public class RelationDefinitionJpaEntity { - @Id - @GeneratedValue(strategy = GenerationType.UUID) - private UUID id; + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; - @EqualsAndHashCode.Include - private String name; + @EqualsAndHashCode.Include + private String name; - private String targetTemplateIdentifier; + private String targetTemplateIdentifier; - @Builder.Default - private boolean required = false; + @Builder.Default + private boolean required = false; - @Builder.Default - private boolean toMany = false; + @Builder.Default + private boolean toMany = false; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java index 8be0fa74..d9cbcb9a 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java @@ -19,153 +19,158 @@ @Repository public interface JpaEntityRepository extends JpaRepository { - @Query("SELECT e.identifier AS identifier, e.name AS name, e.templateIdentifier AS templateIdentifier FROM EntityJpaEntity e WHERE e.identifier IN :identifiers") - List findByIdentifierIn(List identifiers); - - @Query("SELECT e.identifier AS identifier, e.name AS name, e.templateIdentifier AS templateIdentifier FROM EntityJpaEntity e JOIN e.relations r WHERE r.id IN :relationIds") - List findByRelationIdIn(List relationIds); - - Optional findByTemplateIdentifierAndIdentifier(String templateIdentifier, String identifier); - - Optional findByIdentifier(String identifier); - - Optional findByTemplateIdentifierAndName(String templateIdentifier, String name); - - Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable); - - @Modifying(clearAutomatically = true, flushAutomatically = true) - @Query(""" - DELETE FROM PropertyJpaEntity p - WHERE p IN ( - SELECT p2 FROM EntityJpaEntity e JOIN e.properties p2 - WHERE e.templateIdentifier = :templateIdentifier - AND p2.name IN :propertyNames - ) - """) - void deletePropertiesByTemplateIdentifierAndPropertyName( - @Param("templateIdentifier") String templateIdentifier, - @Param("propertyNames") Collection propertyNames); - - @Modifying(clearAutomatically = true, flushAutomatically = true) - @Query(""" - DELETE FROM RelationJpaEntity r - WHERE r IN ( - SELECT r2 FROM EntityJpaEntity e JOIN e.relations r2 - WHERE e.templateIdentifier = :templateIdentifier - AND r2.name IN :relationNames - ) - """) - void deleteRelationsByTemplateIdentifierAndRelationName( - @Param("templateIdentifier") String templateIdentifier, - @Param("relationNames") Collection relationNames); - - /// Batch fetch entities by identifiers with eager loading of relations and properties. - /// Uses two separate queries to avoid Hibernate's MultipleBagFetchException. - /// First fetches entities with relations, then fetches properties separately. - @Query("SELECT DISTINCT e FROM EntityJpaEntity e LEFT JOIN FETCH e.relations WHERE e.identifier IN :identifiers") - List findAllByIdentifierInWithRelations(@Param("identifiers") Collection identifiers); - - /// Fetch properties for entities that were already loaded. - /// This is called after findAllByIdentifierInWithRelations to complete the entity graph. - @Query("SELECT DISTINCT e FROM EntityJpaEntity e LEFT JOIN FETCH e.properties WHERE e.identifier IN :identifiers") - List findAllByIdentifierInWithProperties(@Param("identifiers") Collection identifiers); - - @Query(value = """ - WITH RECURSIVE - -- Traverse outbound relations (this entity -> targets) - outbound_graph(identifier, template_identifier, depth) AS ( - SELECT e.identifier, e.template_identifier, 0 - FROM entity e - WHERE e.identifier = :entityIdentifier - AND e.template_identifier = :templateIdentifier - - UNION ALL - - SELECT e2.identifier, e2.template_identifier, og.depth + 1 - FROM outbound_graph og - JOIN entity e ON e.identifier = og.identifier AND e.template_identifier = og.template_identifier - JOIN entity_relations er ON er.entity_id = e.id - JOIN relation r ON r.id = er.relation_id - JOIN relation_target_entities rte ON rte.relation_id = r.id - JOIN entity e2 ON e2.identifier = rte.target_entity_identifier - WHERE og.depth < :depth - ), - -- Traverse inbound relations (sources -> this entity as target) - inbound_graph(identifier, template_identifier, depth) AS ( - SELECT e.identifier, e.template_identifier, 0 - FROM entity e - WHERE e.identifier = :entityIdentifier - AND e.template_identifier = :templateIdentifier - - UNION ALL - - SELECT e2.identifier, e2.template_identifier, ig.depth + 1 - FROM inbound_graph ig - JOIN entity e ON e.identifier = ig.identifier AND e.template_identifier = ig.template_identifier - JOIN relation_target_entities rte ON rte.target_entity_identifier = e.identifier - JOIN relation r ON r.id = rte.relation_id - JOIN entity_relations er ON er.relation_id = r.id - JOIN entity e2 ON e2.id = er.entity_id - WHERE ig.depth < :depth - ) - SELECT DISTINCT identifier, template_identifier FROM outbound_graph - UNION - SELECT DISTINCT identifier, template_identifier FROM inbound_graph - """, nativeQuery = true) - List findEntityGraphIdentifiers( - @Param("templateIdentifier") String templateIdentifier, - @Param("entityIdentifier") String entityIdentifier, - @Param("depth") int depth); - - /// Variant of [findEntityGraphIdentifiers] that restricts traversal to the given relation names. - /// When the list is empty, all relation names are followed (no filter). - /// The filter is applied inside both the outbound and inbound recursive CTE steps so that only - /// entities reachable through the specified relations are returned, keeping the result set lean. - @Query(value = """ - WITH RECURSIVE - outbound_graph(identifier, template_identifier, depth) AS ( - SELECT e.identifier, e.template_identifier, 0 - FROM entity e - WHERE e.identifier = :entityIdentifier - AND e.template_identifier = :templateIdentifier - - UNION ALL - - SELECT e2.identifier, e2.template_identifier, og.depth + 1 - FROM outbound_graph og - JOIN entity e ON e.identifier = og.identifier AND e.template_identifier = og.template_identifier - JOIN entity_relations er ON er.entity_id = e.id - JOIN relation r ON r.id = er.relation_id - JOIN relation_target_entities rte ON rte.relation_id = r.id - JOIN entity e2 ON e2.identifier = rte.target_entity_identifier - WHERE og.depth < :depth - AND r.name IN :relationNames - ), - inbound_graph(identifier, template_identifier, depth) AS ( - SELECT e.identifier, e.template_identifier, 0 - FROM entity e - WHERE e.identifier = :entityIdentifier - AND e.template_identifier = :templateIdentifier - - UNION ALL - - SELECT e2.identifier, e2.template_identifier, ig.depth + 1 - FROM inbound_graph ig - JOIN entity e ON e.identifier = ig.identifier AND e.template_identifier = ig.template_identifier - JOIN relation_target_entities rte ON rte.target_entity_identifier = e.identifier - JOIN relation r ON r.id = rte.relation_id - JOIN entity_relations er ON er.relation_id = r.id - JOIN entity e2 ON e2.id = er.entity_id - WHERE ig.depth < :depth - AND r.name IN :relationNames - ) - SELECT DISTINCT identifier, template_identifier FROM outbound_graph - UNION - SELECT DISTINCT identifier, template_identifier FROM inbound_graph - """, nativeQuery = true) - List findEntityGraphIdentifiersFilteredByRelations( - @Param("templateIdentifier") String templateIdentifier, - @Param("entityIdentifier") String entityIdentifier, - @Param("depth") int depth, - @Param("relationNames") Collection relationNames); + @Query("SELECT e.identifier AS identifier, e.name AS name, e.templateIdentifier AS templateIdentifier FROM EntityJpaEntity e WHERE e.identifier IN :identifiers") + List findByIdentifierIn(List identifiers); + + @Query("SELECT e.identifier AS identifier, e.name AS name, e.templateIdentifier AS templateIdentifier FROM EntityJpaEntity e JOIN e.relations r WHERE r.id IN :relationIds") + List findByRelationIdIn(List relationIds); + + Optional findByTemplateIdentifierAndIdentifier(String templateIdentifier, + String identifier); + + Optional findByIdentifier(String identifier); + + Optional findByTemplateIdentifierAndName(String templateIdentifier, String name); + + Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + DELETE FROM PropertyJpaEntity p + WHERE p IN ( + SELECT p2 FROM EntityJpaEntity e JOIN e.properties p2 + WHERE e.templateIdentifier = :templateIdentifier + AND p2.name IN :propertyNames + ) + """) + void deletePropertiesByTemplateIdentifierAndPropertyName( + @Param("templateIdentifier") String templateIdentifier, + @Param("propertyNames") Collection propertyNames); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + DELETE FROM RelationJpaEntity r + WHERE r IN ( + SELECT r2 FROM EntityJpaEntity e JOIN e.relations r2 + WHERE e.templateIdentifier = :templateIdentifier + AND r2.name IN :relationNames + ) + """) + void deleteRelationsByTemplateIdentifierAndRelationName( + @Param("templateIdentifier") String templateIdentifier, + @Param("relationNames") Collection relationNames); + + /// Batch fetch entities by identifiers with eager loading of relations and + /// properties. + /// Uses two separate queries to avoid Hibernate's MultipleBagFetchException. + /// First fetches entities with relations, then fetches properties separately. + @Query("SELECT DISTINCT e FROM EntityJpaEntity e LEFT JOIN FETCH e.relations WHERE e.identifier IN :identifiers") + List findAllByIdentifierInWithRelations( + @Param("identifiers") Collection identifiers); + + /// Fetch properties for entities that were already loaded. + /// This is called after findAllByIdentifierInWithRelations to complete the + /// entity graph. + @Query("SELECT DISTINCT e FROM EntityJpaEntity e LEFT JOIN FETCH e.properties WHERE e.identifier IN :identifiers") + List findAllByIdentifierInWithProperties( + @Param("identifiers") Collection identifiers); + + @Query(value = """ + WITH RECURSIVE + -- Traverse outbound relations (this entity -> targets) + outbound_graph(identifier, template_identifier, depth) AS ( + SELECT e.identifier, e.template_identifier, 0 + FROM entity e + WHERE e.identifier = :entityIdentifier + AND e.template_identifier = :templateIdentifier + + UNION ALL + + SELECT e2.identifier, e2.template_identifier, og.depth + 1 + FROM outbound_graph og + JOIN entity e ON e.identifier = og.identifier AND e.template_identifier = og.template_identifier + JOIN entity_relations er ON er.entity_id = e.id + JOIN relation r ON r.id = er.relation_id + JOIN relation_target_entities rte ON rte.relation_id = r.id + JOIN entity e2 ON e2.identifier = rte.target_entity_identifier + WHERE og.depth < :depth + ), + -- Traverse inbound relations (sources -> this entity as target) + inbound_graph(identifier, template_identifier, depth) AS ( + SELECT e.identifier, e.template_identifier, 0 + FROM entity e + WHERE e.identifier = :entityIdentifier + AND e.template_identifier = :templateIdentifier + + UNION ALL + + SELECT e2.identifier, e2.template_identifier, ig.depth + 1 + FROM inbound_graph ig + JOIN entity e ON e.identifier = ig.identifier AND e.template_identifier = ig.template_identifier + JOIN relation_target_entities rte ON rte.target_entity_identifier = e.identifier + JOIN relation r ON r.id = rte.relation_id + JOIN entity_relations er ON er.relation_id = r.id + JOIN entity e2 ON e2.id = er.entity_id + WHERE ig.depth < :depth + ) + SELECT DISTINCT identifier, template_identifier FROM outbound_graph + UNION + SELECT DISTINCT identifier, template_identifier FROM inbound_graph + """, nativeQuery = true) + List findEntityGraphIdentifiers(@Param("templateIdentifier") String templateIdentifier, + @Param("entityIdentifier") String entityIdentifier, @Param("depth") int depth); + + /// Variant of [findEntityGraphIdentifiers] that restricts traversal to the + /// given relation names. + /// When the list is empty, all relation names are followed (no filter). + /// The filter is applied inside both the outbound and inbound recursive CTE + /// steps so that only + /// entities reachable through the specified relations are returned, keeping the + /// result set lean. + @Query(value = """ + WITH RECURSIVE + outbound_graph(identifier, template_identifier, depth) AS ( + SELECT e.identifier, e.template_identifier, 0 + FROM entity e + WHERE e.identifier = :entityIdentifier + AND e.template_identifier = :templateIdentifier + + UNION ALL + + SELECT e2.identifier, e2.template_identifier, og.depth + 1 + FROM outbound_graph og + JOIN entity e ON e.identifier = og.identifier AND e.template_identifier = og.template_identifier + JOIN entity_relations er ON er.entity_id = e.id + JOIN relation r ON r.id = er.relation_id + JOIN relation_target_entities rte ON rte.relation_id = r.id + JOIN entity e2 ON e2.identifier = rte.target_entity_identifier + WHERE og.depth < :depth + AND r.name IN :relationNames + ), + inbound_graph(identifier, template_identifier, depth) AS ( + SELECT e.identifier, e.template_identifier, 0 + FROM entity e + WHERE e.identifier = :entityIdentifier + AND e.template_identifier = :templateIdentifier + + UNION ALL + + SELECT e2.identifier, e2.template_identifier, ig.depth + 1 + FROM inbound_graph ig + JOIN entity e ON e.identifier = ig.identifier AND e.template_identifier = ig.template_identifier + JOIN relation_target_entities rte ON rte.target_entity_identifier = e.identifier + JOIN relation r ON r.id = rte.relation_id + JOIN entity_relations er ON er.relation_id = r.id + JOIN entity e2 ON e2.id = er.entity_id + WHERE ig.depth < :depth + AND r.name IN :relationNames + ) + SELECT DISTINCT identifier, template_identifier FROM outbound_graph + UNION + SELECT DISTINCT identifier, template_identifier FROM inbound_graph + """, nativeQuery = true) + List findEntityGraphIdentifiersFilteredByRelations( + @Param("templateIdentifier") String templateIdentifier, + @Param("entityIdentifier") String entityIdentifier, @Param("depth") int depth, + @Param("relationNames") Collection relationNames); } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityTemplateRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityTemplateRepository.java index 3fe1e70b..21b4218e 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityTemplateRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityTemplateRepository.java @@ -15,22 +15,24 @@ @Repository public interface JpaEntityTemplateRepository extends JpaRepository { - @EntityGraph(attributePaths = {"propertiesDefinitions", "propertiesDefinitions.rules", "relationsDefinitions"}) - Optional findByIdentifier(String templateIdentifier); + @EntityGraph(attributePaths = {"propertiesDefinitions", "propertiesDefinitions.rules", + "relationsDefinitions"}) + Optional findByIdentifier(String templateIdentifier); - @Override - @EntityGraph(attributePaths = {"propertiesDefinitions", "propertiesDefinitions.rules", "relationsDefinitions"}) - Optional findById(UUID id); + @Override + @EntityGraph(attributePaths = {"propertiesDefinitions", "propertiesDefinitions.rules", + "relationsDefinitions"}) + Optional findById(UUID id); - @Override - @EntityGraph(attributePaths = {"propertiesDefinitions", "propertiesDefinitions.rules", "relationsDefinitions"}) - Page findAll(Pageable pageable); + @Override + @EntityGraph(attributePaths = {"propertiesDefinitions", "propertiesDefinitions.rules", + "relationsDefinitions"}) + Page findAll(Pageable pageable); + boolean existsByIdentifier(String identifier); - boolean existsByIdentifier(String identifier); + boolean existsByName(String name); - boolean existsByName(String name); - - @Transactional - void deleteByIdentifier(String identifier); + @Transactional + void deleteByIdentifier(String identifier); } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaRelationRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaRelationRepository.java index 57c0c666..4e642d6a 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaRelationRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaRelationRepository.java @@ -14,15 +14,15 @@ @Repository public interface JpaRelationRepository extends JpaRepository { - @Query(""" - SELECT new com.decathlon.idp_core.domain.model.entity.RelationAsTargetSummary( - tei, r.name, e.identifier, e.name - ) - FROM EntityJpaEntity e - JOIN e.relations r - JOIN r.targetEntityIdentifiers tei - WHERE tei IN :targetEntityIdentifiers - """) - List findRelationsSummariesByTargetEntityIdentifiers( - @Param("targetEntityIdentifiers") List targetEntityIdentifiers); + @Query(""" + SELECT new com.decathlon.idp_core.domain.model.entity.RelationAsTargetSummary( + tei, r.name, e.identifier, e.name + ) + FROM EntityJpaEntity e + JOIN e.relations r + JOIN r.targetEntityIdentifiers tei + WHERE tei IN :targetEntityIdentifiers + """) + List findRelationsSummariesByTargetEntityIdentifiers( + @Param("targetEntityIdentifiers") List targetEntityIdentifiers); } diff --git a/src/test/java/com/decathlon/idp_core/AbstractIntegrationTest.java b/src/test/java/com/decathlon/idp_core/AbstractIntegrationTest.java index a0a35cfc..ff890dd7 100644 --- a/src/test/java/com/decathlon/idp_core/AbstractIntegrationTest.java +++ b/src/test/java/com/decathlon/idp_core/AbstractIntegrationTest.java @@ -66,188 +66,187 @@ @TestClassOrder(ClassOrderer.ClassName.class) public abstract class AbstractIntegrationTest { - @Autowired - public MockMvc mockMvc; + @Autowired + public MockMvc mockMvc; - public final ObjectMapper objectMapper; + public final ObjectMapper objectMapper; - public final ObjectMapper userEventObjectMapper; + public final ObjectMapper userEventObjectMapper; - public static ClientAndServer clientAndServer; + public static ClientAndServer clientAndServer; - public static MockServerClient mockServerClient; + public static MockServerClient mockServerClient; - public static AtomicBoolean initToDo = new AtomicBoolean(true); + public static AtomicBoolean initToDo = new AtomicBoolean(true); - protected AbstractIntegrationTest() { - this.objectMapper = new ObjectMapper(); - objectMapper.registerModule(new JavaTimeModule()); - this.userEventObjectMapper = new ObjectMapper(); - userEventObjectMapper.registerModule(new JavaTimeModule()); - userEventObjectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); - userEventObjectMapper.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE); - } - - @Container - @SuppressWarnings("rawtypes") - private static final JdbcDatabaseContainer postgres = new PostgreSQLContainer("postgres:18-alpine") - .withDatabaseName("idp-core").withUsername("idp-core").withPassword("idp-core"); - - @DynamicPropertySource - static void postgresProperties(DynamicPropertyRegistry registry) { - postgres.start(); - LockSupport.parkNanos(TimeUnit.SECONDS.toNanos(5)); // wait for container to be ready - - registry.add("spring.datasource.url", postgres::getJdbcUrl); - registry.add("spring.datasource.username", postgres::getUsername); - registry.add("spring.datasource.password", postgres::getPassword); - - } - - public void startMockServer() { - if (mockServerClient == null) { - clientAndServer = startClientAndServer(8888); - mockServerClient = new MockServerClient("localhost", 8888); - } - } - - @SafeVarargs - public final void mockApiCall(String path, HttpStatus status, Pair... queryParameterList) { - mockApiCall(GET, path, status, null, queryParameterList); - } - - @SafeVarargs - public final void mockApiCall(String path, Object response, Pair... queryParameterList) { - mockApiCall(GET, path, OK, response, queryParameterList); - } + protected AbstractIntegrationTest() { + this.objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + this.userEventObjectMapper = new ObjectMapper(); + userEventObjectMapper.registerModule(new JavaTimeModule()); + userEventObjectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); + userEventObjectMapper.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE); + } - @SafeVarargs - public final void mockApiCall(HttpMethod httpMethod, String path, Object response, - Pair... queryParameterList) { - mockApiCall(httpMethod, path, OK, response, queryParameterList); - } - - @SafeVarargs - public final void mockApiCall(HttpMethod httpMethod, String path, HttpStatus status, Object response, - Pair... queryParameterList) { - startMockServer(); - HttpRequest requestDefinition = getRequestDefinition(httpMethod, path, queryParameterList); - mockServerClient.clear(requestDefinition); - mockServerClient.when(requestDefinition) - .respond(HttpResponse.response() - .withStatusCode(status.value()) - .withHeaders(new Header(CONTENT_TYPE, APPLICATION_JSON_VALUE)) - .withBody(response != null ? writeValueAsString(response) : null)); - } + @Container + @SuppressWarnings("rawtypes") + private static final JdbcDatabaseContainer postgres = new PostgreSQLContainer( + "postgres:18-alpine").withDatabaseName("idp-core").withUsername("idp-core") + .withPassword("idp-core"); - @SafeVarargs - private static HttpRequest getRequestDefinition(HttpMethod httpMethod, String path, - Pair... queryParameterList) { - HttpRequest requestDefinition = request().withMethod(httpMethod.name()).withPath(path); + @DynamicPropertySource + static void postgresProperties(DynamicPropertyRegistry registry) { + postgres.start(); + LockSupport.parkNanos(TimeUnit.SECONDS.toNanos(5)); // wait for container to be ready - if (queryParameterList != null) { - for (Pair queryParameter : queryParameterList) { - requestDefinition.withQueryStringParameter(queryParameter.getKey(), queryParameter.getValue()); - } - } + registry.add("spring.datasource.url", postgres::getJdbcUrl); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); - return requestDefinition; - } + } - @SneakyThrows - public static String getJsonTestFileContent(String path) { - try (var inputStream = new ClassPathResource(path).getInputStream()) { - return IOUtils.toString(inputStream, UTF_8); - } + public void startMockServer() { + if (mockServerClient == null) { + clientAndServer = startClientAndServer(8888); + mockServerClient = new MockServerClient("localhost", 8888); } - - @SneakyThrows - public String writeValueAsString(Object object) { - if (object instanceof String) { - return (String) object; - } - return objectMapper.writeValueAsString(object); + } + + @SafeVarargs + public final void mockApiCall(String path, HttpStatus status, + Pair... queryParameterList) { + mockApiCall(GET, path, status, null, queryParameterList); + } + + @SafeVarargs + public final void mockApiCall(String path, Object response, + Pair... queryParameterList) { + mockApiCall(GET, path, OK, response, queryParameterList); + } + + @SafeVarargs + public final void mockApiCall(HttpMethod httpMethod, String path, Object response, + Pair... queryParameterList) { + mockApiCall(httpMethod, path, OK, response, queryParameterList); + } + + @SafeVarargs + public final void mockApiCall(HttpMethod httpMethod, String path, HttpStatus status, + Object response, Pair... queryParameterList) { + startMockServer(); + HttpRequest requestDefinition = getRequestDefinition(httpMethod, path, queryParameterList); + mockServerClient.clear(requestDefinition); + mockServerClient.when(requestDefinition) + .respond(HttpResponse.response().withStatusCode(status.value()) + .withHeaders(new Header(CONTENT_TYPE, APPLICATION_JSON_VALUE)) + .withBody(response != null ? writeValueAsString(response) : null)); + } + + @SafeVarargs + private static HttpRequest getRequestDefinition(HttpMethod httpMethod, String path, + Pair... queryParameterList) { + HttpRequest requestDefinition = request().withMethod(httpMethod.name()).withPath(path); + + if (queryParameterList != null) { + for (Pair queryParameter : queryParameterList) { + requestDefinition.withQueryStringParameter(queryParameter.getKey(), + queryParameter.getValue()); + } } - /// Helper method to perform a POST request and validate that it returns a - /// BAD_REQUEST response. - /// - /// @param path the URL path to send the POST request to - /// @param jsonBodyfilePath the file path containing the JSON content to be sent - /// in the request body - /// @param errorDescription the expected error description that should be - /// returned in the response - /// @throws Exception if an error occurs during the mock MVC request execution - public MvcResult postBadRequestAndAssertEquals(String path, String jsonBodyfilePath, String errorDescription) - throws Exception { - return mockMvc.perform(MockMvcRequestBuilders.post(path) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent(jsonBodyfilePath))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("BAD_REQUEST")) - .andExpect(jsonPath("$.error_description").value(errorDescription)) - .andReturn(); + return requestDefinition; + } + @SneakyThrows + public static String getJsonTestFileContent(String path) { + try (var inputStream = new ClassPathResource(path).getInputStream()) { + return IOUtils.toString(inputStream, UTF_8); } + } - /// Helper method to perform a POST request and validate that it returns a - /// BAD_REQUEST response and that the error description contains the expected text. - /// - /// @param path the URL path to send the POST request to - /// @param jsonBodyfilePath the file path containing the JSON content to be sent - /// in the request body - /// @param errorDescription the text that should be contained in the error description - /// @throws Exception if an error occurs during the mock MVC request execution - public MvcResult postBadRequestAndAssertContains(String path, String jsonBodyfilePath, String errorDescription) - throws Exception { - return mockMvc.perform(MockMvcRequestBuilders.post(path) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent(jsonBodyfilePath))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("BAD_REQUEST")) - .andExpect(jsonPath("$.error_description").value(containsString(errorDescription))) - .andReturn(); - + @SneakyThrows + public String writeValueAsString(Object object) { + if (object instanceof String) { + return (String) object; } - - /// Helper method to perform a POST request and validate that it returns a - /// CONFLICT response and that the error description contains the expected text. - /// - /// @param path the URL path to send the POST request to - /// @param jsonBodyfilePath the file path containing the JSON content to be sent - /// in the request body - /// @param errorDescription the text that should be contained in the error description - /// @throws Exception if an error occurs during the mock MVC request execution - public MvcResult postConflictAndAssertContains(String path, String jsonBodyfilePath, String errorDescription) - throws Exception { - return mockMvc.perform(MockMvcRequestBuilders.post(path) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent(jsonBodyfilePath))) - .andExpect(status().isConflict()) - .andExpect(jsonPath("$.error").value("CONFLICT")) - .andExpect(jsonPath("$.error_description").value(containsString(errorDescription))) - .andReturn(); - + return objectMapper.writeValueAsString(object); + } + + /// Helper method to perform a POST request and validate that it returns a + /// BAD_REQUEST response. + /// + /// @param path the URL path to send the POST request to + /// @param jsonBodyfilePath the file path containing the JSON content to be sent + /// in the request body + /// @param errorDescription the expected error description that should be + /// returned in the response + /// @throws Exception if an error occurs during the mock MVC request execution + public MvcResult postBadRequestAndAssertEquals(String path, String jsonBodyfilePath, + String errorDescription) throws Exception { + return mockMvc + .perform( + MockMvcRequestBuilders.post(path).contentType(APPLICATION_JSON).accept(APPLICATION_JSON) + .with(csrf()).content(getJsonTestFileContent(jsonBodyfilePath))) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(errorDescription)).andReturn(); + + } + + /// Helper method to perform a POST request and validate that it returns a + /// BAD_REQUEST response and that the error description contains the expected + /// text. + /// + /// @param path the URL path to send the POST request to + /// @param jsonBodyfilePath the file path containing the JSON content to be sent + /// in the request body + /// @param errorDescription the text that should be contained in the error + /// description + /// @throws Exception if an error occurs during the mock MVC request execution + public MvcResult postBadRequestAndAssertContains(String path, String jsonBodyfilePath, + String errorDescription) throws Exception { + return mockMvc + .perform( + MockMvcRequestBuilders.post(path).contentType(APPLICATION_JSON).accept(APPLICATION_JSON) + .with(csrf()).content(getJsonTestFileContent(jsonBodyfilePath))) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(containsString(errorDescription))) + .andReturn(); + + } + + /// Helper method to perform a POST request and validate that it returns a + /// CONFLICT response and that the error description contains the expected text. + /// + /// @param path the URL path to send the POST request to + /// @param jsonBodyfilePath the file path containing the JSON content to be sent + /// in the request body + /// @param errorDescription the text that should be contained in the error + /// description + /// @throws Exception if an error occurs during the mock MVC request execution + public MvcResult postConflictAndAssertContains(String path, String jsonBodyfilePath, + String errorDescription) throws Exception { + return mockMvc + .perform( + MockMvcRequestBuilders.post(path).contentType(APPLICATION_JSON).accept(APPLICATION_JSON) + .with(csrf()).content(getJsonTestFileContent(jsonBodyfilePath))) + .andExpect(status().isConflict()).andExpect(jsonPath("$.error").value("CONFLICT")) + .andExpect(jsonPath("$.error_description").value(containsString(errorDescription))) + .andReturn(); + + } + + /// Helper method to perform a PUT request and validate that it returns a + @TestConfiguration + public static class TestBeanConfiguration { + + WebClient webClient = WebClient.builder().baseUrl("http://localhost:8888").build(); + + @Bean + @Primary + JwtDecoder jwtDecoder() { + return mock(JwtDecoder.class); } - /// Helper method to perform a PUT request and validate that it returns a - @TestConfiguration - public static class TestBeanConfiguration { - - WebClient webClient = WebClient.builder().baseUrl("http://localhost:8888").build(); - - @Bean - @Primary - JwtDecoder jwtDecoder() { - return mock(JwtDecoder.class); - } - - } + } } diff --git a/src/test/java/com/decathlon/idp_core/TestSecurityConfiguration.java b/src/test/java/com/decathlon/idp_core/TestSecurityConfiguration.java index bc8fc081..39831934 100644 --- a/src/test/java/com/decathlon/idp_core/TestSecurityConfiguration.java +++ b/src/test/java/com/decathlon/idp_core/TestSecurityConfiguration.java @@ -14,7 +14,7 @@ /// This configuration ensures all requests are permitted without authentication. @TestConfiguration @EnableWebSecurity -@Profile({ "test" }) +@Profile({"test"}) public class TestSecurityConfiguration { /// Configures a permissive security filter chain for testing. /// @@ -22,7 +22,8 @@ public class TestSecurityConfiguration { /// - CSRF protection disabled (not needed for API tests) /// - All requests permitted without authentication /// - /// **Why permissive security:** Test scenarios focus on business logic validation + /// **Why permissive security:** Test scenarios focus on business logic + /// validation /// rather than security enforcement, requiring unrestricted access. @Bean public SecurityFilterChain securityFilterChainTest(HttpSecurity http) throws Exception { 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 22b4cb9c..fa92ecf4 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java @@ -38,127 +38,128 @@ @DisplayName("EntityService Tests") class EntityServiceTest { - @Mock - private EntityRepositoryPort entityRepository; + @Mock + private EntityRepositoryPort entityRepository; + + @Mock + private EntityValidationService entityValidationService; + + @Mock + private EntityTemplateValidationService entityTemplateValidationService; + + @Mock + private EntityTemplateService entityTemplateService; + + @InjectMocks + private EntityService entityService; - - @Mock - private EntityValidationService entityValidationService; - - @Mock - private EntityTemplateValidationService entityTemplateValidationService; - - @Mock - private EntityTemplateService entityTemplateService; - - @InjectMocks - private EntityService entityService; - - @Test - @DisplayName("Should return entities page by template identifier") - void shouldReturnEntitiesByTemplateIdentifier() { - var pageable = Pageable.ofSize(10); - var entity = entity("template-a", "entity-a", "Entity A"); - var page = new PageImpl<>(List.of(entity)); - - when(entityRepository.findByTemplateIdentifier("template-a", pageable)).thenReturn(page); - - var result = entityService.getEntitiesByTemplateIdentifier(pageable, "template-a"); - - assertSame(page, result); - verify(entityRepository).findByTemplateIdentifier("template-a", pageable); - } - - @Test - @DisplayName("Should return entity summaries by identifiers") - void shouldReturnEntitySummariesByIdentifiers() { - var summaries = List.of(new EntitySummary("service-a", "Service A", "web-service")); - when(entityRepository.findByIdentifierIn(List.of("service-a"))).thenReturn(summaries); - - var result = entityService.getEntitiesSummariesByIndentifiers(List.of("service-a")); - - assertEquals(summaries, result); - verify(entityRepository).findByIdentifierIn(List.of("service-a")); - } - - @Test - @DisplayName("Should return entity by template and identifier") - void shouldReturnEntityByTemplateAndIdentifier() { - var entity = entity("web-service", "catalog-api", "Catalog API"); - when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) - .thenReturn(Optional.of(entity)); - - var result = entityService.getEntityByTemplateIdentifierAndIdentifier("web-service", "catalog-api"); - - assertSame(entity, result); - verify(entityTemplateValidationService).validateTemplateExists("web-service"); - verify(entityRepository).findByTemplateIdentifierAndIdentifier("web-service", "catalog-api"); - } - - @Test - @DisplayName("Should throw when entity is not found for template") - void shouldThrowWhenEntityNotFoundByTemplateAndIdentifier() { - when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "missing-entity")) - .thenReturn(Optional.empty()); - - assertThrows(EntityNotFoundException.class, - () -> entityService.getEntityByTemplateIdentifierAndIdentifier("web-service", "missing-entity")); - } - - @Test - @DisplayName("Should create entity when validations pass") - void shouldCreateEntityWhenValidationsPass() { - var entity = entity("web-service", "catalog-api", "Catalog API"); - var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", List.of(), - List.of()); - when(entityTemplateService.getEntityTemplateByIdentifier("web-service")).thenReturn(template); - when(entityRepository.save(entity)).thenReturn(entity); - - var result = entityService.createEntity(entity); - - assertSame(entity, result); - - InOrder inOrder = inOrder(entityTemplateService, entityValidationService, entityRepository); - inOrder.verify(entityTemplateService).getEntityTemplateByIdentifier("web-service"); - inOrder.verify(entityValidationService).validateForCreation(entity, template); - inOrder.verify(entityRepository).save(entity); - verifyNoInteractions(entityTemplateValidationService); - } - - @Test - @DisplayName("Should not save when entity already exists") - void shouldNotSaveWhenEntityAlreadyExists() { - var entity = entity("web-service", "catalog-api", "Catalog API"); - var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", List.of(), - List.of()); - var alreadyExists = new EntityAlreadyExistsException("web-service", "catalog-api"); - - when(entityTemplateService.getEntityTemplateByIdentifier("web-service")).thenReturn(template); - doThrow(alreadyExists).when(entityValidationService).validateForCreation(entity, template); - - assertThrows(EntityAlreadyExistsException.class, () -> entityService.createEntity(entity)); - - verify(entityTemplateService).getEntityTemplateByIdentifier("web-service"); - verify(entityValidationService).validateForCreation(entity, template); - verifyNoMoreInteractions(entityRepository); - } - - @Test - @DisplayName("Should stop immediately when template does not exist") - void shouldStopWhenTemplateDoesNotExistOnCreate() { - var entity = entity("missing-template", "catalog-api", "Catalog API"); - - when(entityTemplateService.getEntityTemplateByIdentifier("missing-template")) - .thenThrow(new EntityTemplateNotFoundException("identifier", "missing-template")); - - assertThrows(EntityTemplateNotFoundException.class, () -> entityService.createEntity(entity)); - - verify(entityTemplateService).getEntityTemplateByIdentifier("missing-template"); - verifyNoInteractions(entityValidationService); - verifyNoMoreInteractions(entityRepository); - } - - private Entity entity(String templateIdentifier, String identifier, String name) { - return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, List.of(), List.of()); - } + @Test + @DisplayName("Should return entities page by template identifier") + void shouldReturnEntitiesByTemplateIdentifier() { + var pageable = Pageable.ofSize(10); + var entity = entity("template-a", "entity-a", "Entity A"); + var page = new PageImpl<>(List.of(entity)); + + when(entityRepository.findByTemplateIdentifier("template-a", pageable)).thenReturn(page); + + var result = entityService.getEntitiesByTemplateIdentifier(pageable, "template-a"); + + assertSame(page, result); + verify(entityRepository).findByTemplateIdentifier("template-a", pageable); + } + + @Test + @DisplayName("Should return entity summaries by identifiers") + void shouldReturnEntitySummariesByIdentifiers() { + var summaries = List.of(new EntitySummary("service-a", "Service A", "web-service")); + when(entityRepository.findByIdentifierIn(List.of("service-a"))).thenReturn(summaries); + + var result = entityService.getEntitiesSummariesByIndentifiers(List.of("service-a")); + + assertEquals(summaries, result); + verify(entityRepository).findByIdentifierIn(List.of("service-a")); + } + + @Test + @DisplayName("Should return entity by template and identifier") + void shouldReturnEntityByTemplateAndIdentifier() { + var entity = entity("web-service", "catalog-api", "Catalog API"); + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) + .thenReturn(Optional.of(entity)); + + var result = entityService.getEntityByTemplateIdentifierAndIdentifier("web-service", + "catalog-api"); + + assertSame(entity, result); + verify(entityTemplateValidationService).validateTemplateExists("web-service"); + verify(entityRepository).findByTemplateIdentifierAndIdentifier("web-service", "catalog-api"); + } + + @Test + @DisplayName("Should throw when entity is not found for template") + void shouldThrowWhenEntityNotFoundByTemplateAndIdentifier() { + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "missing-entity")) + .thenReturn(Optional.empty()); + + assertThrows(EntityNotFoundException.class, () -> entityService + .getEntityByTemplateIdentifierAndIdentifier("web-service", "missing-entity")); + } + + @Test + @DisplayName("Should create entity when validations pass") + void shouldCreateEntityWhenValidationsPass() { + var entity = entity("web-service", "catalog-api", "Catalog API"); + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", + List.of(), List.of()); + when(entityTemplateService.getEntityTemplateByIdentifier("web-service")).thenReturn(template); + when(entityRepository.save(entity)).thenReturn(entity); + + var result = entityService.createEntity(entity); + + assertSame(entity, result); + + InOrder inOrder = inOrder(entityTemplateService, entityValidationService, entityRepository); + inOrder.verify(entityTemplateService).getEntityTemplateByIdentifier("web-service"); + inOrder.verify(entityValidationService).validateForCreation(entity, template); + inOrder.verify(entityRepository).save(entity); + verifyNoInteractions(entityTemplateValidationService); + } + + @Test + @DisplayName("Should not save when entity already exists") + void shouldNotSaveWhenEntityAlreadyExists() { + var entity = entity("web-service", "catalog-api", "Catalog API"); + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", + List.of(), List.of()); + var alreadyExists = new EntityAlreadyExistsException("web-service", "catalog-api"); + + when(entityTemplateService.getEntityTemplateByIdentifier("web-service")).thenReturn(template); + doThrow(alreadyExists).when(entityValidationService).validateForCreation(entity, template); + + assertThrows(EntityAlreadyExistsException.class, () -> entityService.createEntity(entity)); + + verify(entityTemplateService).getEntityTemplateByIdentifier("web-service"); + verify(entityValidationService).validateForCreation(entity, template); + verifyNoMoreInteractions(entityRepository); + } + + @Test + @DisplayName("Should stop immediately when template does not exist") + void shouldStopWhenTemplateDoesNotExistOnCreate() { + var entity = entity("missing-template", "catalog-api", "Catalog API"); + + when(entityTemplateService.getEntityTemplateByIdentifier("missing-template")) + .thenThrow(new EntityTemplateNotFoundException("identifier", "missing-template")); + + assertThrows(EntityTemplateNotFoundException.class, () -> entityService.createEntity(entity)); + + verify(entityTemplateService).getEntityTemplateByIdentifier("missing-template"); + verifyNoInteractions(entityValidationService); + verifyNoMoreInteractions(entityRepository); + } + + private Entity entity(String templateIdentifier, String identifier, String name) { + return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, List.of(), + List.of()); + } } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java index 6411bd68..ffadd760 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java @@ -37,195 +37,134 @@ @DisplayName("EntityValidationService Tests") class EntityValidationServiceTest { - @Mock - private EntityRepositoryPort entityRepository; - - - @Mock - private PropertyValidationService propertyValidationService; - - @InjectMocks - private EntityValidationService entityValidationService; - - @Test - @DisplayName("Should throw when entity with same identifier already exists") - void shouldThrowWhenEntityAlreadyExists() { - var template = new EntityTemplate( - UUID.randomUUID(), - "web-service", - "Web Service", - "desc", - Collections.emptyList(), - List.of()); - var entity = entity("web-service", "catalog-api", "Catalog API", List.of(), List.of()); - when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) - .thenReturn(Optional.of(entity)); - - assertThrows(EntityAlreadyExistsException.class, () -> entityValidationService.validateForCreation(entity, template)); - } - - @Test - @DisplayName("Should not query repository when identifier is null") - void shouldNotQueryRepositoryWhenIdentifierIsNull() { - var template = new EntityTemplate( - UUID.randomUUID(), - "web-service", - "Web Service", - "desc", - Collections.emptyList(), - List.of()); - - var entity = entity("web-service", null, "Catalog API", List.of(), List.of()); - - assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); - - verify(entityRepository, never()).findByTemplateIdentifierAndIdentifier(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); - } - - - @Test - @DisplayName("Should aggregate property requirements and rule violations") - void shouldAggregateAllViolationsDuringValidateForCreation() { - var portDefinition = new PropertyDefinition( - UUID.randomUUID(), - "port", - "Port", - PropertyType.NUMBER, - true, - new PropertyRules(null, null, null, null, null, null, 65535, 1024)); - - var requiredDefinition = new PropertyDefinition( - UUID.randomUUID(), - "ownerEmail", - "Owner email", - PropertyType.STRING, - true, - null); - - var template = new EntityTemplate( - UUID.randomUUID(), - "web-service", - "Web Service", - "desc", - List.of(requiredDefinition, portDefinition), - List.of()); - - var entity = entity( - "web-service", - " ", // Blank identifier (handled by Jakarta, not this service) - " ", // Blank name (handled by Jakarta, not this service) - List.of(new Property(UUID.randomUUID(), " ", " "), new Property(UUID.randomUUID(), "port", "80")), - List.of()); // No relations - - when(propertyValidationService.validatePropertyValue(portDefinition, "80")) - .thenReturn(List.of("Property 'port' value must be greater than or equal to 1024")); - - var exception = assertThrows(EntityValidationException.class, () -> entityValidationService.validateForCreation(entity, template)); - - // Expecting exactly 2 errors: the missing required property, and the invalid port value. - assertEquals(2, exception.getViolations().size()); - assertEquals(PROPERTY_REQUIRED_MISSING.formatted("ownerEmail", "web-service"), exception.getViolations().get(0)); - assertEquals("Property 'port' value must be greater than or equal to 1024", exception.getViolations().get(1)); - - verify(propertyValidationService).validatePropertyValue(portDefinition, "80"); - } - - @Test - @DisplayName("Should validate entity successfully when no violations") - void shouldValidateForCreationSuccessfullyWhenNoViolations() { - var versionDefinition = new PropertyDefinition( - UUID.randomUUID(), - "version", - "Version", - PropertyType.STRING, - false, - null); - - var template = new EntityTemplate( - UUID.randomUUID(), - "web-service", - "Web Service", - "desc", - List.of(versionDefinition), - List.of()); - - var entity = entity( - "web-service", - "catalog-api", - "Catalog API", - List.of(new Property(UUID.randomUUID(), "version", "1.0.0")), - null); - - - when(propertyValidationService.validatePropertyValue(versionDefinition, "1.0.0")).thenReturn(List.of()); - - assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); - verify(propertyValidationService).validatePropertyValue(versionDefinition, "1.0.0"); - } - - @Test - @DisplayName("Should skip property rule validation for missing optional property") - void shouldSkipPropertyRuleValidationWhenOptionalPropertyMissing() { - var optionalDefinition = new PropertyDefinition( - UUID.randomUUID(), - "version", - "Version", - PropertyType.STRING, - false, - null); - - var template = new EntityTemplate( - UUID.randomUUID(), - "web-service", - "Web Service", - "desc", - List.of(optionalDefinition), - List.of()); - - var entity = entity("web-service", "catalog-api", "Catalog API", List.of(), List.of()); - - assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); - verifyNoInteractions(propertyValidationService); - } - - @Test - @DisplayName("Should validate property of type STRING with a numeric string value '1234'") - void shouldValidateStringPropertyWithNumericStringValue() { - var stringDefinition = new PropertyDefinition( - UUID.randomUUID(), - "versionCode", - "Version Code as String", - PropertyType.STRING, - false, - null - ); - - var template = new EntityTemplate( - UUID.randomUUID(), - "web-service", - "Web Service", - "desc", - List.of(stringDefinition), - List.of()); - - var entity = entity( - "web-service", - "catalog-api", - "Catalog API", - List.of(new Property(UUID.randomUUID(), "versionCode", "1234")), - null); - when(propertyValidationService.validatePropertyValue(stringDefinition, "1234")).thenReturn(List.of()); - - assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); - verify(propertyValidationService).validatePropertyValue(stringDefinition, "1234"); - } - - private Entity entity( - String templateIdentifier, - String identifier, - String name, - List properties, - List relations) { - return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, properties, relations); - } + @Mock + private EntityRepositoryPort entityRepository; + + @Mock + private PropertyValidationService propertyValidationService; + + @InjectMocks + private EntityValidationService entityValidationService; + + @Test + @DisplayName("Should throw when entity with same identifier already exists") + void shouldThrowWhenEntityAlreadyExists() { + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", + Collections.emptyList(), List.of()); + var entity = entity("web-service", "catalog-api", "Catalog API", List.of(), List.of()); + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) + .thenReturn(Optional.of(entity)); + + assertThrows(EntityAlreadyExistsException.class, + () -> entityValidationService.validateForCreation(entity, template)); + } + + @Test + @DisplayName("Should not query repository when identifier is null") + void shouldNotQueryRepositoryWhenIdentifierIsNull() { + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", + Collections.emptyList(), List.of()); + + var entity = entity("web-service", null, "Catalog API", List.of(), List.of()); + + assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); + + verify(entityRepository, never()).findByTemplateIdentifierAndIdentifier( + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); + } + + @Test + @DisplayName("Should aggregate property requirements and rule violations") + void shouldAggregateAllViolationsDuringValidateForCreation() { + var portDefinition = new PropertyDefinition(UUID.randomUUID(), "port", "Port", + PropertyType.NUMBER, true, + new PropertyRules(null, null, null, null, null, null, 65535, 1024)); + + var requiredDefinition = new PropertyDefinition(UUID.randomUUID(), "ownerEmail", "Owner email", + PropertyType.STRING, true, null); + + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", + List.of(requiredDefinition, portDefinition), List.of()); + + var entity = entity("web-service", " ", // Blank identifier (handled by Jakarta, not this + // service) + " ", // Blank name (handled by Jakarta, not this service) + List.of(new Property(UUID.randomUUID(), " ", " "), + new Property(UUID.randomUUID(), "port", "80")), + List.of()); // No relations + + when(propertyValidationService.validatePropertyValue(portDefinition, "80")) + .thenReturn(List.of("Property 'port' value must be greater than or equal to 1024")); + + var exception = assertThrows(EntityValidationException.class, + () -> entityValidationService.validateForCreation(entity, template)); + + // Expecting exactly 2 errors: the missing required property, and the invalid + // port value. + assertEquals(2, exception.getViolations().size()); + assertEquals(PROPERTY_REQUIRED_MISSING.formatted("ownerEmail", "web-service"), + exception.getViolations().get(0)); + assertEquals("Property 'port' value must be greater than or equal to 1024", + exception.getViolations().get(1)); + + verify(propertyValidationService).validatePropertyValue(portDefinition, "80"); + } + + @Test + @DisplayName("Should validate entity successfully when no violations") + void shouldValidateForCreationSuccessfullyWhenNoViolations() { + var versionDefinition = new PropertyDefinition(UUID.randomUUID(), "version", "Version", + PropertyType.STRING, false, null); + + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", + List.of(versionDefinition), List.of()); + + var entity = entity("web-service", "catalog-api", "Catalog API", + List.of(new Property(UUID.randomUUID(), "version", "1.0.0")), null); + + when(propertyValidationService.validatePropertyValue(versionDefinition, "1.0.0")) + .thenReturn(List.of()); + + assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); + verify(propertyValidationService).validatePropertyValue(versionDefinition, "1.0.0"); + } + + @Test + @DisplayName("Should skip property rule validation for missing optional property") + void shouldSkipPropertyRuleValidationWhenOptionalPropertyMissing() { + var optionalDefinition = new PropertyDefinition(UUID.randomUUID(), "version", "Version", + PropertyType.STRING, false, null); + + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", + List.of(optionalDefinition), List.of()); + + var entity = entity("web-service", "catalog-api", "Catalog API", List.of(), List.of()); + + assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); + verifyNoInteractions(propertyValidationService); + } + + @Test + @DisplayName("Should validate property of type STRING with a numeric string value '1234'") + void shouldValidateStringPropertyWithNumericStringValue() { + var stringDefinition = new PropertyDefinition(UUID.randomUUID(), "versionCode", + "Version Code as String", PropertyType.STRING, false, null); + + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", + List.of(stringDefinition), List.of()); + + var entity = entity("web-service", "catalog-api", "Catalog API", + List.of(new Property(UUID.randomUUID(), "versionCode", "1234")), null); + when(propertyValidationService.validatePropertyValue(stringDefinition, "1234")) + .thenReturn(List.of()); + + assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); + verify(propertyValidationService).validatePropertyValue(stringDefinition, "1234"); + } + + private Entity entity(String templateIdentifier, String identifier, String name, + List properties, List relations) { + return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, properties, + relations); + } } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java index 0f6b50f2..768efc8b 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java @@ -35,344 +35,328 @@ @DisplayName("EntityGraphService Tests") class EntityGraphServiceTest { - @Mock - private EntityRepositoryPort entityRepositoryPort; + @Mock + private EntityRepositoryPort entityRepositoryPort; - @Mock - private EntityGraphRepositoryPort entityGraphRepositoryPort; + @Mock + private EntityGraphRepositoryPort entityGraphRepositoryPort; - @InjectMocks - private EntityGraphService entityGraphService; + @InjectMocks + private EntityGraphService entityGraphService; - // --- Fixtures --- + // --- Fixtures --- - private Entity entity(String templateIdentifier, String identifier, String name) { - return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, List.of(), List.of()); - } + private Entity entity(String templateIdentifier, String identifier, String name) { + return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, List.of(), + List.of()); + } - private Entity entityWithRelations(String templateIdentifier, String identifier, String name, - List relations) { - return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, List.of(), relations); - } + private Entity entityWithRelations(String templateIdentifier, String identifier, String name, + List relations) { + return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, List.of(), + relations); + } - private Relation relation(String name, String targetTemplateIdentifier, String... targetIds) { - return new Relation(UUID.randomUUID(), name, targetTemplateIdentifier, List.of(targetIds)); - } + private Relation relation(String name, String targetTemplateIdentifier, String... targetIds) { + return new Relation(UUID.randomUUID(), name, targetTemplateIdentifier, List.of(targetIds)); + } - private EntityCompositeKey key(String templateIdentifier, String identifier) { - return new EntityCompositeKey(templateIdentifier, identifier); - } + private EntityCompositeKey key(String templateIdentifier, String identifier) { + return new EntityCompositeKey(templateIdentifier, identifier); + } - private static final String TEMPLATE = "web-service"; + private static final String TEMPLATE = "web-service"; - // --- Helper to stub both ports --- + // --- Helper to stub both ports --- - private void stubGraph(Map entityMap) { - when(entityGraphRepositoryPort.findEntityGraph(anyString(), anyString(), anyInt(), anyBoolean())) - .thenReturn(entityMap); - } + private void stubGraph(Map entityMap) { + when( + entityGraphRepositoryPort.findEntityGraph(anyString(), anyString(), anyInt(), anyBoolean())) + .thenReturn(entityMap); + } - // ======================== - @Nested - @DisplayName("Root Entity Not Found") - class RootEntityNotFound { + // ======================== + @Nested + @DisplayName("Root Entity Not Found") + class RootEntityNotFound { - @Test - @DisplayName("Should throw EntityNotFoundException when root entity does not exist") - void shouldThrowWhenRootEntityNotFound() { - when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "missing")) - .thenReturn(Optional.empty()); + @Test + @DisplayName("Should throw EntityNotFoundException when root entity does not exist") + void shouldThrowWhenRootEntityNotFound() { + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "missing")) + .thenReturn(Optional.empty()); - assertThatThrownBy(() -> entityGraphService.getEntityGraph(TEMPLATE, "missing", 1, false)) - .isInstanceOf(EntityNotFoundException.class); + assertThatThrownBy(() -> entityGraphService.getEntityGraph(TEMPLATE, "missing", 1, false)) + .isInstanceOf(EntityNotFoundException.class); - verify(entityGraphRepositoryPort, never()) - .findEntityGraph(anyString(), anyString(), anyInt(), anyBoolean()); - } + verify(entityGraphRepositoryPort, never()).findEntityGraph(anyString(), anyString(), anyInt(), + anyBoolean()); } - - // ======================== - @Nested - @DisplayName("Single Root — No Relations") - class SingleRootNoRelations { - - @Test - @DisplayName("Should return leaf node when entity has no relations") - void shouldReturnLeafNodeWhenNoRelations() { - Entity api = entity(TEMPLATE, "api", "API Service"); - when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) - .thenReturn(Optional.of(api)); - stubGraph(Map.of(key(TEMPLATE, "api"), api)); - - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); - - assertThat(result.identifier()).isEqualTo("api"); - assertThat(result.name()).isEqualTo("API Service"); - assertThat(result.relations()).isEmpty(); - assertThat(result.relationsAsTarget()).isEmpty(); - } + } + + // ======================== + @Nested + @DisplayName("Single Root — No Relations") + class SingleRootNoRelations { + + @Test + @DisplayName("Should return leaf node when entity has no relations") + void shouldReturnLeafNodeWhenNoRelations() { + Entity api = entity(TEMPLATE, "api", "API Service"); + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); + + assertThat(result.identifier()).isEqualTo("api"); + assertThat(result.name()).isEqualTo("API Service"); + assertThat(result.relations()).isEmpty(); + assertThat(result.relationsAsTarget()).isEmpty(); } - - // ======================== - @Nested - @DisplayName("Outbound Relations") - class OutboundRelations { - - @Test - @DisplayName("Should resolve outbound relation targets at depth 1") - void shouldResolveOutboundRelations() { - Entity api = entityWithRelations(TEMPLATE, "api", "API Service", - List.of(relation("uses-db", "database", "postgres"))); - Entity postgres = entity("database", "postgres", "Postgres DB"); - - when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) - .thenReturn(Optional.of(api)); - stubGraph(Map.of( - key(TEMPLATE, "api"), api, - key("database", "postgres"), postgres)); - - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); - - assertThat(result.relations()).hasSize(1); - assertThat(result.relations().get(0).name()).isEqualTo("uses-db"); - assertThat(result.relations().get(0).targets()).hasSize(1); - assertThat(result.relations().get(0).targets().get(0).identifier()).isEqualTo("postgres"); - } - - @Test - @DisplayName("Should return fallback node when target is not in the pre-loaded entity map") - void shouldReturnFallbackNodeWhenTargetNotInMap() { - Entity api = entityWithRelations(TEMPLATE, "api", "API Service", - List.of(relation("uses-db", "database", "missing-db"))); - - when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) - .thenReturn(Optional.of(api)); - stubGraph(Map.of(key(TEMPLATE, "api"), api)); - - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); - - assertThat(result.relations()).hasSize(1); - EntityGraphNode fallback = result.relations().get(0).targets().get(0); - assertThat(fallback.identifier()).isEqualTo("missing-db"); - } + } + + // ======================== + @Nested + @DisplayName("Outbound Relations") + class OutboundRelations { + + @Test + @DisplayName("Should resolve outbound relation targets at depth 1") + void shouldResolveOutboundRelations() { + Entity api = entityWithRelations(TEMPLATE, "api", "API Service", + List.of(relation("uses-db", "database", "postgres"))); + Entity postgres = entity("database", "postgres", "Postgres DB"); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api, key("database", "postgres"), postgres)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); + + assertThat(result.relations()).hasSize(1); + assertThat(result.relations().get(0).name()).isEqualTo("uses-db"); + assertThat(result.relations().get(0).targets()).hasSize(1); + assertThat(result.relations().get(0).targets().get(0).identifier()).isEqualTo("postgres"); } - // ======================== - @Nested - @DisplayName("Inbound Relations (relationsAsTarget)") - class InboundRelations { - - @Test - @DisplayName("Should resolve inbound relations when another entity points to root") - void shouldResolveInboundRelations() { - Entity api = entity(TEMPLATE, "api", "API Service"); - Entity consumer = entityWithRelations(TEMPLATE, "consumer", "Consumer", - List.of(relation("depends-on", TEMPLATE, "api"))); - - when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) - .thenReturn(Optional.of(api)); - stubGraph(Map.of( - key(TEMPLATE, "api"), api, - key(TEMPLATE, "consumer"), consumer)); - - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); - - assertThat(result.relationsAsTarget()).hasSize(1); - assertThat(result.relationsAsTarget().get(0).name()).isEqualTo("depends-on"); - assertThat(result.relationsAsTarget().get(0).targets().get(0).identifier()).isEqualTo("consumer"); - } - } + @Test + @DisplayName("Should return fallback node when target is not in the pre-loaded entity map") + void shouldReturnFallbackNodeWhenTargetNotInMap() { + Entity api = entityWithRelations(TEMPLATE, "api", "API Service", + List.of(relation("uses-db", "database", "missing-db"))); - // ======================== - @Nested - @DisplayName("Depth Clamping") - class DepthClamping { + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api)); - @Test - @DisplayName("Should clamp depth below 1 to 1") - void shouldClampDepthBelowOne() { - Entity api = entity(TEMPLATE, "api", "API Service"); - when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) - .thenReturn(Optional.of(api)); - stubGraph(Map.of(key(TEMPLATE, "api"), api)); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); - entityGraphService.getEntityGraph(TEMPLATE, "api", 0, false); + assertThat(result.relations()).hasSize(1); + EntityGraphNode fallback = result.relations().get(0).targets().get(0); + assertThat(fallback.identifier()).isEqualTo("missing-db"); + } + } + + // ======================== + @Nested + @DisplayName("Inbound Relations (relationsAsTarget)") + class InboundRelations { + + @Test + @DisplayName("Should resolve inbound relations when another entity points to root") + void shouldResolveInboundRelations() { + Entity api = entity(TEMPLATE, "api", "API Service"); + Entity consumer = entityWithRelations(TEMPLATE, "consumer", "Consumer", + List.of(relation("depends-on", TEMPLATE, "api"))); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api, key(TEMPLATE, "consumer"), consumer)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); + + assertThat(result.relationsAsTarget()).hasSize(1); + assertThat(result.relationsAsTarget().get(0).name()).isEqualTo("depends-on"); + assertThat(result.relationsAsTarget().get(0).targets().get(0).identifier()) + .isEqualTo("consumer"); + } + } - verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "api", 1, false); - } + // ======================== + @Nested + @DisplayName("Depth Clamping") + class DepthClamping { - @Test - @DisplayName("Should clamp depth above MAX_DEPTH to MAX_DEPTH") - void shouldClampDepthAboveTen() { - Entity api = entity(TEMPLATE, "api", "API Service"); - when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) - .thenReturn(Optional.of(api)); - stubGraph(Map.of(key(TEMPLATE, "api"), api)); + @Test + @DisplayName("Should clamp depth below 1 to 1") + void shouldClampDepthBelowOne() { + Entity api = entity(TEMPLATE, "api", "API Service"); + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api)); - entityGraphService.getEntityGraph(TEMPLATE, "api", 99, false); + entityGraphService.getEntityGraph(TEMPLATE, "api", 0, false); - verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "api", 10, false); - } + verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "api", 1, false); } - // ======================== - @Nested - @DisplayName("Depth Limit — Leaf Nodes at Boundary") - class DepthLimit { - - @Test - @DisplayName("Should return target as leaf node when depth limit is reached") - void shouldReturnLeafNodeAtDepthBoundary() { - Entity api = entityWithRelations(TEMPLATE, "api", "API Service", - List.of(relation("uses-db", "database", "postgres"))); - Entity postgres = entityWithRelations("database", "postgres", "Postgres DB", - List.of(relation("runs-on", "infra", "server-1"))); - Entity server = entity("infra", "server-1", "Server 1"); - - when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) - .thenReturn(Optional.of(api)); - stubGraph(Map.of( - key(TEMPLATE, "api"), api, - key("database", "postgres"), postgres, - key("infra", "server-1"), server)); - - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); - - EntityGraphNode postgresNode = result.relations().get(0).targets().get(0); - assertThat(postgresNode.identifier()).isEqualTo("postgres"); - // At depth=1, postgres is a leaf — no further relations resolved - assertThat(postgresNode.relations()).isEmpty(); - assertThat(postgresNode.relationsAsTarget()).isEmpty(); - } - } + @Test + @DisplayName("Should clamp depth above MAX_DEPTH to MAX_DEPTH") + void shouldClampDepthAboveTen() { + Entity api = entity(TEMPLATE, "api", "API Service"); + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api)); - // ======================== - @Nested - @DisplayName("Multiple Named Relations") - class MultipleRelations { - - @Test - @DisplayName("Should resolve multiple distinct relation types") - void shouldResolveMultipleNamedRelations() { - Entity api = entityWithRelations(TEMPLATE, "api", "API Service", List.of( - relation("uses-db", "database", "postgres"), - relation("depends-on", TEMPLATE, "auth"))); - Entity postgres = entity("database", "postgres", "Postgres DB"); - Entity auth = entity(TEMPLATE, "auth", "Auth Service"); - - when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) - .thenReturn(Optional.of(api)); - stubGraph(Map.of( - key(TEMPLATE, "api"), api, - key("database", "postgres"), postgres, - key(TEMPLATE, "auth"), auth)); - - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); - - assertThat(result.relations()).hasSize(2); - assertThat(result.relations().stream().map(EntityGraphRelation::name)) - .containsExactlyInAnyOrder("uses-db", "depends-on"); - } - } + entityGraphService.getEntityGraph(TEMPLATE, "api", 99, false); - // ======================== - @Nested - @DisplayName("Full Graph Returned — Filtering Is a Mapper Concern") - class FullGraphReturned { - - @Test - @DisplayName("Should return all edges regardless of relation type (no filtering in service)") - void shouldReturnAllEdgesWithoutFiltering() { - // A --(depends-on)--> B --(owns)--> C - // The service must return both edges — the mapper will filter them. - Entity a = entityWithRelations(TEMPLATE, "a", "A", - List.of(relation("depends-on", TEMPLATE, "b"))); - Entity b = entityWithRelations(TEMPLATE, "b", "B", - List.of(relation("owns", TEMPLATE, "c"))); - Entity c = entity(TEMPLATE, "c", "C"); - - when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "a")) - .thenReturn(Optional.of(a)); - stubGraph(Map.of( - key(TEMPLATE, "a"), a, - key(TEMPLATE, "b"), b, - key(TEMPLATE, "c"), c)); - - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 2, false); - - // Root A has one outbound "depends-on" edge → B - assertThat(result.relations()).hasSize(1); - assertThat(result.relations().get(0).name()).isEqualTo("depends-on"); - - // B (at depth 1) has one outbound "owns" edge → C - EntityGraphNode nodeB = result.relations().get(0).targets().get(0); - assertThat(nodeB.identifier()).isEqualTo("b"); - assertThat(nodeB.relations()).hasSize(1); - assertThat(nodeB.relations().get(0).name()).isEqualTo("owns"); - assertThat(nodeB.relations().get(0).targets().get(0).identifier()).isEqualTo("c"); - - verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "a", 2, false); - } + verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "api", 10, false); + } + } + + // ======================== + @Nested + @DisplayName("Depth Limit — Leaf Nodes at Boundary") + class DepthLimit { + + @Test + @DisplayName("Should return target as leaf node when depth limit is reached") + void shouldReturnLeafNodeAtDepthBoundary() { + Entity api = entityWithRelations(TEMPLATE, "api", "API Service", + List.of(relation("uses-db", "database", "postgres"))); + Entity postgres = entityWithRelations("database", "postgres", "Postgres DB", + List.of(relation("runs-on", "infra", "server-1"))); + Entity server = entity("infra", "server-1", "Server 1"); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api, key("database", "postgres"), postgres, + key("infra", "server-1"), server)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); + + EntityGraphNode postgresNode = result.relations().get(0).targets().get(0); + assertThat(postgresNode.identifier()).isEqualTo("postgres"); + // At depth=1, postgres is a leaf — no further relations resolved + assertThat(postgresNode.relations()).isEmpty(); + assertThat(postgresNode.relationsAsTarget()).isEmpty(); + } + } + + // ======================== + @Nested + @DisplayName("Multiple Named Relations") + class MultipleRelations { + + @Test + @DisplayName("Should resolve multiple distinct relation types") + void shouldResolveMultipleNamedRelations() { + Entity api = entityWithRelations(TEMPLATE, "api", "API Service", List.of( + relation("uses-db", "database", "postgres"), relation("depends-on", TEMPLATE, "auth"))); + Entity postgres = entity("database", "postgres", "Postgres DB"); + Entity auth = entity(TEMPLATE, "auth", "Auth Service"); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api, key("database", "postgres"), postgres, + key(TEMPLATE, "auth"), auth)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); + + assertThat(result.relations()).hasSize(2); + assertThat(result.relations().stream().map(EntityGraphRelation::name)) + .containsExactlyInAnyOrder("uses-db", "depends-on"); + } + } + + // ======================== + @Nested + @DisplayName("Full Graph Returned — Filtering Is a Mapper Concern") + class FullGraphReturned { + + @Test + @DisplayName("Should return all edges regardless of relation type (no filtering in service)") + void shouldReturnAllEdgesWithoutFiltering() { + // A --(depends-on)--> B --(owns)--> C + // The service must return both edges — the mapper will filter them. + Entity a = entityWithRelations(TEMPLATE, "a", "A", + List.of(relation("depends-on", TEMPLATE, "b"))); + Entity b = entityWithRelations(TEMPLATE, "b", "B", List.of(relation("owns", TEMPLATE, "c"))); + Entity c = entity(TEMPLATE, "c", "C"); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "a")) + .thenReturn(Optional.of(a)); + stubGraph(Map.of(key(TEMPLATE, "a"), a, key(TEMPLATE, "b"), b, key(TEMPLATE, "c"), c)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 2, false); + + // Root A has one outbound "depends-on" edge → B + assertThat(result.relations()).hasSize(1); + assertThat(result.relations().get(0).name()).isEqualTo("depends-on"); + + // B (at depth 1) has one outbound "owns" edge → C + EntityGraphNode nodeB = result.relations().get(0).targets().get(0); + assertThat(nodeB.identifier()).isEqualTo("b"); + assertThat(nodeB.relations()).hasSize(1); + assertThat(nodeB.relations().get(0).name()).isEqualTo("owns"); + assertThat(nodeB.relations().get(0).targets().get(0).identifier()).isEqualTo("c"); + + verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "a", 2, false); + } + } + + // ======================== + @Nested + @DisplayName("Visited Node Guard — OOM Prevention") + class VisitedNodeGuard { + + @Test + @DisplayName("Should complete at depth=10 without exponential recursion for a small graph") + void shouldNotExplodeAtMaxDepthWithSmallGraph() { + // A --(uses)--> B --(uses)--> C; B also has inbound from A and C has inbound + // from B. + // Without the visited-node guard this produces O(2^depth) calls at depth=10. + Entity a = entityWithRelations(TEMPLATE, "a", "A", List.of(relation("uses", TEMPLATE, "b"))); + Entity b = entityWithRelations(TEMPLATE, "b", "B", List.of(relation("uses", TEMPLATE, "c"))); + Entity c = entity(TEMPLATE, "c", "C"); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "a")) + .thenReturn(Optional.of(a)); + stubGraph(Map.of(key(TEMPLATE, "a"), a, key(TEMPLATE, "b"), b, key(TEMPLATE, "c"), c)); + + // Must complete instantly — any OOM or StackOverflow here means the guard is + // missing. + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 10, false); + + assertThat(result.identifier()).isEqualTo("a"); + assertThat(result.relations()).hasSize(1); } - // ======================== - @Nested - @DisplayName("Visited Node Guard — OOM Prevention") - class VisitedNodeGuard { - - @Test - @DisplayName("Should complete at depth=10 without exponential recursion for a small graph") - void shouldNotExplodeAtMaxDepthWithSmallGraph() { - // A --(uses)--> B --(uses)--> C; B also has inbound from A and C has inbound from B. - // Without the visited-node guard this produces O(2^depth) calls at depth=10. - Entity a = entityWithRelations(TEMPLATE, "a", "A", - List.of(relation("uses", TEMPLATE, "b"))); - Entity b = entityWithRelations(TEMPLATE, "b", "B", - List.of(relation("uses", TEMPLATE, "c"))); - Entity c = entity(TEMPLATE, "c", "C"); - - when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "a")) - .thenReturn(Optional.of(a)); - stubGraph(Map.of( - key(TEMPLATE, "a"), a, - key(TEMPLATE, "b"), b, - key(TEMPLATE, "c"), c)); - - // Must complete instantly — any OOM or StackOverflow here means the guard is missing. - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 10, false); - - assertThat(result.identifier()).isEqualTo("a"); - assertThat(result.relations()).hasSize(1); - } - - @Test - @DisplayName("Should return stub leaf for already-visited node instead of re-expanding it") - void shouldReturnStubLeafForRevisitedNode() { - // A --(uses)--> B; B also points back to A (cycle: A→B→A) - Entity a = entityWithRelations(TEMPLATE, "a", "A", - List.of(relation("uses", TEMPLATE, "b"))); - Entity b = entityWithRelations(TEMPLATE, "b", "B", - List.of(relation("uses", TEMPLATE, "a"))); - - when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "a")) - .thenReturn(Optional.of(a)); - stubGraph(Map.of( - key(TEMPLATE, "a"), a, - key(TEMPLATE, "b"), b)); - - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 5, false); - - // A → B is resolved - assertThat(result.relations()).hasSize(1); - EntityGraphNode nodeB = result.relations().get(0).targets().get(0); - assertThat(nodeB.identifier()).isEqualTo("b"); - - // B → A is a revisit: A was already marked visited, so it returns a stub leaf - // with no further outbound or inbound relations (no infinite loop). - EntityGraphNode stubA = nodeB.relations().get(0).targets().get(0); - assertThat(stubA.identifier()).isEqualTo("a"); - assertThat(stubA.relations()).isEmpty(); - assertThat(stubA.relationsAsTarget()).isEmpty(); - } + @Test + @DisplayName("Should return stub leaf for already-visited node instead of re-expanding it") + void shouldReturnStubLeafForRevisitedNode() { + // A --(uses)--> B; B also points back to A (cycle: A→B→A) + Entity a = entityWithRelations(TEMPLATE, "a", "A", List.of(relation("uses", TEMPLATE, "b"))); + Entity b = entityWithRelations(TEMPLATE, "b", "B", List.of(relation("uses", TEMPLATE, "a"))); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "a")) + .thenReturn(Optional.of(a)); + stubGraph(Map.of(key(TEMPLATE, "a"), a, key(TEMPLATE, "b"), b)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 5, false); + + // A → B is resolved + assertThat(result.relations()).hasSize(1); + EntityGraphNode nodeB = result.relations().get(0).targets().get(0); + assertThat(nodeB.identifier()).isEqualTo("b"); + + // B → A is a revisit: A was already marked visited, so it returns a stub leaf + // with no further outbound or inbound relations (no infinite loop). + EntityGraphNode stubA = nodeB.relations().get(0).targets().get(0); + assertThat(stubA.identifier()).isEqualTo("a"); + assertThat(stubA.relations()).isEmpty(); + assertThat(stubA.relationsAsTarget()).isEmpty(); } + } } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateServiceTest.java index 5d406633..a6cb3177 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateServiceTest.java @@ -32,241 +32,233 @@ @ExtendWith(MockitoExtension.class) class EntityTemplateServiceTest { - @Mock - private EntityTemplateRepositoryPort entityTemplateRepositoryPort; - @Mock - private EntityTemplateValidationService entityTemplateValidationService; - @Mock - private EntityRepositoryPort entityRepositoryPort; - - private EntityTemplateService entityTemplateService; - - @BeforeEach - void setUp() { - entityTemplateService = new EntityTemplateService( - entityTemplateRepositoryPort, - entityTemplateValidationService, - entityRepositoryPort); + @Mock + private EntityTemplateRepositoryPort entityTemplateRepositoryPort; + @Mock + private EntityTemplateValidationService entityTemplateValidationService; + @Mock + private EntityRepositoryPort entityRepositoryPort; + + private EntityTemplateService entityTemplateService; + + @BeforeEach + void setUp() { + entityTemplateService = new EntityTemplateService(entityTemplateRepositoryPort, + entityTemplateValidationService, entityRepositoryPort); + } + + @Nested + @DisplayName("updateEntityTemplate - relation purge on definition removal") + class RelationPurgeTests { + + private static final UUID TEMPLATE_ID = UUID.randomUUID(); + private static final String TEMPLATE_IDENTIFIER = "web-service"; + + private EntityTemplate buildTemplate(List relations) { + return new EntityTemplate(TEMPLATE_ID, TEMPLATE_IDENTIFIER, "Web Service", "desc", List.of(), + relations); } - @Nested - @DisplayName("updateEntityTemplate - relation purge on definition removal") - class RelationPurgeTests { - - private static final UUID TEMPLATE_ID = UUID.randomUUID(); - private static final String TEMPLATE_IDENTIFIER = "web-service"; - - private EntityTemplate buildTemplate(List relations) { - return new EntityTemplate(TEMPLATE_ID, TEMPLATE_IDENTIFIER, "Web Service", "desc", List.of(), relations); - } - - @Test - @DisplayName("Should purge entity relations when a RelationDefinition is removed") - void shouldPurgeWhenRelationDefinitionRemoved() { - var existingRelation = new RelationDefinition(UUID.randomUUID(), "owns", "microservice", true, false); - var existingTemplate = buildTemplate(List.of(existingRelation)); - var updatedTemplate = buildTemplate(List.of()); // "owns" removed - - when(entityTemplateRepositoryPort.findByIdentifier(TEMPLATE_IDENTIFIER)) - .thenReturn(Optional.of(existingTemplate)); - when(entityTemplateRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); - - entityTemplateService.updateEntityTemplate(TEMPLATE_IDENTIFIER, updatedTemplate); - - verify(entityRepositoryPort).deleteRelationsByTemplateIdentifierAndRelationName( - eq(TEMPLATE_IDENTIFIER), - argThat((Collection c) -> - c.size() == 1 && c.contains("owns"))); - } - - @Test - @DisplayName("Should purge all removed relation names when multiple are removed") - void shouldPurgeAllRemovedRelations() { - var rel1 = new RelationDefinition(UUID.randomUUID(), "owns", "microservice", true, false); - var rel2 = new RelationDefinition(UUID.randomUUID(), "uses", "database-service", false, true); - var rel3 = new RelationDefinition(UUID.randomUUID(), "belongsTo", "team", false, false); - var existingTemplate = buildTemplate(List.of(rel1, rel2, rel3)); - // Only "belongsTo" is kept - var updatedTemplate = buildTemplate(List.of(rel3)); - - when(entityTemplateRepositoryPort.findByIdentifier(TEMPLATE_IDENTIFIER)) - .thenReturn(Optional.of(existingTemplate)); - when(entityTemplateRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); - - entityTemplateService.updateEntityTemplate(TEMPLATE_IDENTIFIER, updatedTemplate); - - verify(entityRepositoryPort).deleteRelationsByTemplateIdentifierAndRelationName( - eq(TEMPLATE_IDENTIFIER), - argThat((Collection c) -> - c.size() == 2 - && c.contains("owns") - && c.contains("uses"))); - } - - @Test - @DisplayName("Should NOT call purge when no RelationDefinitions are removed") - void shouldNotPurgeWhenNoRelationsRemoved() { - var rel = new RelationDefinition(UUID.randomUUID(), "owns", "microservice", true, false); - var existingTemplate = buildTemplate(List.of(rel)); - var updatedTemplate = buildTemplate(List.of(rel)); - - when(entityTemplateRepositoryPort.findByIdentifier(TEMPLATE_IDENTIFIER)) - .thenReturn(Optional.of(existingTemplate)); - when(entityTemplateRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); - - entityTemplateService.updateEntityTemplate(TEMPLATE_IDENTIFIER, updatedTemplate); - - verify(entityRepositoryPort, never()) - .deleteRelationsByTemplateIdentifierAndRelationName(anyString(), any()); - } - - @Test - @DisplayName("Should NOT call purge when template had no relations") - void shouldNotPurgeWhenTemplateHadNoRelations() { - var existingTemplate = buildTemplate(List.of()); - var newRel = new RelationDefinition(UUID.randomUUID(), "owns", "microservice", true, false); - var updatedTemplate = buildTemplate(List.of(newRel)); - - when(entityTemplateRepositoryPort.findByIdentifier(TEMPLATE_IDENTIFIER)) - .thenReturn(Optional.of(existingTemplate)); - when(entityTemplateRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); - - entityTemplateService.updateEntityTemplate(TEMPLATE_IDENTIFIER, updatedTemplate); - - verify(entityRepositoryPort, never()) - .deleteRelationsByTemplateIdentifierAndRelationName(anyString(), any()); - } - - @Test - @DisplayName("Should match removed relations case-insensitively") - void shouldMatchRemovedRelationsCaseInsensitively() { - var rel = new RelationDefinition(UUID.randomUUID(), "Owns", "microservice", true, false); - var existingTemplate = buildTemplate(List.of(rel)); - // Incoming uses different casing but same logical name - var updatedRelation = new RelationDefinition(null, "owns", "microservice", true, false); - var updatedTemplate = buildTemplate(List.of(updatedRelation)); - - when(entityTemplateRepositoryPort.findByIdentifier(TEMPLATE_IDENTIFIER)) - .thenReturn(Optional.of(existingTemplate)); - when(entityTemplateRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); - - entityTemplateService.updateEntityTemplate(TEMPLATE_IDENTIFIER, updatedTemplate); - - // "Owns" and "owns" are the same — no removal, no purge call - verify(entityRepositoryPort, never()) - .deleteRelationsByTemplateIdentifierAndRelationName(anyString(), any()); - } + @Test + @DisplayName("Should purge entity relations when a RelationDefinition is removed") + void shouldPurgeWhenRelationDefinitionRemoved() { + var existingRelation = new RelationDefinition(UUID.randomUUID(), "owns", "microservice", true, + false); + var existingTemplate = buildTemplate(List.of(existingRelation)); + var updatedTemplate = buildTemplate(List.of()); // "owns" removed + + when(entityTemplateRepositoryPort.findByIdentifier(TEMPLATE_IDENTIFIER)) + .thenReturn(Optional.of(existingTemplate)); + when(entityTemplateRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + entityTemplateService.updateEntityTemplate(TEMPLATE_IDENTIFIER, updatedTemplate); + + verify(entityRepositoryPort).deleteRelationsByTemplateIdentifierAndRelationName( + eq(TEMPLATE_IDENTIFIER), + argThat((Collection c) -> c.size() == 1 && c.contains("owns"))); } - @Nested - @DisplayName("updateEntityTemplate - property purge on definition removal") - class PropertyPurgeTests { - - private static final UUID TEMPLATE_ID = UUID.randomUUID(); - private static final String TEMPLATE_IDENTIFIER = "web-service"; - - private EntityTemplate buildTemplate(List properties) { - return new EntityTemplate(TEMPLATE_ID, TEMPLATE_IDENTIFIER, "Web Service", "desc", properties, List.of()); - } - - private PropertyDefinition prop(String name) { - return new PropertyDefinition(UUID.randomUUID(), name, "desc", PropertyType.STRING, false, null); - } - - @Test - @DisplayName("Should purge entity properties when a PropertyDefinition is removed") - void shouldPurgeWhenPropertyDefinitionRemoved() { - var existingTemplate = buildTemplate(List.of(prop("color"))); - var updatedTemplate = buildTemplate(List.of()); // "color" removed - - when(entityTemplateRepositoryPort.findByIdentifier(TEMPLATE_IDENTIFIER)) - .thenReturn(Optional.of(existingTemplate)); - when(entityTemplateRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); - - entityTemplateService.updateEntityTemplate(TEMPLATE_IDENTIFIER, updatedTemplate); - - verify(entityRepositoryPort).deletePropertiesByTemplateIdentifierAndPropertyName( - eq(TEMPLATE_IDENTIFIER), - argThat((Collection c) -> - c.size() == 1 && c.contains("color"))); - } - - @Test - @DisplayName("Should purge all removed property names when multiple are removed") - void shouldPurgeAllRemovedProperties() { - var p1 = prop("color"); - var p2 = prop("port"); - var p3 = prop("env"); - var existingTemplate = buildTemplate(List.of(p1, p2, p3)); - var updatedTemplate = buildTemplate(List.of(p3)); // keep only "env" - - when(entityTemplateRepositoryPort.findByIdentifier(TEMPLATE_IDENTIFIER)) - .thenReturn(Optional.of(existingTemplate)); - when(entityTemplateRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); - - entityTemplateService.updateEntityTemplate(TEMPLATE_IDENTIFIER, updatedTemplate); - - verify(entityRepositoryPort).deletePropertiesByTemplateIdentifierAndPropertyName( - eq(TEMPLATE_IDENTIFIER), - argThat((Collection c) -> - c.size() == 2 - && c.contains("color") - && c.contains("port"))); - } - - @Test - @DisplayName("Should NOT call purge when no PropertyDefinitions are removed") - void shouldNotPurgeWhenNoPropertiesRemoved() { - var p = prop("color"); - var existingTemplate = buildTemplate(List.of(p)); - var updatedTemplate = buildTemplate(List.of(p)); - - when(entityTemplateRepositoryPort.findByIdentifier(TEMPLATE_IDENTIFIER)) - .thenReturn(Optional.of(existingTemplate)); - when(entityTemplateRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); - - entityTemplateService.updateEntityTemplate(TEMPLATE_IDENTIFIER, updatedTemplate); - - verify(entityRepositoryPort, never()) - .deletePropertiesByTemplateIdentifierAndPropertyName(anyString(), any()); - } - - @Test - @DisplayName("Should NOT call purge when template had no properties") - void shouldNotPurgeWhenTemplateHadNoProperties() { - var existingTemplate = buildTemplate(List.of()); - var updatedTemplate = buildTemplate(List.of(prop("color"))); - - when(entityTemplateRepositoryPort.findByIdentifier(TEMPLATE_IDENTIFIER)) - .thenReturn(Optional.of(existingTemplate)); - when(entityTemplateRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); - - entityTemplateService.updateEntityTemplate(TEMPLATE_IDENTIFIER, updatedTemplate); - - verify(entityRepositoryPort, never()) - .deletePropertiesByTemplateIdentifierAndPropertyName(anyString(), any()); - } - - @Test - @DisplayName("Should match removed properties case-insensitively") - void shouldMatchRemovedPropertiesCaseInsensitively() { - var existingTemplate = buildTemplate(List.of( - new PropertyDefinition(UUID.randomUUID(), "Color", "desc", - PropertyType.STRING, false, null))); - // Incoming uses lowercase — same logical property - var updatedTemplate = buildTemplate(List.of( - new PropertyDefinition(null, "color", "desc", - PropertyType.STRING, false, null))); - - when(entityTemplateRepositoryPort.findByIdentifier(TEMPLATE_IDENTIFIER)) - .thenReturn(Optional.of(existingTemplate)); - when(entityTemplateRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); - - entityTemplateService.updateEntityTemplate(TEMPLATE_IDENTIFIER, updatedTemplate); - - // "Color" and "color" are the same — no removal, no purge call - verify(entityRepositoryPort, never()) - .deletePropertiesByTemplateIdentifierAndPropertyName(anyString(), any()); - } + @Test + @DisplayName("Should purge all removed relation names when multiple are removed") + void shouldPurgeAllRemovedRelations() { + var rel1 = new RelationDefinition(UUID.randomUUID(), "owns", "microservice", true, false); + var rel2 = new RelationDefinition(UUID.randomUUID(), "uses", "database-service", false, true); + var rel3 = new RelationDefinition(UUID.randomUUID(), "belongsTo", "team", false, false); + var existingTemplate = buildTemplate(List.of(rel1, rel2, rel3)); + // Only "belongsTo" is kept + var updatedTemplate = buildTemplate(List.of(rel3)); + + when(entityTemplateRepositoryPort.findByIdentifier(TEMPLATE_IDENTIFIER)) + .thenReturn(Optional.of(existingTemplate)); + when(entityTemplateRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + entityTemplateService.updateEntityTemplate(TEMPLATE_IDENTIFIER, updatedTemplate); + + verify(entityRepositoryPort) + .deleteRelationsByTemplateIdentifierAndRelationName(eq(TEMPLATE_IDENTIFIER), argThat( + (Collection c) -> c.size() == 2 && c.contains("owns") && c.contains("uses"))); + } + + @Test + @DisplayName("Should NOT call purge when no RelationDefinitions are removed") + void shouldNotPurgeWhenNoRelationsRemoved() { + var rel = new RelationDefinition(UUID.randomUUID(), "owns", "microservice", true, false); + var existingTemplate = buildTemplate(List.of(rel)); + var updatedTemplate = buildTemplate(List.of(rel)); + + when(entityTemplateRepositoryPort.findByIdentifier(TEMPLATE_IDENTIFIER)) + .thenReturn(Optional.of(existingTemplate)); + when(entityTemplateRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + entityTemplateService.updateEntityTemplate(TEMPLATE_IDENTIFIER, updatedTemplate); + + verify(entityRepositoryPort, never()) + .deleteRelationsByTemplateIdentifierAndRelationName(anyString(), any()); + } + + @Test + @DisplayName("Should NOT call purge when template had no relations") + void shouldNotPurgeWhenTemplateHadNoRelations() { + var existingTemplate = buildTemplate(List.of()); + var newRel = new RelationDefinition(UUID.randomUUID(), "owns", "microservice", true, false); + var updatedTemplate = buildTemplate(List.of(newRel)); + + when(entityTemplateRepositoryPort.findByIdentifier(TEMPLATE_IDENTIFIER)) + .thenReturn(Optional.of(existingTemplate)); + when(entityTemplateRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + entityTemplateService.updateEntityTemplate(TEMPLATE_IDENTIFIER, updatedTemplate); + + verify(entityRepositoryPort, never()) + .deleteRelationsByTemplateIdentifierAndRelationName(anyString(), any()); + } + + @Test + @DisplayName("Should match removed relations case-insensitively") + void shouldMatchRemovedRelationsCaseInsensitively() { + var rel = new RelationDefinition(UUID.randomUUID(), "Owns", "microservice", true, false); + var existingTemplate = buildTemplate(List.of(rel)); + // Incoming uses different casing but same logical name + var updatedRelation = new RelationDefinition(null, "owns", "microservice", true, false); + var updatedTemplate = buildTemplate(List.of(updatedRelation)); + + when(entityTemplateRepositoryPort.findByIdentifier(TEMPLATE_IDENTIFIER)) + .thenReturn(Optional.of(existingTemplate)); + when(entityTemplateRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + entityTemplateService.updateEntityTemplate(TEMPLATE_IDENTIFIER, updatedTemplate); + + // "Owns" and "owns" are the same — no removal, no purge call + verify(entityRepositoryPort, never()) + .deleteRelationsByTemplateIdentifierAndRelationName(anyString(), any()); + } + } + + @Nested + @DisplayName("updateEntityTemplate - property purge on definition removal") + class PropertyPurgeTests { + + private static final UUID TEMPLATE_ID = UUID.randomUUID(); + private static final String TEMPLATE_IDENTIFIER = "web-service"; + + private EntityTemplate buildTemplate(List properties) { + return new EntityTemplate(TEMPLATE_ID, TEMPLATE_IDENTIFIER, "Web Service", "desc", properties, + List.of()); + } + + private PropertyDefinition prop(String name) { + return new PropertyDefinition(UUID.randomUUID(), name, "desc", PropertyType.STRING, false, + null); + } + + @Test + @DisplayName("Should purge entity properties when a PropertyDefinition is removed") + void shouldPurgeWhenPropertyDefinitionRemoved() { + var existingTemplate = buildTemplate(List.of(prop("color"))); + var updatedTemplate = buildTemplate(List.of()); // "color" removed + + when(entityTemplateRepositoryPort.findByIdentifier(TEMPLATE_IDENTIFIER)) + .thenReturn(Optional.of(existingTemplate)); + when(entityTemplateRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + entityTemplateService.updateEntityTemplate(TEMPLATE_IDENTIFIER, updatedTemplate); + + verify(entityRepositoryPort).deletePropertiesByTemplateIdentifierAndPropertyName( + eq(TEMPLATE_IDENTIFIER), + argThat((Collection c) -> c.size() == 1 && c.contains("color"))); + } + + @Test + @DisplayName("Should purge all removed property names when multiple are removed") + void shouldPurgeAllRemovedProperties() { + var p1 = prop("color"); + var p2 = prop("port"); + var p3 = prop("env"); + var existingTemplate = buildTemplate(List.of(p1, p2, p3)); + var updatedTemplate = buildTemplate(List.of(p3)); // keep only "env" + + when(entityTemplateRepositoryPort.findByIdentifier(TEMPLATE_IDENTIFIER)) + .thenReturn(Optional.of(existingTemplate)); + when(entityTemplateRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + entityTemplateService.updateEntityTemplate(TEMPLATE_IDENTIFIER, updatedTemplate); + + verify(entityRepositoryPort).deletePropertiesByTemplateIdentifierAndPropertyName( + eq(TEMPLATE_IDENTIFIER), argThat((Collection c) -> c.size() == 2 + && c.contains("color") && c.contains("port"))); + } + + @Test + @DisplayName("Should NOT call purge when no PropertyDefinitions are removed") + void shouldNotPurgeWhenNoPropertiesRemoved() { + var p = prop("color"); + var existingTemplate = buildTemplate(List.of(p)); + var updatedTemplate = buildTemplate(List.of(p)); + + when(entityTemplateRepositoryPort.findByIdentifier(TEMPLATE_IDENTIFIER)) + .thenReturn(Optional.of(existingTemplate)); + when(entityTemplateRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + entityTemplateService.updateEntityTemplate(TEMPLATE_IDENTIFIER, updatedTemplate); + + verify(entityRepositoryPort, never()) + .deletePropertiesByTemplateIdentifierAndPropertyName(anyString(), any()); + } + + @Test + @DisplayName("Should NOT call purge when template had no properties") + void shouldNotPurgeWhenTemplateHadNoProperties() { + var existingTemplate = buildTemplate(List.of()); + var updatedTemplate = buildTemplate(List.of(prop("color"))); + + when(entityTemplateRepositoryPort.findByIdentifier(TEMPLATE_IDENTIFIER)) + .thenReturn(Optional.of(existingTemplate)); + when(entityTemplateRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + entityTemplateService.updateEntityTemplate(TEMPLATE_IDENTIFIER, updatedTemplate); + + verify(entityRepositoryPort, never()) + .deletePropertiesByTemplateIdentifierAndPropertyName(anyString(), any()); + } + + @Test + @DisplayName("Should match removed properties case-insensitively") + void shouldMatchRemovedPropertiesCaseInsensitively() { + var existingTemplate = buildTemplate(List.of(new PropertyDefinition(UUID.randomUUID(), + "Color", "desc", PropertyType.STRING, false, null))); + // Incoming uses lowercase — same logical property + var updatedTemplate = buildTemplate( + List.of(new PropertyDefinition(null, "color", "desc", PropertyType.STRING, false, null))); + + when(entityTemplateRepositoryPort.findByIdentifier(TEMPLATE_IDENTIFIER)) + .thenReturn(Optional.of(existingTemplate)); + when(entityTemplateRepositoryPort.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + entityTemplateService.updateEntityTemplate(TEMPLATE_IDENTIFIER, updatedTemplate); + + // "Color" and "color" are the same — no removal, no purge call + verify(entityRepositoryPort, never()) + .deletePropertiesByTemplateIdentifierAndPropertyName(anyString(), any()); } + } } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationServiceTest.java index daee862c..098ecca9 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationServiceTest.java @@ -9,14 +9,14 @@ import java.util.List; import java.util.UUID; -import com.decathlon.idp_core.domain.exception.entity_template.PropertyNameAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.entity_template.PropertyTypeChangeException; -import com.decathlon.idp_core.domain.exception.entity_template.PropertyDefinitionRulesConflictException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import com.decathlon.idp_core.domain.exception.entity_template.PropertyDefinitionRulesConflictException; +import com.decathlon.idp_core.domain.exception.entity_template.PropertyNameAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_template.PropertyTypeChangeException; import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; import com.decathlon.idp_core.domain.model.entity_template.PropertyRules; import com.decathlon.idp_core.domain.model.enums.PropertyFormat; @@ -25,1193 +25,715 @@ @DisplayName("PropertyDefinitionValidationService Tests") class PropertyDefinitionValidationServiceTest { - private PropertyDefinitionValidationService propertyDefinitionValidationService; + private PropertyDefinitionValidationService propertyDefinitionValidationService; + + @BeforeEach + void setUp() { + propertyDefinitionValidationService = new PropertyDefinitionValidationService( + new PropertyRegexValidationService()); + } + + @Nested + @DisplayName("STRING Property Type") + class StringPropertyTypeTests { + + @Test + @DisplayName("Happy path: STRING with format and max_length rules") + void testStringWithValidRules() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), PropertyFormat.EMAIL, null, null, + 255, 1, null, null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "email", + "Email address", PropertyType.STRING, true, rules); + + assertDoesNotThrow( + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + } + + @Test + @DisplayName("Happy path: STRING with min_length and max_length") + void testStringWithLengthConstraints() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, null, null, 100, 10, null, + null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "description", + "A description", PropertyType.STRING, false, rules); + + assertDoesNotThrow( + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + } + + @Test + @DisplayName("Happy path: STRING with enum_values") + void testStringWithEnumValues() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, + List.of("ACTIVE", "INACTIVE"), null, null, null, null, null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "status", "Status", + PropertyType.STRING, true, rules); + + assertDoesNotThrow( + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + } + + @Test + @DisplayName("Happy path: STRING with regex pattern") + void testStringWithRegex() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, null, "^[a-zA-Z0-9]+$", null, + null, null, null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "username", + "Username", PropertyType.STRING, true, rules); + + assertDoesNotThrow( + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + } + + @Test + @DisplayName("Error: STRING with numeric max_value rule") + void testStringRejectsMaxValue() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, null, null, null, null, 100, + null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "name", "Name", + PropertyType.STRING, true, rules); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + assertTrue(ex.getMessage().contains("name")); + assertTrue(ex.getMessage().contains("STRING")); + } + + @Test + @DisplayName("Error: STRING with numeric min_value rule") + void testStringRejectsMinValue() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, null, null, null, null, null, + 0); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "counter", "Counter", + PropertyType.STRING, false, rules); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + assertTrue(ex.getMessage().contains("counter")); + assertTrue(ex.getMessage().contains("STRING")); + } + + @Test + @DisplayName("Error: STRING with min_length > max_length") + void testStringWithInvalidLengthConstraints() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, null, null, 50, 100, null, + null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "field", "A field", + PropertyType.STRING, true, rules); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + assertTrue(ex.getMessage().contains("min_length")); + assertTrue(ex.getMessage().contains("max_length")); + } + + @Test + @DisplayName("Error: STRING with negative min_length") + void testStringWithNegativeMinLength() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, null, null, 255, -1, null, + null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "field", "A field", + PropertyType.STRING, true, rules); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + assertTrue(ex.getMessage().contains("min_length")); + assertTrue(ex.getMessage().contains("0")); + } + + @Test + @DisplayName("Error: STRING with invalid regex pattern") + void testStringWithInvalidRegexPattern() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, null, "[invalid-regex", 255, + null, null, null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "field", "A field", + PropertyType.STRING, true, rules); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + assertTrue(ex.getMessage().contains("regex")); + assertTrue(ex.getMessage().contains("[invalid-regex")); + } + + @Test + @DisplayName("Happy path: STRING with null rules") + void testStringWithNullRules() { + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "field", "A field", + PropertyType.STRING, true, null); + + assertDoesNotThrow( + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + } + + @Test + @DisplayName("Happy path: STRING with min_length = 0 and max_length > 0") + void testStringWithZeroMinLength() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, null, null, 100, 0, null, + null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "optional_field", + "An optional field", PropertyType.STRING, false, rules); + + assertDoesNotThrow( + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + } + + @Test + @DisplayName("Error: STRING with max_length <= 0") + void testStringWithNonPositiveMaxLength() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, null, null, 0, null, null, + null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "field", "A field", + PropertyType.STRING, true, rules); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + assertTrue(ex.getMessage().contains("max_length")); + assertTrue(ex.getMessage().contains("greater than 0")); + } + + @Test + @DisplayName("Error: STRING with format and enum_values combined") + void testStringRejectsFormatWithEnumValues() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), PropertyFormat.EMAIL, + List.of("EMAIL", "POSTAL_CODE"), null, null, null, null, null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "contact", + "Contact field", PropertyType.STRING, true, rules); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + assertTrue(ex.getMessage().contains("format")); + assertTrue(ex.getMessage().contains("enum_values")); + } + + @Test + @DisplayName("Error: STRING with format and regex combined") + void testStringRejectsFormatWithRegex() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), PropertyFormat.EMAIL, null, + "^[a-zA-Z]+$", null, null, null, null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "contact", + "Contact field", PropertyType.STRING, true, rules); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + assertTrue(ex.getMessage().contains("format")); + assertTrue(ex.getMessage().contains("regex")); + } + + @Test + @DisplayName("Error: STRING with regex and enum_values combined") + void testStringRejectsRegexWithEnumValues() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, + List.of("ACTIVE", "INACTIVE"), "^[A-Z]+$", null, null, null, null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "status", + "Status field", PropertyType.STRING, true, rules); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + assertTrue(ex.getMessage().contains("regex")); + assertTrue(ex.getMessage().contains("enum_values")); + } + + @Test + @DisplayName("Error: STRING with enum_values and max_length combined") + void testStringRejectsEnumValuesWithMaxLength() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, + List.of("EMAIL", "POSTAL_CODE"), null, 12, null, null, null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "contact_type", + "Contact type field", PropertyType.STRING, true, rules); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + assertTrue(ex.getMessage().contains("enum_values")); + assertTrue(ex.getMessage().contains("max_length")); + } + + @Test + @DisplayName("Error: STRING with enum_values and min_length combined") + void testStringRejectsEnumValuesWithMinLength() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, + List.of("EMAIL", "POSTAL_CODE"), null, null, 3, null, null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "contact_type", + "Contact type field", PropertyType.STRING, true, rules); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + assertTrue(ex.getMessage().contains("enum_values")); + assertTrue(ex.getMessage().contains("min_length")); + } + } + + @Nested + @DisplayName("NUMBER Property Type") + class NumberPropertyTypeTests { + + @Test + @DisplayName("Happy path: NUMBER with min_value and max_value") + void testNumberWithValidRules() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, null, null, null, null, 1000, + 0); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "score", + "Numeric score", PropertyType.NUMBER, true, rules); + + assertDoesNotThrow( + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + } + + @Test + @DisplayName("Happy path: NUMBER with only max_value") + void testNumberWithOnlyMaxValue() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, null, null, null, null, 100, + null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "percentage", + "Percentage value", PropertyType.NUMBER, false, rules); + + assertDoesNotThrow( + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + } + + @Test + @DisplayName("Error: NUMBER with format rule") + void testNumberRejectsFormat() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), PropertyFormat.EMAIL, null, null, + null, null, null, null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "value", + "Numeric value", PropertyType.NUMBER, true, rules); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + assertTrue(ex.getMessage().contains("value")); + assertTrue(ex.getMessage().contains("NUMBER")); + assertTrue(ex.getMessage().contains("format")); + } + + @Test + @DisplayName("Error: NUMBER with enum_values rule") + void testNumberRejectsEnumValues() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, List.of("1", "2", "3"), null, + null, null, null, null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "category", + "Category", PropertyType.NUMBER, true, rules); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + assertTrue(ex.getMessage().contains("enum_values")); + } + + @Test + @DisplayName("Error: NUMBER with regex rule") + void testNumberRejectsRegex() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, null, "^[0-9]+$", null, null, + null, null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "id", "ID", + PropertyType.NUMBER, true, rules); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + assertTrue(ex.getMessage().contains("regex")); + } + + @Test + @DisplayName("Error: NUMBER with min_length rule") + void testNumberRejectsMinLength() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, null, null, null, 5, null, + null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "field", "A field", + PropertyType.NUMBER, true, rules); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + assertTrue(ex.getMessage().contains("min_length")); + } + + @Test + @DisplayName("Error: NUMBER with max_length rule") + void testNumberRejectsMaxLength() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, null, null, 50, null, null, + null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "field", "A field", + PropertyType.NUMBER, true, rules); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + assertTrue(ex.getMessage().contains("max_length")); + } + + @Test + @DisplayName("Error: NUMBER with min_value > max_value") + void testNumberWithInvalidValueConstraints() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, null, null, null, null, 0, + 100); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "range", "A range", + PropertyType.NUMBER, true, rules); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + assertTrue(ex.getMessage().contains("min_value")); + assertTrue(ex.getMessage().contains("max_value")); + } + + @Test + @DisplayName("Happy path: NUMBER with only min_value") + void testNumberWithOnlyMinValue() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, null, null, null, null, null, + 10); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "minimum_age", + "Minimum age", PropertyType.NUMBER, false, rules); + + assertDoesNotThrow( + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + } + + @Test + @DisplayName("Happy path: NUMBER with negative min_value and max_value") + void testNumberWithNegativeValues() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, null, null, null, null, 100, + -100); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "temperature", + "Temperature", PropertyType.NUMBER, false, rules); + + assertDoesNotThrow( + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + } + + @Test + @DisplayName("Happy path: NUMBER with null rules") + void testNumberWithNullRules() { + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "count", "A count", + PropertyType.NUMBER, true, null); + + assertDoesNotThrow( + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + } + } + + @Nested + @DisplayName("BOOLEAN Property Type") + class BooleanPropertyTypeTests { + + @Test + @DisplayName("Happy path: BOOLEAN with no rules") + void testBooleanWithNullRules() { + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "active", "Is active", + PropertyType.BOOLEAN, true, null); + + assertDoesNotThrow( + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + } + + @Test + @DisplayName("Error: BOOLEAN with format rule") + void testBooleanRejectsFormat() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), PropertyFormat.EMAIL, null, null, + null, null, null, null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "enabled", "Enabled", + PropertyType.BOOLEAN, true, rules); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + assertTrue(ex.getMessage().contains("BOOLEAN")); + assertTrue(ex.getMessage().contains("rules")); + } + + @Test + @DisplayName("Error: BOOLEAN with enum_values rule") + void testBooleanRejectsEnumValues() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, List.of("true", "false"), + null, null, null, null, null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "flag", "A flag", + PropertyType.BOOLEAN, true, rules); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + assertTrue(ex.getMessage().contains("BOOLEAN")); + } + + @Test + @DisplayName("Error: BOOLEAN with regex rule") + void testBooleanRejectsRegex() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, null, ".*", null, null, null, + null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "test", "Test", + PropertyType.BOOLEAN, true, rules); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + assertTrue(ex.getMessage().contains("BOOLEAN")); + } + + @Test + @DisplayName("Error: BOOLEAN with min_value rule") + void testBooleanRejectsMinValue() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, null, null, null, null, null, + 0); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "valid", "Valid", + PropertyType.BOOLEAN, true, rules); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + assertTrue(ex.getMessage().contains("BOOLEAN")); + } + + @Test + @DisplayName("Error: BOOLEAN with max_value rule") + void testBooleanRejectsMaxValue() { + PropertyRules rules = new PropertyRules(UUID.randomUUID(), null, null, null, null, null, 1, + null); + PropertyDefinition property = new PropertyDefinition(UUID.randomUUID(), "valid", "Valid", + PropertyType.BOOLEAN, true, rules); + + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); + assertTrue(ex.getMessage().contains("BOOLEAN")); + } + } + + @Nested + @DisplayName("validateUniquePropertyNames") + class ValidateUniquePropertyNamesTests { + + @Test + @DisplayName("Happy path: all property names are unique") + void testUniquePropertyNames() { + List properties = List.of( + new PropertyDefinition(UUID.randomUUID(), "name", "Name", PropertyType.STRING, true, + null), + new PropertyDefinition(UUID.randomUUID(), "age", "Age", PropertyType.NUMBER, false, null), + new PropertyDefinition(UUID.randomUUID(), "active", "Active", PropertyType.BOOLEAN, true, + null)); + + assertDoesNotThrow( + () -> propertyDefinitionValidationService.validatePropertyNamesUniqueness(properties)); + } + + @Test + @DisplayName("Happy path: empty property list") + void testEmptyPropertyList() { + assertDoesNotThrow(() -> propertyDefinitionValidationService + .validatePropertyNamesUniqueness(new ArrayList<>())); + } + + @Test + @DisplayName("Error: duplicate property names") + void testDuplicatePropertyNames() { + List properties = List.of( + new PropertyDefinition(UUID.randomUUID(), "email", "Email", PropertyType.STRING, true, + null), + new PropertyDefinition(UUID.randomUUID(), "email", "Alternative Email", + PropertyType.STRING, false, null)); + + PropertyNameAlreadyExistsException ex = assertThrows(PropertyNameAlreadyExistsException.class, + () -> propertyDefinitionValidationService.validatePropertyNamesUniqueness(properties)); + assertTrue(ex.getMessage().contains("email")); + } + + @Test + @DisplayName("Error: multiple duplicates detected") + void testMultipleDuplicates() { + List properties = List.of( + new PropertyDefinition(UUID.randomUUID(), "name", "Name", PropertyType.STRING, true, + null), + new PropertyDefinition(UUID.randomUUID(), "name", "Duplicate 1", PropertyType.STRING, + false, null), + new PropertyDefinition(UUID.randomUUID(), "email", "Email", PropertyType.STRING, true, + null), + new PropertyDefinition(UUID.randomUUID(), "email", "Duplicate Email", PropertyType.STRING, + false, null)); + + PropertyNameAlreadyExistsException ex = assertThrows(PropertyNameAlreadyExistsException.class, + () -> propertyDefinitionValidationService.validatePropertyNamesUniqueness(properties)); + // Should fail on first duplicate found + assertTrue(ex.getMessage().contains("name")); + } + + @Test + @DisplayName("Error: case-insensitive duplicates (Name vs name)") + void testCaseInsensitiveDuplicates() { + List properties = List.of( + new PropertyDefinition(UUID.randomUUID(), "applicationName", "Application Name", + PropertyType.STRING, true, null), + new PropertyDefinition(UUID.randomUUID(), "applicationname", + "Application Name (lowercase)", PropertyType.STRING, false, null)); + + PropertyNameAlreadyExistsException ex = assertThrows(PropertyNameAlreadyExistsException.class, + () -> propertyDefinitionValidationService.validatePropertyNamesUniqueness(properties)); + assertTrue(ex.getMessage().contains("applicationname")); + } + } + + @Nested + @DisplayName("validateTypeChanges") + class ValidateTypeChangesTests { + + @Test + @DisplayName("Happy path: no existing properties") + void testNoExistingProperties() { + List updated = List.of(new PropertyDefinition(UUID.randomUUID(), "name", + "Name", PropertyType.STRING, true, null)); + + assertDoesNotThrow( + () -> propertyDefinitionValidationService.validateTypeChanges(null, updated)); + } + + @Test + @DisplayName("Happy path: no type changes") + void testNoTypeChanges() { + UUID propertyId = UUID.randomUUID(); + List existing = List + .of(new PropertyDefinition(propertyId, "name", "Name", PropertyType.STRING, true, null)); + List updated = List.of(new PropertyDefinition(propertyId, "name", + "Updated Name", PropertyType.STRING, false, null)); + + assertDoesNotThrow( + () -> propertyDefinitionValidationService.validateTypeChanges(existing, updated)); + } + + @Test + @DisplayName("Error: conversion NUMBER to STRING is forbidden") + void testConversionNumberToStringForbidden() { + List existing = List.of( + new PropertyDefinition(UUID.randomUUID(), "age", "Age", PropertyType.NUMBER, true, null)); + List updated = List.of( + new PropertyDefinition(UUID.randomUUID(), "age", "Age", PropertyType.STRING, true, null)); + + PropertyTypeChangeException ex = assertThrows(PropertyTypeChangeException.class, + () -> propertyDefinitionValidationService.validateTypeChanges(existing, updated)); + assertTrue(ex.getMessage().contains("age")); + assertTrue(ex.getMessage().contains("NUMBER")); + assertTrue(ex.getMessage().contains("STRING")); + } + + @Test + @DisplayName("Error: conversion BOOLEAN to STRING is forbidden") + void testConversionBooleanToStringForbidden() { + List existing = List.of(new PropertyDefinition(UUID.randomUUID(), + "active", "Active", PropertyType.BOOLEAN, true, null)); + List updated = List.of(new PropertyDefinition(UUID.randomUUID(), "active", + "Active", PropertyType.STRING, true, null)); + + PropertyTypeChangeException ex = assertThrows(PropertyTypeChangeException.class, + () -> propertyDefinitionValidationService.validateTypeChanges(existing, updated)); + assertTrue(ex.getMessage().contains("active")); + assertTrue(ex.getMessage().contains("BOOLEAN")); + assertTrue(ex.getMessage().contains("STRING")); + } - @BeforeEach - void setUp() { - propertyDefinitionValidationService = new PropertyDefinitionValidationService(new PropertyRegexValidationService()); + @Test + @DisplayName("Error: any type conversion STRING to NUMBER is forbidden") + void testConversionStringToNumberForbidden() { + List existing = List.of(new PropertyDefinition(UUID.randomUUID(), "code", + "Code", PropertyType.STRING, true, null)); + List updated = List.of(new PropertyDefinition(UUID.randomUUID(), "code", + "Code", PropertyType.NUMBER, true, null)); + + PropertyTypeChangeException ex = assertThrows(PropertyTypeChangeException.class, + () -> propertyDefinitionValidationService.validateTypeChanges(existing, updated)); + assertTrue(ex.getMessage().contains("code")); + assertTrue(ex.getMessage().contains("STRING")); + assertTrue(ex.getMessage().contains("NUMBER")); } - @Nested - @DisplayName("STRING Property Type") - class StringPropertyTypeTests { - - @Test - @DisplayName("Happy path: STRING with format and max_length rules") - void testStringWithValidRules() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - PropertyFormat.EMAIL, - null, - null, - 255, - 1, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "email", - "Email address", - PropertyType.STRING, - true, - rules - ); - - assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); - } - - @Test - @DisplayName("Happy path: STRING with min_length and max_length") - void testStringWithLengthConstraints() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - null, - null, - 100, - 10, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "description", - "A description", - PropertyType.STRING, - false, - rules - ); - - assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); - } - - @Test - @DisplayName("Happy path: STRING with enum_values") - void testStringWithEnumValues() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - List.of("ACTIVE", "INACTIVE"), - null, - null, - null, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "status", - "Status", - PropertyType.STRING, - true, - rules - ); - - assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); - } - - @Test - @DisplayName("Happy path: STRING with regex pattern") - void testStringWithRegex() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - null, - "^[a-zA-Z0-9]+$", - null, - null, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "username", - "Username", - PropertyType.STRING, - true, - rules - ); - - assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); - } - - @Test - @DisplayName("Error: STRING with numeric max_value rule") - void testStringRejectsMaxValue() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - null, - null, - null, - null, - 100, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "name", - "Name", - PropertyType.STRING, - true, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("name")); - assertTrue(ex.getMessage().contains("STRING")); - } - - @Test - @DisplayName("Error: STRING with numeric min_value rule") - void testStringRejectsMinValue() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - null, - null, - null, - null, - null, - 0 - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "counter", - "Counter", - PropertyType.STRING, - false, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("counter")); - assertTrue(ex.getMessage().contains("STRING")); - } - - @Test - @DisplayName("Error: STRING with min_length > max_length") - void testStringWithInvalidLengthConstraints() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - null, - null, - 50, - 100, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "field", - "A field", - PropertyType.STRING, - true, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("min_length")); - assertTrue(ex.getMessage().contains("max_length")); - } - - @Test - @DisplayName("Error: STRING with negative min_length") - void testStringWithNegativeMinLength() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - null, - null, - 255, - -1, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "field", - "A field", - PropertyType.STRING, - true, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("min_length")); - assertTrue(ex.getMessage().contains("0")); - } - - @Test - @DisplayName("Error: STRING with invalid regex pattern") - void testStringWithInvalidRegexPattern() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - null, - "[invalid-regex", - 255, - null, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "field", - "A field", - PropertyType.STRING, - true, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("regex")); - assertTrue(ex.getMessage().contains("[invalid-regex")); - } - - @Test - @DisplayName("Happy path: STRING with null rules") - void testStringWithNullRules() { - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "field", - "A field", - PropertyType.STRING, - true, - null - ); - - assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); - } - - @Test - @DisplayName("Happy path: STRING with min_length = 0 and max_length > 0") - void testStringWithZeroMinLength() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - null, - null, - 100, - 0, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "optional_field", - "An optional field", - PropertyType.STRING, - false, - rules - ); - - assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); - } - - @Test - @DisplayName("Error: STRING with max_length <= 0") - void testStringWithNonPositiveMaxLength() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - null, - null, - 0, - null, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "field", - "A field", - PropertyType.STRING, - true, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("max_length")); - assertTrue(ex.getMessage().contains("greater than 0")); - } - - @Test - @DisplayName("Error: STRING with format and enum_values combined") - void testStringRejectsFormatWithEnumValues() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - PropertyFormat.EMAIL, - List.of("EMAIL", "POSTAL_CODE"), - null, - null, - null, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "contact", - "Contact field", - PropertyType.STRING, - true, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("format")); - assertTrue(ex.getMessage().contains("enum_values")); - } - - @Test - @DisplayName("Error: STRING with format and regex combined") - void testStringRejectsFormatWithRegex() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - PropertyFormat.EMAIL, - null, - "^[a-zA-Z]+$", - null, - null, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "contact", - "Contact field", - PropertyType.STRING, - true, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("format")); - assertTrue(ex.getMessage().contains("regex")); - } - - @Test - @DisplayName("Error: STRING with regex and enum_values combined") - void testStringRejectsRegexWithEnumValues() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - List.of("ACTIVE", "INACTIVE"), - "^[A-Z]+$", - null, - null, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "status", - "Status field", - PropertyType.STRING, - true, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("regex")); - assertTrue(ex.getMessage().contains("enum_values")); - } - - @Test - @DisplayName("Error: STRING with enum_values and max_length combined") - void testStringRejectsEnumValuesWithMaxLength() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - List.of("EMAIL", "POSTAL_CODE"), - null, - 12, - null, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "contact_type", - "Contact type field", - PropertyType.STRING, - true, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("enum_values")); - assertTrue(ex.getMessage().contains("max_length")); - } - - @Test - @DisplayName("Error: STRING with enum_values and min_length combined") - void testStringRejectsEnumValuesWithMinLength() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - List.of("EMAIL", "POSTAL_CODE"), - null, - null, - 3, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "contact_type", - "Contact type field", - PropertyType.STRING, - true, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("enum_values")); - assertTrue(ex.getMessage().contains("min_length")); - } + @Test + @DisplayName("Error: any type conversion NUMBER to BOOLEAN is forbidden") + void testConversionNumberToBooleanForbidden() { + List existing = List.of(new PropertyDefinition(UUID.randomUUID(), "count", + "Count", PropertyType.NUMBER, true, null)); + List updated = List.of(new PropertyDefinition(UUID.randomUUID(), "count", + "Count", PropertyType.BOOLEAN, true, null)); + + PropertyTypeChangeException ex = assertThrows(PropertyTypeChangeException.class, + () -> propertyDefinitionValidationService.validateTypeChanges(existing, updated)); + assertTrue(ex.getMessage().contains("count")); + assertTrue(ex.getMessage().contains("NUMBER")); + assertTrue(ex.getMessage().contains("BOOLEAN")); } - @Nested - @DisplayName("NUMBER Property Type") - class NumberPropertyTypeTests { - - @Test - @DisplayName("Happy path: NUMBER with min_value and max_value") - void testNumberWithValidRules() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - null, - null, - null, - null, - 1000, - 0 - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "score", - "Numeric score", - PropertyType.NUMBER, - true, - rules - ); - - assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); - } - - @Test - @DisplayName("Happy path: NUMBER with only max_value") - void testNumberWithOnlyMaxValue() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - null, - null, - null, - null, - 100, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "percentage", - "Percentage value", - PropertyType.NUMBER, - false, - rules - ); - - assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); - } - - @Test - @DisplayName("Error: NUMBER with format rule") - void testNumberRejectsFormat() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - PropertyFormat.EMAIL, - null, - null, - null, - null, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "value", - "Numeric value", - PropertyType.NUMBER, - true, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("value")); - assertTrue(ex.getMessage().contains("NUMBER")); - assertTrue(ex.getMessage().contains("format")); - } - - @Test - @DisplayName("Error: NUMBER with enum_values rule") - void testNumberRejectsEnumValues() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - List.of("1", "2", "3"), - null, - null, - null, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "category", - "Category", - PropertyType.NUMBER, - true, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("enum_values")); - } - - @Test - @DisplayName("Error: NUMBER with regex rule") - void testNumberRejectsRegex() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - null, - "^[0-9]+$", - null, - null, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "id", - "ID", - PropertyType.NUMBER, - true, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("regex")); - } - - @Test - @DisplayName("Error: NUMBER with min_length rule") - void testNumberRejectsMinLength() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - null, - null, - null, - 5, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "field", - "A field", - PropertyType.NUMBER, - true, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("min_length")); - } - - @Test - @DisplayName("Error: NUMBER with max_length rule") - void testNumberRejectsMaxLength() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - null, - null, - 50, - null, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "field", - "A field", - PropertyType.NUMBER, - true, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("max_length")); - } - - @Test - @DisplayName("Error: NUMBER with min_value > max_value") - void testNumberWithInvalidValueConstraints() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - null, - null, - null, - null, - 0, - 100 - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "range", - "A range", - PropertyType.NUMBER, - true, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("min_value")); - assertTrue(ex.getMessage().contains("max_value")); - } - - @Test - @DisplayName("Happy path: NUMBER with only min_value") - void testNumberWithOnlyMinValue() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - null, - null, - null, - null, - null, - 10 - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "minimum_age", - "Minimum age", - PropertyType.NUMBER, - false, - rules - ); - - assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); - } - - @Test - @DisplayName("Happy path: NUMBER with negative min_value and max_value") - void testNumberWithNegativeValues() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - null, - null, - null, - null, - 100, - -100 - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "temperature", - "Temperature", - PropertyType.NUMBER, - false, - rules - ); - - assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); - } - - @Test - @DisplayName("Happy path: NUMBER with null rules") - void testNumberWithNullRules() { - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "count", - "A count", - PropertyType.NUMBER, - true, - null - ); - - assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); - } + @Test + @DisplayName("Error: any type conversion BOOLEAN to NUMBER is forbidden") + void testConversionBooleanToNumberForbidden() { + List existing = List.of(new PropertyDefinition(UUID.randomUUID(), + "active", "Active", PropertyType.BOOLEAN, true, null)); + List updated = List.of(new PropertyDefinition(UUID.randomUUID(), "active", + "Active", PropertyType.NUMBER, true, null)); + + PropertyTypeChangeException ex = assertThrows(PropertyTypeChangeException.class, + () -> propertyDefinitionValidationService.validateTypeChanges(existing, updated)); + assertTrue(ex.getMessage().contains("active")); + assertTrue(ex.getMessage().contains("BOOLEAN")); + assertTrue(ex.getMessage().contains("NUMBER")); } - @Nested - @DisplayName("BOOLEAN Property Type") - class BooleanPropertyTypeTests { - - @Test - @DisplayName("Happy path: BOOLEAN with no rules") - void testBooleanWithNullRules() { - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "active", - "Is active", - PropertyType.BOOLEAN, - true, - null - ); - - assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property)); - } - - @Test - @DisplayName("Error: BOOLEAN with format rule") - void testBooleanRejectsFormat() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - PropertyFormat.EMAIL, - null, - null, - null, - null, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "enabled", - "Enabled", - PropertyType.BOOLEAN, - true, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("BOOLEAN")); - assertTrue(ex.getMessage().contains("rules")); - } - - @Test - @DisplayName("Error: BOOLEAN with enum_values rule") - void testBooleanRejectsEnumValues() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - List.of("true", "false"), - null, - null, - null, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "flag", - "A flag", - PropertyType.BOOLEAN, - true, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("BOOLEAN")); - } - - @Test - @DisplayName("Error: BOOLEAN with regex rule") - void testBooleanRejectsRegex() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - null, - ".*", - null, - null, - null, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "test", - "Test", - PropertyType.BOOLEAN, - true, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("BOOLEAN")); - } - - @Test - @DisplayName("Error: BOOLEAN with min_value rule") - void testBooleanRejectsMinValue() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - null, - null, - null, - null, - null, - 0 - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "valid", - "Valid", - PropertyType.BOOLEAN, - true, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("BOOLEAN")); - } - - @Test - @DisplayName("Error: BOOLEAN with max_value rule") - void testBooleanRejectsMaxValue() { - PropertyRules rules = new PropertyRules( - UUID.randomUUID(), - null, - null, - null, - null, - null, - 1, - null - ); - PropertyDefinition property = new PropertyDefinition( - UUID.randomUUID(), - "valid", - "Valid", - PropertyType.BOOLEAN, - true, - rules - ); - - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyDefinitionValidationService.validatePropertyDefinitionRules(property) - ); - assertTrue(ex.getMessage().contains("BOOLEAN")); - } + @Test + @DisplayName("Happy path: property removed from updated list") + void testPropertyRemoved() { + List existing = List.of( + new PropertyDefinition(UUID.randomUUID(), "name", "Name", PropertyType.STRING, true, + null), + new PropertyDefinition(UUID.randomUUID(), "age", "Age", PropertyType.NUMBER, false, + null)); + List updated = List.of(new PropertyDefinition(UUID.randomUUID(), "name", + "Name", PropertyType.STRING, true, null)); + + assertDoesNotThrow( + () -> propertyDefinitionValidationService.validateTypeChanges(existing, updated)); } - @Nested - @DisplayName("validateUniquePropertyNames") - class ValidateUniquePropertyNamesTests { - - @Test - @DisplayName("Happy path: all property names are unique") - void testUniquePropertyNames() { - List properties = List.of( - new PropertyDefinition(UUID.randomUUID(), "name", "Name", PropertyType.STRING, true, null), - new PropertyDefinition(UUID.randomUUID(), "age", "Age", PropertyType.NUMBER, false, null), - new PropertyDefinition(UUID.randomUUID(), "active", "Active", PropertyType.BOOLEAN, true, null) - ); - - assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyNamesUniqueness(properties)); - } - - @Test - @DisplayName("Happy path: empty property list") - void testEmptyPropertyList() { - assertDoesNotThrow(() -> propertyDefinitionValidationService.validatePropertyNamesUniqueness(new ArrayList<>())); - } - - @Test - @DisplayName("Error: duplicate property names") - void testDuplicatePropertyNames() { - List properties = List.of( - new PropertyDefinition(UUID.randomUUID(), "email", "Email", PropertyType.STRING, true, null), - new PropertyDefinition(UUID.randomUUID(), "email", "Alternative Email", PropertyType.STRING, false, null) - ); - - PropertyNameAlreadyExistsException ex = assertThrows( - PropertyNameAlreadyExistsException.class, - () -> propertyDefinitionValidationService.validatePropertyNamesUniqueness(properties) - ); - assertTrue(ex.getMessage().contains("email")); - } - - @Test - @DisplayName("Error: multiple duplicates detected") - void testMultipleDuplicates() { - List properties = List.of( - new PropertyDefinition(UUID.randomUUID(), "name", "Name", PropertyType.STRING, true, null), - new PropertyDefinition(UUID.randomUUID(), "name", "Duplicate 1", PropertyType.STRING, false, null), - new PropertyDefinition(UUID.randomUUID(), "email", "Email", PropertyType.STRING, true, null), - new PropertyDefinition(UUID.randomUUID(), "email", "Duplicate Email", PropertyType.STRING, false, null) - ); - - PropertyNameAlreadyExistsException ex = assertThrows( - PropertyNameAlreadyExistsException.class, - () -> propertyDefinitionValidationService.validatePropertyNamesUniqueness(properties) - ); - // Should fail on first duplicate found - assertTrue(ex.getMessage().contains("name")); - } - - @Test - @DisplayName("Error: case-insensitive duplicates (Name vs name)") - void testCaseInsensitiveDuplicates() { - List properties = List.of( - new PropertyDefinition(UUID.randomUUID(), "applicationName", "Application Name", PropertyType.STRING, true, null), - new PropertyDefinition(UUID.randomUUID(), "applicationname", "Application Name (lowercase)", PropertyType.STRING, false, null) - ); - - PropertyNameAlreadyExistsException ex = assertThrows( - PropertyNameAlreadyExistsException.class, - () -> propertyDefinitionValidationService.validatePropertyNamesUniqueness(properties) - ); - assertTrue(ex.getMessage().contains("applicationname")); - } + @Test + @DisplayName("Happy path: new property added to updated list") + void testPropertyAdded() { + List existing = List.of(new PropertyDefinition(UUID.randomUUID(), "name", + "Name", PropertyType.STRING, true, null)); + List updated = List.of( + new PropertyDefinition(UUID.randomUUID(), "name", "Name", PropertyType.STRING, true, + null), + new PropertyDefinition(UUID.randomUUID(), "email", "Email", PropertyType.STRING, false, + null)); + + assertDoesNotThrow( + () -> propertyDefinitionValidationService.validateTypeChanges(existing, updated)); } - @Nested - @DisplayName("validateTypeChanges") - class ValidateTypeChangesTests { - - @Test - @DisplayName("Happy path: no existing properties") - void testNoExistingProperties() { - List updated = List.of( - new PropertyDefinition(UUID.randomUUID(), "name", "Name", PropertyType.STRING, true, null) - ); - - assertDoesNotThrow(() -> propertyDefinitionValidationService.validateTypeChanges(null, updated)); - } - - @Test - @DisplayName("Happy path: no type changes") - void testNoTypeChanges() { - UUID propertyId = UUID.randomUUID(); - List existing = List.of( - new PropertyDefinition(propertyId, "name", "Name", PropertyType.STRING, true, null) - ); - List updated = List.of( - new PropertyDefinition(propertyId, "name", "Updated Name", PropertyType.STRING, false, null) - ); - - assertDoesNotThrow(() -> propertyDefinitionValidationService.validateTypeChanges(existing, updated)); - } - - @Test - @DisplayName("Error: conversion NUMBER to STRING is forbidden") - void testConversionNumberToStringForbidden() { - List existing = List.of( - new PropertyDefinition(UUID.randomUUID(), "age", "Age", PropertyType.NUMBER, true, null) - ); - List updated = List.of( - new PropertyDefinition(UUID.randomUUID(), "age", "Age", PropertyType.STRING, true, null) - ); - - PropertyTypeChangeException ex = assertThrows( - PropertyTypeChangeException.class, - () -> propertyDefinitionValidationService.validateTypeChanges(existing, updated) - ); - assertTrue(ex.getMessage().contains("age")); - assertTrue(ex.getMessage().contains("NUMBER")); - assertTrue(ex.getMessage().contains("STRING")); - } - - @Test - @DisplayName("Error: conversion BOOLEAN to STRING is forbidden") - void testConversionBooleanToStringForbidden() { - List existing = List.of( - new PropertyDefinition(UUID.randomUUID(), "active", "Active", PropertyType.BOOLEAN, true, null) - ); - List updated = List.of( - new PropertyDefinition(UUID.randomUUID(), "active", "Active", PropertyType.STRING, true, null) - ); - - PropertyTypeChangeException ex = assertThrows( - PropertyTypeChangeException.class, - () -> propertyDefinitionValidationService.validateTypeChanges(existing, updated) - ); - assertTrue(ex.getMessage().contains("active")); - assertTrue(ex.getMessage().contains("BOOLEAN")); - assertTrue(ex.getMessage().contains("STRING")); - } - - @Test - @DisplayName("Error: any type conversion STRING to NUMBER is forbidden") - void testConversionStringToNumberForbidden() { - List existing = List.of( - new PropertyDefinition(UUID.randomUUID(), "code", "Code", PropertyType.STRING, true, null) - ); - List updated = List.of( - new PropertyDefinition(UUID.randomUUID(), "code", "Code", PropertyType.NUMBER, true, null) - ); - - PropertyTypeChangeException ex = assertThrows( - PropertyTypeChangeException.class, - () -> propertyDefinitionValidationService.validateTypeChanges(existing, updated) - ); - assertTrue(ex.getMessage().contains("code")); - assertTrue(ex.getMessage().contains("STRING")); - assertTrue(ex.getMessage().contains("NUMBER")); - } - - @Test - @DisplayName("Error: any type conversion NUMBER to BOOLEAN is forbidden") - void testConversionNumberToBooleanForbidden() { - List existing = List.of( - new PropertyDefinition(UUID.randomUUID(), "count", "Count", PropertyType.NUMBER, true, null) - ); - List updated = List.of( - new PropertyDefinition(UUID.randomUUID(), "count", "Count", PropertyType.BOOLEAN, true, null) - ); - - PropertyTypeChangeException ex = assertThrows( - PropertyTypeChangeException.class, - () -> propertyDefinitionValidationService.validateTypeChanges(existing, updated) - ); - assertTrue(ex.getMessage().contains("count")); - assertTrue(ex.getMessage().contains("NUMBER")); - assertTrue(ex.getMessage().contains("BOOLEAN")); - } - - @Test - @DisplayName("Error: any type conversion BOOLEAN to NUMBER is forbidden") - void testConversionBooleanToNumberForbidden() { - List existing = List.of( - new PropertyDefinition(UUID.randomUUID(), "active", "Active", PropertyType.BOOLEAN, true, null) - ); - List updated = List.of( - new PropertyDefinition(UUID.randomUUID(), "active", "Active", PropertyType.NUMBER, true, null) - ); - - PropertyTypeChangeException ex = assertThrows( - PropertyTypeChangeException.class, - () -> propertyDefinitionValidationService.validateTypeChanges(existing, updated) - ); - assertTrue(ex.getMessage().contains("active")); - assertTrue(ex.getMessage().contains("BOOLEAN")); - assertTrue(ex.getMessage().contains("NUMBER")); - } - - @Test - @DisplayName("Happy path: property removed from updated list") - void testPropertyRemoved() { - List existing = List.of( - new PropertyDefinition(UUID.randomUUID(), "name", "Name", PropertyType.STRING, true, null), - new PropertyDefinition(UUID.randomUUID(), "age", "Age", PropertyType.NUMBER, false, null) - ); - List updated = List.of( - new PropertyDefinition(UUID.randomUUID(), "name", "Name", PropertyType.STRING, true, null) - ); - - assertDoesNotThrow(() -> propertyDefinitionValidationService.validateTypeChanges(existing, updated)); - } - - @Test - @DisplayName("Happy path: new property added to updated list") - void testPropertyAdded() { - List existing = List.of( - new PropertyDefinition(UUID.randomUUID(), "name", "Name", PropertyType.STRING, true, null) - ); - List updated = List.of( - new PropertyDefinition(UUID.randomUUID(), "name", "Name", PropertyType.STRING, true, null), - new PropertyDefinition(UUID.randomUUID(), "email", "Email", PropertyType.STRING, false, null) - ); - - assertDoesNotThrow(() -> propertyDefinitionValidationService.validateTypeChanges(existing, updated)); - } - - @Test - @DisplayName("Error: multiple type conversions forbidden, fails on first") - void testMultipleTypeConversionsForbidden() { - List existing = List.of( - new PropertyDefinition(UUID.randomUUID(), "field1", "Field 1", PropertyType.STRING, true, null), - new PropertyDefinition(UUID.randomUUID(), "field2", "Field 2", PropertyType.NUMBER, true, null) - ); - List updated = List.of( - new PropertyDefinition(UUID.randomUUID(), "field1", "Field 1", PropertyType.NUMBER, true, null), - new PropertyDefinition(UUID.randomUUID(), "field2", "Field 2", PropertyType.BOOLEAN, true, null) - ); - - PropertyTypeChangeException ex = assertThrows( - PropertyTypeChangeException.class, - () -> propertyDefinitionValidationService.validateTypeChanges(existing, updated) - ); - assertTrue(ex.getMessage().contains("field1")); - assertTrue(ex.getMessage().contains("STRING")); - assertTrue(ex.getMessage().contains("NUMBER")); - assertFalse(ex.getMessage().contains("BOOLEAN")); - } + @Test + @DisplayName("Error: multiple type conversions forbidden, fails on first") + void testMultipleTypeConversionsForbidden() { + List existing = List.of( + new PropertyDefinition(UUID.randomUUID(), "field1", "Field 1", PropertyType.STRING, true, + null), + new PropertyDefinition(UUID.randomUUID(), "field2", "Field 2", PropertyType.NUMBER, true, + null)); + List updated = List.of( + new PropertyDefinition(UUID.randomUUID(), "field1", "Field 1", PropertyType.NUMBER, true, + null), + new PropertyDefinition(UUID.randomUUID(), "field2", "Field 2", PropertyType.BOOLEAN, true, + null)); + + PropertyTypeChangeException ex = assertThrows(PropertyTypeChangeException.class, + () -> propertyDefinitionValidationService.validateTypeChanges(existing, updated)); + assertTrue(ex.getMessage().contains("field1")); + assertTrue(ex.getMessage().contains("STRING")); + assertTrue(ex.getMessage().contains("NUMBER")); + assertFalse(ex.getMessage().contains("BOOLEAN")); } + } } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationServiceTest.java index 1e9827fc..31e86291 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_template/PropertyRegexValidationServiceTest.java @@ -15,69 +15,65 @@ @DisplayName("PropertyRegexValidationService Tests") class PropertyRegexValidationServiceTest { - private PropertyRegexValidationService propertyRegexValidationService; + private PropertyRegexValidationService propertyRegexValidationService; - @BeforeEach - void setUp() { - propertyRegexValidationService = new PropertyRegexValidationService(); - } + @BeforeEach + void setUp() { + propertyRegexValidationService = new PropertyRegexValidationService(); + } - @ParameterizedTest - @ValueSource(strings = { - "^[a-z0-9]+@[a-z0-9]+\\.[a-z]{2,}$", // email-like pattern - "^(foo|bar)$", // safe alternation, not quantified - "a{1,999}", // safe repetition bound - "^[a-zA-Z0-9_-]+$", // alphanumeric slug - "^\\d{4}-\\d{2}-\\d{2}$" // ISO date - }) - @DisplayName("Happy path: safe regex patterns are accepted") - void testSafeRegexPatternsAccepted(String safePattern) { - assertDoesNotThrow(() -> propertyRegexValidationService.validateRegexPattern("field", safePattern)); - } + @ParameterizedTest + @ValueSource(strings = {"^[a-z0-9]+@[a-z0-9]+\\.[a-z]{2,}$", // email-like pattern + "^(foo|bar)$", // safe alternation, not quantified + "a{1,999}", // safe repetition bound + "^[a-zA-Z0-9_-]+$", // alphanumeric slug + "^\\d{4}-\\d{2}-\\d{2}$" // ISO date + }) + @DisplayName("Happy path: safe regex patterns are accepted") + void testSafeRegexPatternsAccepted(String safePattern) { + assertDoesNotThrow( + () -> propertyRegexValidationService.validateRegexPattern("field", safePattern)); + } - @Test - @DisplayName("Error: Regex pattern exceeds maximum length (1000 chars)") - void testRegexPatternTooLong() { - String longPattern = "a".repeat(1001); - String propertyName = "field"; + @Test + @DisplayName("Error: Regex pattern exceeds maximum length (1000 chars)") + void testRegexPatternTooLong() { + String longPattern = "a".repeat(1001); + String propertyName = "field"; - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyRegexValidationService.validateRegexPattern(propertyName, longPattern) - ); - assertTrue(ex.getMessage().contains("too long")); - assertTrue(ex.getMessage().contains("1000")); - } + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyRegexValidationService.validateRegexPattern(propertyName, longPattern)); + assertTrue(ex.getMessage().contains("too long")); + assertTrue(ex.getMessage().contains("1000")); + } - @ParameterizedTest - @ValueSource(strings = { - "(a+)+", // nested quantifiers with + - "(a*)*", // nested quantifiers with * - "(a+)*", // mixed nested quantifiers - "(a|b)+", // quantified alternation with + - "(foo|bar)*", // quantified alternation with * - "a{1,5000}" // excessive repetition bound - }) - @DisplayName("Error: Regex patterns with ReDoS vulnerabilities") - void testRegexWithDangerousPatterns(String dangerousPattern) { - String propertyName = "field"; + @ParameterizedTest + @ValueSource(strings = {"(a+)+", // nested quantifiers with + + "(a*)*", // nested quantifiers with * + "(a+)*", // mixed nested quantifiers + "(a|b)+", // quantified alternation with + + "(foo|bar)*", // quantified alternation with * + "a{1,5000}" // excessive repetition bound + }) + @DisplayName("Error: Regex patterns with ReDoS vulnerabilities") + void testRegexWithDangerousPatterns(String dangerousPattern) { + String propertyName = "field"; - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyRegexValidationService.validateRegexPattern(propertyName, dangerousPattern) - ); - assertTrue(ex.getMessage().contains("unsafe"), - "Expected 'unsafe' in error message for pattern: " + dangerousPattern); - } + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyRegexValidationService.validateRegexPattern(propertyName, dangerousPattern)); + assertTrue(ex.getMessage().contains("unsafe"), + "Expected 'unsafe' in error message for pattern: " + dangerousPattern); + } - @Test - @DisplayName("Error: Regex with invalid syntax") - void testRegexWithInvalidSyntax() { - PropertyDefinitionRulesConflictException ex = assertThrows( - PropertyDefinitionRulesConflictException.class, - () -> propertyRegexValidationService.validateRegexPattern("field", "[unclosed-bracket") - ); - assertTrue(ex.getMessage().contains("Invalid regex")); - } + @Test + @DisplayName("Error: Regex with invalid syntax") + void testRegexWithInvalidSyntax() { + PropertyDefinitionRulesConflictException ex = assertThrows( + PropertyDefinitionRulesConflictException.class, + () -> propertyRegexValidationService.validateRegexPattern("field", "[unclosed-bracket")); + assertTrue(ex.getMessage().contains("Invalid regex")); + } } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_template/RelationDefinitionValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_template/RelationDefinitionValidationServiceTest.java index 4d9513db..8c29ba02 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity_template/RelationDefinitionValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_template/RelationDefinitionValidationServiceTest.java @@ -28,318 +28,292 @@ @ExtendWith(MockitoExtension.class) class RelationDefinitionValidationServiceTest { - @Mock - private EntityTemplateRepositoryPort entityTemplateRepositoryPort; + @Mock + private EntityTemplateRepositoryPort entityTemplateRepositoryPort; + + private RelationDefinitionValidationService relationDefinitionValidationService; + + @BeforeEach + void setUp() { + relationDefinitionValidationService = new RelationDefinitionValidationService( + entityTemplateRepositoryPort); + } + + @Nested + @DisplayName("validateUniqueRelationNames") + class ValidateUniqueRelationNamesTests { + + @Test + @DisplayName("Happy path: all relation names are unique") + void testUniqueRelationNames() { + List relations = List.of( + new RelationDefinition(UUID.randomUUID(), "parent", "parent-template", true, false), + new RelationDefinition(UUID.randomUUID(), "children", "child-template", false, true), + new RelationDefinition(UUID.randomUUID(), "owner", "owner-template", true, false)); + + assertDoesNotThrow( + () -> relationDefinitionValidationService.validateRelationNamesUniqueness(relations)); + } + + @Test + @DisplayName("Happy path: single relation") + void testSingleRelation() { + List relations = List + .of(new RelationDefinition(UUID.randomUUID(), "owner", "owner-template", true, false)); + + assertDoesNotThrow( + () -> relationDefinitionValidationService.validateRelationNamesUniqueness(relations)); + } + + @Test + @DisplayName("Happy path: empty relation list") + void testEmptyRelationList() { + assertDoesNotThrow(() -> relationDefinitionValidationService + .validateRelationNamesUniqueness(new ArrayList<>())); + } + + @Test + @DisplayName("Error: duplicate relation names") + void testDuplicateRelationNames() { + List relations = List.of( + new RelationDefinition(UUID.randomUUID(), "parent", "parent-template", true, false), + new RelationDefinition(UUID.randomUUID(), "parent", "alternative-parent-template", false, + false)); + + RelationNameAlreadyExistsException ex = assertThrows(RelationNameAlreadyExistsException.class, + () -> relationDefinitionValidationService.validateRelationNamesUniqueness(relations)); + assertTrue(ex.getMessage().contains("parent")); + } + + @Test + @DisplayName("Error: multiple duplicates detected on first occurrence") + void testMultipleDuplicates() { + List relations = List.of( + new RelationDefinition(UUID.randomUUID(), "parent", "parent-template", true, false), + new RelationDefinition(UUID.randomUUID(), "parent", "duplicate-parent", true, false), + new RelationDefinition(UUID.randomUUID(), "children", "child-template", false, true), + new RelationDefinition(UUID.randomUUID(), "children", "duplicate-children", false, true)); + + RelationNameAlreadyExistsException ex = assertThrows(RelationNameAlreadyExistsException.class, + () -> relationDefinitionValidationService.validateRelationNamesUniqueness(relations)); + // Should fail on first duplicate found + assertTrue(ex.getMessage().contains("parent")); + } + + @Test + @DisplayName("Error: case-insensitive name comparison (Parent vs parent)") + void testCaseInsensitiveNames() { + List relations = List.of( + new RelationDefinition(UUID.randomUUID(), "Parent", "parent-template", true, false), + new RelationDefinition(UUID.randomUUID(), "parent", "alternative-parent", false, false)); + + // "Parent" and "parent" should now be treated as duplicates (case-insensitive) + RelationNameAlreadyExistsException ex = assertThrows(RelationNameAlreadyExistsException.class, + () -> relationDefinitionValidationService.validateRelationNamesUniqueness(relations)); + assertTrue(ex.getMessage().contains("parent")); + } + + @Test + @DisplayName("Error: duplicate relation names with different cardinalities") + void testDuplicateNamesWithDifferentCardinalities() { + List relations = List.of( + new RelationDefinition(UUID.randomUUID(), "items", "item-template", true, false), + new RelationDefinition(UUID.randomUUID(), "items", "item-template", false, true)); + + RelationNameAlreadyExistsException ex = assertThrows(RelationNameAlreadyExistsException.class, + () -> relationDefinitionValidationService.validateRelationNamesUniqueness(relations)); + assertTrue(ex.getMessage().contains("items")); + } + } + + @Nested + @DisplayName("validateTargetTemplatesExist") + class ValidateTargetTemplatesExistTests { + + @Test + @DisplayName("Happy path: all target templates exist") + void testAllTargetTemplatesExist() { + List relations = List.of( + new RelationDefinition(UUID.randomUUID(), "parent", "parent-template", true, false), + new RelationDefinition(UUID.randomUUID(), "children", "child-template", false, true), + new RelationDefinition(UUID.randomUUID(), "owner", "owner-template", true, false)); + + when(entityTemplateRepositoryPort.existsByIdentifier("parent-template")).thenReturn(true); + when(entityTemplateRepositoryPort.existsByIdentifier("child-template")).thenReturn(true); + when(entityTemplateRepositoryPort.existsByIdentifier("owner-template")).thenReturn(true); + + assertDoesNotThrow( + () -> relationDefinitionValidationService.validateTargetTemplatesExist(relations)); + } + + @Test + @DisplayName("Happy path: empty relation list") + void testEmptyRelationList() { + assertDoesNotThrow(() -> relationDefinitionValidationService + .validateTargetTemplatesExist(new ArrayList<>())); + } + + @Test + @DisplayName("Error: single relation with non-existent target") + void testSingleRelationWithNonExistentTarget() { + List relations = List.of( + new RelationDefinition(UUID.randomUUID(), "owner", "non-existent-template", true, false)); - private RelationDefinitionValidationService relationDefinitionValidationService; + when(entityTemplateRepositoryPort.existsByIdentifier("non-existent-template")) + .thenReturn(false); - @BeforeEach - void setUp() { - relationDefinitionValidationService = new RelationDefinitionValidationService(entityTemplateRepositoryPort); + TargetTemplateNotFoundException ex = assertThrows(TargetTemplateNotFoundException.class, + () -> relationDefinitionValidationService.validateTargetTemplatesExist(relations)); + assertTrue(ex.getMessage().contains("non-existent-template")); } - @Nested - @DisplayName("validateUniqueRelationNames") - class ValidateUniqueRelationNamesTests { - - @Test - @DisplayName("Happy path: all relation names are unique") - void testUniqueRelationNames() { - List relations = List.of( - new RelationDefinition(UUID.randomUUID(), "parent", "parent-template", true, false), - new RelationDefinition(UUID.randomUUID(), "children", "child-template", false, true), - new RelationDefinition(UUID.randomUUID(), "owner", "owner-template", true, false) - ); - - assertDoesNotThrow(() -> relationDefinitionValidationService.validateRelationNamesUniqueness(relations)); - } - - @Test - @DisplayName("Happy path: single relation") - void testSingleRelation() { - List relations = List.of( - new RelationDefinition(UUID.randomUUID(), "owner", "owner-template", true, false) - ); - - assertDoesNotThrow(() -> relationDefinitionValidationService.validateRelationNamesUniqueness(relations)); - } - - @Test - @DisplayName("Happy path: empty relation list") - void testEmptyRelationList() { - assertDoesNotThrow(() -> relationDefinitionValidationService.validateRelationNamesUniqueness(new ArrayList<>())); - } - - @Test - @DisplayName("Error: duplicate relation names") - void testDuplicateRelationNames() { - List relations = List.of( - new RelationDefinition(UUID.randomUUID(), "parent", "parent-template", true, false), - new RelationDefinition(UUID.randomUUID(), "parent", "alternative-parent-template", false, false) - ); - - RelationNameAlreadyExistsException ex = assertThrows( - RelationNameAlreadyExistsException.class, - () -> relationDefinitionValidationService.validateRelationNamesUniqueness(relations) - ); - assertTrue(ex.getMessage().contains("parent")); - } - - @Test - @DisplayName("Error: multiple duplicates detected on first occurrence") - void testMultipleDuplicates() { - List relations = List.of( - new RelationDefinition(UUID.randomUUID(), "parent", "parent-template", true, false), - new RelationDefinition(UUID.randomUUID(), "parent", "duplicate-parent", true, false), - new RelationDefinition(UUID.randomUUID(), "children", "child-template", false, true), - new RelationDefinition(UUID.randomUUID(), "children", "duplicate-children", false, true) - ); - - RelationNameAlreadyExistsException ex = assertThrows( - RelationNameAlreadyExistsException.class, - () -> relationDefinitionValidationService.validateRelationNamesUniqueness(relations) - ); - // Should fail on first duplicate found - assertTrue(ex.getMessage().contains("parent")); - } - - @Test - @DisplayName("Error: case-insensitive name comparison (Parent vs parent)") - void testCaseInsensitiveNames() { - List relations = List.of( - new RelationDefinition(UUID.randomUUID(), "Parent", "parent-template", true, false), - new RelationDefinition(UUID.randomUUID(), "parent", "alternative-parent", false, false) - ); - - // "Parent" and "parent" should now be treated as duplicates (case-insensitive) - RelationNameAlreadyExistsException ex = assertThrows( - RelationNameAlreadyExistsException.class, - () -> relationDefinitionValidationService.validateRelationNamesUniqueness(relations) - ); - assertTrue(ex.getMessage().contains("parent")); - } - - @Test - @DisplayName("Error: duplicate relation names with different cardinalities") - void testDuplicateNamesWithDifferentCardinalities() { - List relations = List.of( - new RelationDefinition(UUID.randomUUID(), "items", "item-template", true, false), - new RelationDefinition(UUID.randomUUID(), "items", "item-template", false, true) - ); - - RelationNameAlreadyExistsException ex = assertThrows( - RelationNameAlreadyExistsException.class, - () -> relationDefinitionValidationService.validateRelationNamesUniqueness(relations) - ); - assertTrue(ex.getMessage().contains("items")); - } + @Test + @DisplayName("Error: multiple relations with multiple targets missing") + void testMultipleTargetsNotFound() { + List relations = List.of( + new RelationDefinition(UUID.randomUUID(), "parent", "missing-parent-template", true, + false), + new RelationDefinition(UUID.randomUUID(), "children", "child-template", false, true), + new RelationDefinition(UUID.randomUUID(), "owner", "missing-owner-template", true, + false)); + + when(entityTemplateRepositoryPort.existsByIdentifier("missing-parent-template")) + .thenReturn(false); + + TargetTemplateNotFoundException ex = assertThrows(TargetTemplateNotFoundException.class, + () -> relationDefinitionValidationService.validateTargetTemplatesExist(relations)); + // Should fail on first missing target + assertTrue(ex.getMessage().contains("missing-parent-template")); + } + + @Test + @DisplayName("Error: relation with null target identifier") + void testRelationWithNullTargetIdentifier() { + List relations = List + .of(new RelationDefinition(UUID.randomUUID(), "optional-parent", null, false, false)); + + TargetTemplateNotFoundException ex = assertThrows(TargetTemplateNotFoundException.class, + () -> relationDefinitionValidationService.validateTargetTemplatesExist(relations)); + assertTrue(ex.getMessage().contains("null") || ex.getMessage().contains("target")); + } + } + + @Nested + @DisplayName("validateTargetTemplateIdentifierChanges") + class ValidateTargetTemplateIdentifierChangesTests { + + @Test + @DisplayName("Happy path: no relations change targetTemplateIdentifier") + void testNoTargetTemplateChange() { + var existing = List.of( + new RelationDefinition(UUID.randomUUID(), "owns", "microservice", true, false), + new RelationDefinition(UUID.randomUUID(), "belongsTo", "team", false, false)); + var incoming = List.of( + new RelationDefinition(UUID.randomUUID(), "owns", "microservice", false, true), + new RelationDefinition(UUID.randomUUID(), "belongsTo", "team", true, false)); + + assertDoesNotThrow(() -> relationDefinitionValidationService + .validateTargetTemplateChanges(existing, incoming)); + } + + @Test + @DisplayName("Happy path: new relation added without changing existing targets") + void testNewRelationAdded() { + var existing = List + .of(new RelationDefinition(UUID.randomUUID(), "owns", "microservice", true, false)); + var incoming = List.of( + new RelationDefinition(UUID.randomUUID(), "owns", "microservice", true, false), + new RelationDefinition(UUID.randomUUID(), "uses", "database-service", false, true)); + + assertDoesNotThrow(() -> relationDefinitionValidationService + .validateTargetTemplateChanges(existing, incoming)); + } + + @Test + @DisplayName("Happy path: existing relation removed from incoming list") + void testExistingRelationRemoved() { + var existing = List.of( + new RelationDefinition(UUID.randomUUID(), "owns", "microservice", true, false), + new RelationDefinition(UUID.randomUUID(), "uses", "database-service", false, true)); + var incoming = List + .of(new RelationDefinition(UUID.randomUUID(), "owns", "microservice", true, false)); + + assertDoesNotThrow(() -> relationDefinitionValidationService + .validateTargetTemplateChanges(existing, incoming)); + } + + @Test + @DisplayName("Error: changing targetTemplateIdentifier on existing relation") + void testTargetTemplateIdentifierChanged() { + var existing = List + .of(new RelationDefinition(UUID.randomUUID(), "Owns", "microservice", true, false)); + var incoming = List + .of(new RelationDefinition(UUID.randomUUID(), "owns", "batch-job", true, false)); + + RelationTargetTemplateChangeException ex = assertThrows( + RelationTargetTemplateChangeException.class, () -> relationDefinitionValidationService + .validateTargetTemplateChanges(existing, incoming)); + assertTrue(ex.getMessage().contains("Owns")); + assertTrue(ex.getMessage().contains("microservice")); + assertTrue(ex.getMessage().contains("batch-job")); + } + + @Test + @DisplayName("Error: fails on first relation with changed target when multiple relations changed") + void testFailsOnFirstChangedTarget() { + var existing = List.of( + new RelationDefinition(UUID.randomUUID(), "owns", "microservice", true, false), + new RelationDefinition(UUID.randomUUID(), "uses", "database-service", false, true)); + var incoming = List.of( + new RelationDefinition(UUID.randomUUID(), "owns", "batch-job", true, false), + new RelationDefinition(UUID.randomUUID(), "uses", "other-service", false, true)); + + RelationTargetTemplateChangeException ex = assertThrows( + RelationTargetTemplateChangeException.class, () -> relationDefinitionValidationService + .validateTargetTemplateChanges(existing, incoming)); + assertTrue(ex.getMessage().contains("owns") || ex.getMessage().contains("uses")); + } + } + + @Nested + @DisplayName("validateNoSelfReference") + class ValidateNoSelfReferenceTests { + + @Test + @DisplayName("Happy path: no relations — no exception") + void noRelations() { + assertDoesNotThrow(() -> relationDefinitionValidationService + .validateRelationNoSelfReference("my-template", List.of())); } - @Nested - @DisplayName("validateTargetTemplatesExist") - class ValidateTargetTemplatesExistTests { - - @Test - @DisplayName("Happy path: all target templates exist") - void testAllTargetTemplatesExist() { - List relations = List.of( - new RelationDefinition(UUID.randomUUID(), "parent", "parent-template", true, false), - new RelationDefinition(UUID.randomUUID(), "children", "child-template", false, true), - new RelationDefinition(UUID.randomUUID(), "owner", "owner-template", true, false) - ); - - when(entityTemplateRepositoryPort.existsByIdentifier("parent-template")).thenReturn(true); - when(entityTemplateRepositoryPort.existsByIdentifier("child-template")).thenReturn(true); - when(entityTemplateRepositoryPort.existsByIdentifier("owner-template")).thenReturn(true); - - assertDoesNotThrow(() -> relationDefinitionValidationService.validateTargetTemplatesExist(relations)); - } - - @Test - @DisplayName("Happy path: empty relation list") - void testEmptyRelationList() { - assertDoesNotThrow(() -> relationDefinitionValidationService.validateTargetTemplatesExist(new ArrayList<>())); - } - - @Test - @DisplayName("Error: single relation with non-existent target") - void testSingleRelationWithNonExistentTarget() { - List relations = List.of( - new RelationDefinition(UUID.randomUUID(), "owner", "non-existent-template", true, false) - ); - - when(entityTemplateRepositoryPort.existsByIdentifier("non-existent-template")).thenReturn(false); - - TargetTemplateNotFoundException ex = assertThrows( - TargetTemplateNotFoundException.class, - () -> relationDefinitionValidationService.validateTargetTemplatesExist(relations) - ); - assertTrue(ex.getMessage().contains("non-existent-template")); - } - - @Test - @DisplayName("Error: multiple relations with multiple targets missing") - void testMultipleTargetsNotFound() { - List relations = List.of( - new RelationDefinition(UUID.randomUUID(), "parent", "missing-parent-template", true, false), - new RelationDefinition(UUID.randomUUID(), "children", "child-template", false, true), - new RelationDefinition(UUID.randomUUID(), "owner", "missing-owner-template", true, false) - ); - - when(entityTemplateRepositoryPort.existsByIdentifier("missing-parent-template")).thenReturn(false); - - TargetTemplateNotFoundException ex = assertThrows( - TargetTemplateNotFoundException.class, - () -> relationDefinitionValidationService.validateTargetTemplatesExist(relations) - ); - // Should fail on first missing target - assertTrue(ex.getMessage().contains("missing-parent-template")); - } - - @Test - @DisplayName("Error: relation with null target identifier") - void testRelationWithNullTargetIdentifier() { - List relations = List.of( - new RelationDefinition(UUID.randomUUID(), "optional-parent", null, false, false) - ); - - TargetTemplateNotFoundException ex = assertThrows( - TargetTemplateNotFoundException.class, - () -> relationDefinitionValidationService.validateTargetTemplatesExist(relations) - ); - assertTrue(ex.getMessage().contains("null") || ex.getMessage().contains("target")); - } + @Test + @DisplayName("Happy path: null template identifier — no exception") + void nullTemplateIdentifier() { + var rel = new RelationDefinition(UUID.randomUUID(), "owns", "other-template", true, false); + assertDoesNotThrow(() -> relationDefinitionValidationService + .validateRelationNoSelfReference(null, List.of(rel))); } - @Nested - @DisplayName("validateTargetTemplateIdentifierChanges") - class ValidateTargetTemplateIdentifierChangesTests { - - @Test - @DisplayName("Happy path: no relations change targetTemplateIdentifier") - void testNoTargetTemplateChange() { - var existing = List.of( - new RelationDefinition(UUID.randomUUID(), "owns", "microservice", true, false), - new RelationDefinition(UUID.randomUUID(), "belongsTo", "team", false, false) - ); - var incoming = List.of( - new RelationDefinition(UUID.randomUUID(), "owns", "microservice", false, true), - new RelationDefinition(UUID.randomUUID(), "belongsTo", "team", true, false) - ); - - assertDoesNotThrow(() -> - relationDefinitionValidationService.validateTargetTemplateChanges(existing, incoming)); - } - - @Test - @DisplayName("Happy path: new relation added without changing existing targets") - void testNewRelationAdded() { - var existing = List.of( - new RelationDefinition(UUID.randomUUID(), "owns", "microservice", true, false) - ); - var incoming = List.of( - new RelationDefinition(UUID.randomUUID(), "owns", "microservice", true, false), - new RelationDefinition(UUID.randomUUID(), "uses", "database-service", false, true) - ); - - assertDoesNotThrow(() -> - relationDefinitionValidationService.validateTargetTemplateChanges(existing, incoming)); - } - - @Test - @DisplayName("Happy path: existing relation removed from incoming list") - void testExistingRelationRemoved() { - var existing = List.of( - new RelationDefinition(UUID.randomUUID(), "owns", "microservice", true, false), - new RelationDefinition(UUID.randomUUID(), "uses", "database-service", false, true) - ); - var incoming = List.of( - new RelationDefinition(UUID.randomUUID(), "owns", "microservice", true, false) - ); - - assertDoesNotThrow(() -> - relationDefinitionValidationService.validateTargetTemplateChanges(existing, incoming)); - } - - @Test - @DisplayName("Error: changing targetTemplateIdentifier on existing relation") - void testTargetTemplateIdentifierChanged() { - var existing = List.of( - new RelationDefinition(UUID.randomUUID(), "Owns", "microservice", true, false) - ); - var incoming = List.of( - new RelationDefinition(UUID.randomUUID(), "owns", "batch-job", true, false) - ); - - RelationTargetTemplateChangeException ex = assertThrows( - RelationTargetTemplateChangeException.class, - () -> relationDefinitionValidationService.validateTargetTemplateChanges(existing, incoming) - ); - assertTrue(ex.getMessage().contains("Owns")); - assertTrue(ex.getMessage().contains("microservice")); - assertTrue(ex.getMessage().contains("batch-job")); - } - - @Test - @DisplayName("Error: fails on first relation with changed target when multiple relations changed") - void testFailsOnFirstChangedTarget() { - var existing = List.of( - new RelationDefinition(UUID.randomUUID(), "owns", "microservice", true, false), - new RelationDefinition(UUID.randomUUID(), "uses", "database-service", false, true) - ); - var incoming = List.of( - new RelationDefinition(UUID.randomUUID(), "owns", "batch-job", true, false), - new RelationDefinition(UUID.randomUUID(), "uses", "other-service", false, true) - ); - - RelationTargetTemplateChangeException ex = assertThrows( - RelationTargetTemplateChangeException.class, - () -> relationDefinitionValidationService.validateTargetTemplateChanges(existing, incoming) - ); - assertTrue(ex.getMessage().contains("owns") || ex.getMessage().contains("uses")); - } + @Test + @DisplayName("Happy path: relations target different templates — no exception") + void relationsTargetOtherTemplates() { + var rel1 = new RelationDefinition(UUID.randomUUID(), "owns", "microservice", true, false); + var rel2 = new RelationDefinition(UUID.randomUUID(), "uses", "database-service", false, true); + assertDoesNotThrow(() -> relationDefinitionValidationService + .validateRelationNoSelfReference("my-template", List.of(rel1, rel2))); } - @Nested - @DisplayName("validateNoSelfReference") - class ValidateNoSelfReferenceTests { - - @Test - @DisplayName("Happy path: no relations — no exception") - void noRelations() { - assertDoesNotThrow(() -> - relationDefinitionValidationService.validateRelationNoSelfReference("my-template", List.of())); - } - - @Test - @DisplayName("Happy path: null template identifier — no exception") - void nullTemplateIdentifier() { - var rel = new RelationDefinition(UUID.randomUUID(), "owns", "other-template", true, false); - assertDoesNotThrow(() -> - relationDefinitionValidationService.validateRelationNoSelfReference(null, List.of(rel))); - } - - @Test - @DisplayName("Happy path: relations target different templates — no exception") - void relationsTargetOtherTemplates() { - var rel1 = new RelationDefinition(UUID.randomUUID(), "owns", "microservice", true, false); - var rel2 = new RelationDefinition(UUID.randomUUID(), "uses", "database-service", false, true); - assertDoesNotThrow(() -> - relationDefinitionValidationService.validateRelationNoSelfReference("my-template", List.of(rel1, rel2))); - } - - @Test - @DisplayName("Error: one of multiple relations targets its own template") - void oneOfMultipleRelationsTargetsSelf() { - var rel1 = new RelationDefinition(UUID.randomUUID(), "owns", "other-template", true, false); - var rel2 = new RelationDefinition(UUID.randomUUID(), "circular", "my-template", false, false); - var relations = List.of(rel1, rel2); - - RelationCannotTargetItselfException ex = assertThrows( - RelationCannotTargetItselfException.class, - () -> relationDefinitionValidationService.validateRelationNoSelfReference("my-template", relations) - ); - assertTrue(ex.getMessage().contains("circular") && ex.getMessage().contains("my-template")); - } + @Test + @DisplayName("Error: one of multiple relations targets its own template") + void oneOfMultipleRelationsTargetsSelf() { + var rel1 = new RelationDefinition(UUID.randomUUID(), "owns", "other-template", true, false); + var rel2 = new RelationDefinition(UUID.randomUUID(), "circular", "my-template", false, false); + var relations = List.of(rel1, rel2); + + RelationCannotTargetItselfException ex = assertThrows( + RelationCannotTargetItselfException.class, () -> relationDefinitionValidationService + .validateRelationNoSelfReference("my-template", relations)); + assertTrue(ex.getMessage().contains("circular") && ex.getMessage().contains("my-template")); } + } } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java index 14ed3f64..19dffe36 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java @@ -19,318 +19,349 @@ @DisplayName("PropertyValidationService Tests") class PropertyValidationServiceTest { - private final PropertyValidationService service = new PropertyValidationService(); + private final PropertyValidationService service = new PropertyValidationService(); - @Nested - @DisplayName("STRING validation") - class StringValidationTests { + @Nested + @DisplayName("STRING validation") + class StringValidationTests { - @Test - @DisplayName("Should report type mismatch when STRING value is null") - void shouldReportTypeMismatchWhenStringValueIsNull() { - var definition = propertyDefinition("label", PropertyType.STRING, null); + @Test + @DisplayName("Should report type mismatch when STRING value is null") + void shouldReportTypeMismatchWhenStringValueIsNull() { + var definition = propertyDefinition("label", PropertyType.STRING, null); - var violations = service.validatePropertyValue(definition, null); + var violations = service.validatePropertyValue(definition, null); - assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("label", PropertyType.STRING)), violations); - } + assertEquals( + List.of( + ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("label", PropertyType.STRING)), + violations); + } - @Test - @DisplayName("Should return no violations when STRING has no rules") - void shouldReturnNoViolationsWhenStringHasNoRules() { - var definition = propertyDefinition("label", PropertyType.STRING, null); + @Test + @DisplayName("Should return no violations when STRING has no rules") + void shouldReturnNoViolationsWhenStringHasNoRules() { + var definition = propertyDefinition("label", PropertyType.STRING, null); - var violations = service.validatePropertyValue(definition, "hello"); + var violations = service.validatePropertyValue(definition, "hello"); - assertEquals(List.of(), violations); - } + assertEquals(List.of(), violations); + } - @Test - @DisplayName("Should return no violations when STRING value satisfies all rules") - void shouldReturnNoViolationsWhenStringPassesAllRules() { - var rules = new PropertyRules(null, null, List.of("dev", "prod"), "^[a-z]+$", 10, 2, null, null); - var definition = propertyDefinition("env", PropertyType.STRING, rules); + @Test + @DisplayName("Should return no violations when STRING value satisfies all rules") + void shouldReturnNoViolationsWhenStringPassesAllRules() { + var rules = new PropertyRules(null, null, List.of("dev", "prod"), "^[a-z]+$", 10, 2, null, + null); + var definition = propertyDefinition("env", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "dev"); + var violations = service.validatePropertyValue(definition, "dev"); - assertEquals(List.of(), violations); - } + assertEquals(List.of(), violations); + } - @Test - @DisplayName("Should report minLength violation") - void shouldReportMinLengthViolation() { - var rules = new PropertyRules(null, null, null, null, null, 5, null, null); - var definition = propertyDefinition("name", PropertyType.STRING, rules); + @Test + @DisplayName("Should report minLength violation") + void shouldReportMinLengthViolation() { + var rules = new PropertyRules(null, null, null, null, null, 5, null, null); + var definition = propertyDefinition("name", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "ab"); + var violations = service.validatePropertyValue(definition, "ab"); - assertEquals(List.of(ValidationMessages.PROPERTY_MIN_LENGTH_VIOLATION.formatted("name", 5)), violations); - } + assertEquals(List.of(ValidationMessages.PROPERTY_MIN_LENGTH_VIOLATION.formatted("name", 5)), + violations); + } - @Test - @DisplayName("Should report maxLength violation") - void shouldReportMaxLengthViolation() { - var rules = new PropertyRules(null, null, null, null, 5, null, null, null); - var definition = propertyDefinition("name", PropertyType.STRING, rules); + @Test + @DisplayName("Should report maxLength violation") + void shouldReportMaxLengthViolation() { + var rules = new PropertyRules(null, null, null, null, 5, null, null, null); + var definition = propertyDefinition("name", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "too-long-value"); + var violations = service.validatePropertyValue(definition, "too-long-value"); - assertEquals(List.of(ValidationMessages.PROPERTY_MAX_LENGTH_VIOLATION.formatted("name", 5)), violations); - } + assertEquals(List.of(ValidationMessages.PROPERTY_MAX_LENGTH_VIOLATION.formatted("name", 5)), + violations); + } - @Test - @DisplayName("Should report regex violation") - void shouldReportRegexViolation() { - var rules = new PropertyRules(null, null, null, "^[0-9]+$", null, null, null, null); - var definition = propertyDefinition("code", PropertyType.STRING, rules); + @Test + @DisplayName("Should report regex violation") + void shouldReportRegexViolation() { + var rules = new PropertyRules(null, null, null, "^[0-9]+$", null, null, null, null); + var definition = propertyDefinition("code", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "abc"); + var violations = service.validatePropertyValue(definition, "abc"); - assertEquals(List.of(ValidationMessages.PROPERTY_REGEX_VIOLATION.formatted("code")), violations); - } + assertEquals(List.of(ValidationMessages.PROPERTY_REGEX_VIOLATION.formatted("code")), + violations); + } - @Test - @DisplayName("Should accept value matching regex") - void shouldAcceptValueMatchingRegex() { - var rules = new PropertyRules(null, null, null, "^[0-9]+$", null, null, null, null); - var definition = propertyDefinition("code", PropertyType.STRING, rules); + @Test + @DisplayName("Should accept value matching regex") + void shouldAcceptValueMatchingRegex() { + var rules = new PropertyRules(null, null, null, "^[0-9]+$", null, null, null, null); + var definition = propertyDefinition("code", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "12345"); + var violations = service.validatePropertyValue(definition, "12345"); - assertEquals(List.of(), violations); - } + assertEquals(List.of(), violations); + } - @Test - @DisplayName("Should report enum violation when value not in allowed list") - void shouldReportEnumViolation() { - var rules = new PropertyRules(null, null, List.of("ACTIVE", "INACTIVE"), null, null, null, null, null); - var definition = propertyDefinition("status", PropertyType.STRING, rules); + @Test + @DisplayName("Should report enum violation when value not in allowed list") + void shouldReportEnumViolation() { + var rules = new PropertyRules(null, null, List.of("ACTIVE", "INACTIVE"), null, null, null, + null, null); + var definition = propertyDefinition("status", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "UNKNOWN"); + var violations = service.validatePropertyValue(definition, "UNKNOWN"); - assertEquals(List.of(ValidationMessages.PROPERTY_ENUM_VIOLATION.formatted("status", List.of("ACTIVE", "INACTIVE"))), violations); - } + assertEquals(List.of(ValidationMessages.PROPERTY_ENUM_VIOLATION.formatted("status", + List.of("ACTIVE", "INACTIVE"))), violations); + } - @Test - @DisplayName("Should accept enum value with case-insensitive match") - void shouldAcceptEnumValueCaseInsensitive() { - var rules = new PropertyRules(null, null, List.of("ACTIVE", "INACTIVE"), null, null, null, null, null); - var definition = propertyDefinition("status", PropertyType.STRING, rules); + @Test + @DisplayName("Should accept enum value with case-insensitive match") + void shouldAcceptEnumValueCaseInsensitive() { + var rules = new PropertyRules(null, null, List.of("ACTIVE", "INACTIVE"), null, null, null, + null, null); + var definition = propertyDefinition("status", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "active"); + var violations = service.validatePropertyValue(definition, "active"); - assertEquals(List.of(), violations); - } + assertEquals(List.of(), violations); + } - @Test - @DisplayName("Should skip enum check when enumValues is empty") - void shouldSkipEnumCheckWhenEnumValuesIsEmpty() { - var rules = new PropertyRules(null, null, List.of(), null, null, null, null, null); - var definition = propertyDefinition("status", PropertyType.STRING, rules); + @Test + @DisplayName("Should skip enum check when enumValues is empty") + void shouldSkipEnumCheckWhenEnumValuesIsEmpty() { + var rules = new PropertyRules(null, null, List.of(), null, null, null, null, null); + var definition = propertyDefinition("status", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "anything"); + var violations = service.validatePropertyValue(definition, "anything"); - assertEquals(List.of(), violations); - } + assertEquals(List.of(), violations); + } - @Test - @DisplayName("Should report format violation for invalid EMAIL") - void shouldReportFormatViolationForInvalidEmail() { - var rules = new PropertyRules(null, PropertyFormat.EMAIL, null, null, null, null, null, null); - var definition = propertyDefinition("email", PropertyType.STRING, rules); + @Test + @DisplayName("Should report format violation for invalid EMAIL") + void shouldReportFormatViolationForInvalidEmail() { + var rules = new PropertyRules(null, PropertyFormat.EMAIL, null, null, null, null, null, null); + var definition = propertyDefinition("email", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "not-an-email"); + var violations = service.validatePropertyValue(definition, "not-an-email"); - assertEquals(List.of(ValidationMessages.PROPERTY_FORMAT_VIOLATION.formatted("email", PropertyFormat.EMAIL)), violations); - } + assertEquals(List.of( + ValidationMessages.PROPERTY_FORMAT_VIOLATION.formatted("email", PropertyFormat.EMAIL)), + violations); + } - @Test - @DisplayName("Should accept valid EMAIL format") - void shouldAcceptValidEmailFormat() { - var rules = new PropertyRules(null, PropertyFormat.EMAIL, null, null, null, null, null, null); - var definition = propertyDefinition("email", PropertyType.STRING, rules); + @Test + @DisplayName("Should accept valid EMAIL format") + void shouldAcceptValidEmailFormat() { + var rules = new PropertyRules(null, PropertyFormat.EMAIL, null, null, null, null, null, null); + var definition = propertyDefinition("email", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "user@example.com"); + var violations = service.validatePropertyValue(definition, "user@example.com"); - assertEquals(List.of(), violations); - } + assertEquals(List.of(), violations); + } - @Test - @DisplayName("Should report format violation for invalid URL") - void shouldReportFormatViolationForInvalidUrl() { - var rules = new PropertyRules(null, PropertyFormat.URL, null, null, null, null, null, null); - var definition = propertyDefinition("url", PropertyType.STRING, rules); + @Test + @DisplayName("Should report format violation for invalid URL") + void shouldReportFormatViolationForInvalidUrl() { + var rules = new PropertyRules(null, PropertyFormat.URL, null, null, null, null, null, null); + var definition = propertyDefinition("url", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "not-a-url"); + var violations = service.validatePropertyValue(definition, "not-a-url"); - assertEquals(List.of(ValidationMessages.PROPERTY_FORMAT_VIOLATION.formatted("url", PropertyFormat.URL)), violations); - } + assertEquals( + List.of( + ValidationMessages.PROPERTY_FORMAT_VIOLATION.formatted("url", PropertyFormat.URL)), + violations); + } - @Test - @DisplayName("Should accept valid URL format") - void shouldAcceptValidUrlFormat() { - var rules = new PropertyRules(null, PropertyFormat.URL, null, null, null, null, null, null); - var definition = propertyDefinition("url", PropertyType.STRING, rules); + @Test + @DisplayName("Should accept valid URL format") + void shouldAcceptValidUrlFormat() { + var rules = new PropertyRules(null, PropertyFormat.URL, null, null, null, null, null, null); + var definition = propertyDefinition("url", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "https://github.com/org/repo"); + var violations = service.validatePropertyValue(definition, "https://github.com/org/repo"); - assertEquals(List.of(), violations); - } + assertEquals(List.of(), violations); + } - @Test - @DisplayName("Should report multiple violations at once") - void shouldReportMultipleStringViolations() { - var rules = new PropertyRules(null, PropertyFormat.EMAIL, List.of("prod", "dev"), "^[a-z]+$", 5, 3, null, null); - var definition = propertyDefinition("name", PropertyType.STRING, rules); + @Test + @DisplayName("Should report multiple violations at once") + void shouldReportMultipleStringViolations() { + var rules = new PropertyRules(null, PropertyFormat.EMAIL, List.of("prod", "dev"), "^[a-z]+$", + 5, 3, null, null); + var definition = propertyDefinition("name", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "AA"); + var violations = service.validatePropertyValue(definition, "AA"); - assertEquals(4, violations.size()); - } + assertEquals(4, violations.size()); + } - @Test - @DisplayName("Should use cached pattern for repeated regex validations") - void shouldUseCachedPatternForRepeatedRegex() { - var rules = new PropertyRules(null, null, null, "^[a-z]+$", null, null, null, null); - var definition = propertyDefinition("code", PropertyType.STRING, rules); + @Test + @DisplayName("Should use cached pattern for repeated regex validations") + void shouldUseCachedPatternForRepeatedRegex() { + var rules = new PropertyRules(null, null, null, "^[a-z]+$", null, null, null, null); + var definition = propertyDefinition("code", PropertyType.STRING, rules); - // Validate twice with the same regex to exercise the cache - var violations1 = service.validatePropertyValue(definition, "abc"); - var violations2 = service.validatePropertyValue(definition, "def"); + // Validate twice with the same regex to exercise the cache + var violations1 = service.validatePropertyValue(definition, "abc"); + var violations2 = service.validatePropertyValue(definition, "def"); - assertEquals(List.of(), violations1); - assertEquals(List.of(), violations2); - } + assertEquals(List.of(), violations1); + assertEquals(List.of(), violations2); } + } - @Nested - @DisplayName("NUMBER validation") - class NumberValidationTests { + @Nested + @DisplayName("NUMBER validation") + class NumberValidationTests { - @Test - @DisplayName("Should report type mismatch for non-numeric NUMBER value") - void shouldReportTypeMismatchWhenNumberValueIsInvalid() { - var definition = propertyDefinition("score", PropertyType.NUMBER, null); + @Test + @DisplayName("Should report type mismatch for non-numeric NUMBER value") + void shouldReportTypeMismatchWhenNumberValueIsInvalid() { + var definition = propertyDefinition("score", PropertyType.NUMBER, null); - var violations = service.validatePropertyValue(definition, "not-a-number"); + var violations = service.validatePropertyValue(definition, "not-a-number"); - assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("score", PropertyType.NUMBER)), violations); - } + assertEquals( + List.of( + ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("score", PropertyType.NUMBER)), + violations); + } - @Test - @DisplayName("Should return no violations when NUMBER has no rules") - void shouldReturnNoViolationsWhenNumberHasNoRules() { - var definition = propertyDefinition("count", PropertyType.NUMBER, null); + @Test + @DisplayName("Should return no violations when NUMBER has no rules") + void shouldReturnNoViolationsWhenNumberHasNoRules() { + var definition = propertyDefinition("count", PropertyType.NUMBER, null); - var violations = service.validatePropertyValue(definition, "42"); + var violations = service.validatePropertyValue(definition, "42"); - assertEquals(List.of(), violations); - } + assertEquals(List.of(), violations); + } - @Test - @DisplayName("Should return no violations when NUMBER is within bounds") - void shouldReturnNoViolationsWhenNumberIsWithinBounds() { - var rules = new PropertyRules(null, null, null, null, null, null, 100, 0); - var definition = propertyDefinition("score", PropertyType.NUMBER, rules); + @Test + @DisplayName("Should return no violations when NUMBER is within bounds") + void shouldReturnNoViolationsWhenNumberIsWithinBounds() { + var rules = new PropertyRules(null, null, null, null, null, null, 100, 0); + var definition = propertyDefinition("score", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "50"); + var violations = service.validatePropertyValue(definition, "50"); - assertEquals(List.of(), violations); - } + assertEquals(List.of(), violations); + } - @Test - @DisplayName("Should report minValue violation") - void shouldReportMinValueViolation() { - var rules = new PropertyRules(null, null, null, null, null, null, 10, 5); - var definition = propertyDefinition("size", PropertyType.NUMBER, rules); + @Test + @DisplayName("Should report minValue violation") + void shouldReportMinValueViolation() { + var rules = new PropertyRules(null, null, null, null, null, null, 10, 5); + var definition = propertyDefinition("size", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "3"); + var violations = service.validatePropertyValue(definition, "3"); - assertEquals(List.of(ValidationMessages.PROPERTY_MIN_VALUE_VIOLATION.formatted("size", 5)), violations); - } + assertEquals(List.of(ValidationMessages.PROPERTY_MIN_VALUE_VIOLATION.formatted("size", 5)), + violations); + } - @Test - @DisplayName("Should report maxValue violation") - void shouldReportMaxValueViolation() { - var rules = new PropertyRules(null, null, null, null, null, null, 10, 0); - var definition = propertyDefinition("size", PropertyType.NUMBER, rules); + @Test + @DisplayName("Should report maxValue violation") + void shouldReportMaxValueViolation() { + var rules = new PropertyRules(null, null, null, null, null, null, 10, 0); + var definition = propertyDefinition("size", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "15"); + var violations = service.validatePropertyValue(definition, "15"); - assertEquals(List.of(ValidationMessages.PROPERTY_MAX_VALUE_VIOLATION.formatted("size", 10)), violations); - } + assertEquals(List.of(ValidationMessages.PROPERTY_MAX_VALUE_VIOLATION.formatted("size", 10)), + violations); + } - @Test - @DisplayName("Should report both minValue and maxValue violations") - void shouldReportBothMinAndMaxViolations() { - // minValue > maxValue edge case — value below min triggers min violation - var rules = new PropertyRules(null, null, null, null, null, null, 5, 10); - var definition = propertyDefinition("range", PropertyType.NUMBER, rules); + @Test + @DisplayName("Should report both minValue and maxValue violations") + void shouldReportBothMinAndMaxViolations() { + // minValue > maxValue edge case — value below min triggers min violation + var rules = new PropertyRules(null, null, null, null, null, null, 5, 10); + var definition = propertyDefinition("range", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "7"); + var violations = service.validatePropertyValue(definition, "7"); - // 7 < 10 (minValue) → min violation; 7 > 5 (maxValue) → max violation - assertEquals(2, violations.size()); - } + // 7 < 10 (minValue) → min violation; 7 > 5 (maxValue) → max violation + assertEquals(2, violations.size()); + } - @Test - @DisplayName("Should accept decimal number values") - void shouldAcceptDecimalNumberValues() { - var rules = new PropertyRules(null, null, null, null, null, null, 100, 0); - var definition = propertyDefinition("rate", PropertyType.NUMBER, rules); + @Test + @DisplayName("Should accept decimal number values") + void shouldAcceptDecimalNumberValues() { + var rules = new PropertyRules(null, null, null, null, null, null, 100, 0); + var definition = propertyDefinition("rate", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "99.5"); + var violations = service.validatePropertyValue(definition, "99.5"); - assertEquals(List.of(), violations); - } + assertEquals(List.of(), violations); + } - @Test - @DisplayName("Should report type mismatch when a boolean is sent for a NUMBER property") - void shouldReportTypeMismatchWhenBooleanSentForNumber() { - var definition = propertyDefinition("count", PropertyType.NUMBER, null); + @Test + @DisplayName("Should report type mismatch when a boolean is sent for a NUMBER property") + void shouldReportTypeMismatchWhenBooleanSentForNumber() { + var definition = propertyDefinition("count", PropertyType.NUMBER, null); - var violations = service.validatePropertyValue(definition, "true"); + var violations = service.validatePropertyValue(definition, "true"); - assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("count", PropertyType.NUMBER)), violations); - } + assertEquals( + List.of( + ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("count", PropertyType.NUMBER)), + violations); } + } - @Nested - @DisplayName("BOOLEAN validation") - class BooleanValidationTests { + @Nested + @DisplayName("BOOLEAN validation") + class BooleanValidationTests { - @ParameterizedTest(name = "Should accept valid boolean value: ''{0}''") - @ValueSource(strings = {"true", "false", "TRUE", "FALSE"}) - void shouldAcceptValidBooleanValues(String value) { - var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); + @ParameterizedTest(name = "Should accept valid boolean value: ''{0}''") + @ValueSource(strings = {"true", "false", "TRUE", "FALSE"}) + void shouldAcceptValidBooleanValues(String value) { + var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); - var violations = service.validatePropertyValue(definition, value); + var violations = service.validatePropertyValue(definition, value); - assertEquals(List.of(), violations); - } + assertEquals(List.of(), violations); + } - @Test - @DisplayName("Should report type mismatch for invalid boolean value") - void shouldReportTypeMismatchForInvalidBoolean() { - var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); + @Test + @DisplayName("Should report type mismatch for invalid boolean value") + void shouldReportTypeMismatchForInvalidBoolean() { + var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); - var violations = service.validatePropertyValue(definition, "yes"); + var violations = service.validatePropertyValue(definition, "yes"); - assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("flag", PropertyType.BOOLEAN)), violations); - } + assertEquals( + List.of( + ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("flag", PropertyType.BOOLEAN)), + violations); + } - @Test - @DisplayName("Should report type mismatch when a number is sent for a BOOLEAN property") - void shouldReportTypeMismatchWhenNumberSentForBoolean() { - var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); + @Test + @DisplayName("Should report type mismatch when a number is sent for a BOOLEAN property") + void shouldReportTypeMismatchWhenNumberSentForBoolean() { + var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); - var violations = service.validatePropertyValue(definition, "42"); + var violations = service.validatePropertyValue(definition, "42"); - assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("flag", PropertyType.BOOLEAN)), violations); - } + assertEquals( + List.of( + ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("flag", PropertyType.BOOLEAN)), + violations); } + } - private PropertyDefinition propertyDefinition(String name, PropertyType type, PropertyRules rules) { - return new PropertyDefinition(null, name, "description", type, true, rules); - } + private PropertyDefinition propertyDefinition(String name, PropertyType type, + PropertyRules rules) { + return new PropertyDefinition(null, name, "description", type, true, rules); + } } 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 53f5b15a..93330add 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 @@ -23,242 +23,238 @@ /// identifier. public class EntityControllerTest extends AbstractIntegrationTest { - private static final String TEMPLATE_IDENTIFIER = "web-service"; - private static final String ENTITY_IDENTIFIER = "web-api-2"; - private static final String ENTITIES_BY_IDENTIFIER_PATH = "/api/v1/entities/{template-identifier}/identifier/{identifier}"; - private static final String ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH = "/api/v1/entities/{template-identifier}"; - private static final String ENTITY_JSON_FILES_TEST_PATH = "integration_test/json/entity/v1/"; - @Autowired - private MockMvc mockMvc; + private static final String TEMPLATE_IDENTIFIER = "web-service"; + private static final String ENTITY_IDENTIFIER = "web-api-2"; + private static final String ENTITIES_BY_IDENTIFIER_PATH = "/api/v1/entities/{template-identifier}/identifier/{identifier}"; + private static final String ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH = "/api/v1/entities/{template-identifier}"; + private static final String ENTITY_JSON_FILES_TEST_PATH = "integration_test/json/entity/v1/"; + @Autowired + private MockMvc mockMvc; - /// Tests for GET /api/v1/entities/{template-identifier} endpoint (paginated - /// retrieval). - @Nested - @DisplayName("GET /api/v1/entities/{template-identifier} - Get Templates Paginated") - class GetEntitiesByTemplateIdentifierTests { + /// Tests for GET /api/v1/entities/{template-identifier} endpoint (paginated + /// retrieval). + @Nested + @DisplayName("GET /api/v1/entities/{template-identifier} - Get Templates Paginated") + class GetEntitiesByTemplateIdentifierTests { - @Test - @DisplayName("Should return paginated entities with default pagination") - @WithMockUser - void getEntities_paginated_200() throws Exception { - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .param("page", "0") - .param("size", "15") - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(APPLICATION_JSON)) - .andExpect(jsonPath("$.content").isArray()) - .andExpect(jsonPath("$.content.length()").value(5)) - .andExpect(jsonPath("$.page.total_elements").value(5)) - .andExpect(jsonPath("$.page.total_pages").value(1)) - .andExpect(jsonPath("$.page.size").value(15)) - .andExpect(jsonPath("$.page.number").value(0)) - .andExpect(jsonPath("$.content[0].template_identifier").value(TEMPLATE_IDENTIFIER)); - } - - @Test - @DisplayName("Should return paginated entities with default pagination") - @WithMockUser - void getEntities_paginated_404_when_non_existent_template() throws Exception { - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "non-existent-template-identifier") - .accept(APPLICATION_JSON)) - .andExpect(status().isNotFound()); - } + @Test + @DisplayName("Should return paginated entities with default pagination") + @WithMockUser + void getEntities_paginated_200() throws Exception { + mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER).param("page", "0") + .param("size", "15").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content.length()").value(5)) + .andExpect(jsonPath("$.page.total_elements").value(5)) + .andExpect(jsonPath("$.page.total_pages").value(1)) + .andExpect(jsonPath("$.page.size").value(15)) + .andExpect(jsonPath("$.page.number").value(0)) + .andExpect(jsonPath("$.content[0].template_identifier").value(TEMPLATE_IDENTIFIER)); + } - @Test - @DisplayName("Should return 401 without authentication") - void getTemplates_paginated_401_without_user_token() throws Exception { - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .accept(APPLICATION_JSON)) - .andExpect(status().isUnauthorized()); - } + @Test + @DisplayName("Should return paginated entities with default pagination") + @WithMockUser + void getEntities_paginated_404_when_non_existent_template() throws Exception { + mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "non-existent-template-identifier") + .accept(APPLICATION_JSON)).andExpect(status().isNotFound()); + } - @Test - @DisplayName("Should return paginated entities with custom pagination") - @WithMockUser - void getEntities_paginated_200_custom() throws Exception { + @Test + @DisplayName("Should return 401 without authentication") + void getTemplates_paginated_401_without_user_token() throws Exception { + mockMvc.perform( + get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER).accept(APPLICATION_JSON)) + .andExpect(status().isUnauthorized()); + } - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "monitoring-service") - .param("page", "1") - .param("size", "5") - .param("sort", "template_identifier,asc") - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(APPLICATION_JSON)) - .andExpect(jsonPath("$.content.length()").value(1)) - .andExpect(jsonPath("$.content[0].name").value("Monitoring Service 6")) - .andExpect(jsonPath("$.page.total_elements").value(6)) - .andExpect(jsonPath("$.page.total_pages").value(2)) - .andExpect(jsonPath("$.page.size").value(5)) - .andExpect(jsonPath("$.page.number").value(1)); - } + @Test + @DisplayName("Should return paginated entities with custom pagination") + @WithMockUser + void getEntities_paginated_200_custom() throws Exception { - @Test - @DisplayName("Should return paginated entities with default pagination") - @WithMockUser - void getEntities_invalid_pagination_200() throws Exception { - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(APPLICATION_JSON)) - .andExpect(jsonPath("$.content").isArray()) - .andExpect(jsonPath("$.content.length()").value(5)) - .andExpect(jsonPath("$.page.total_elements").value(5)) - .andExpect(jsonPath("$.page.total_pages").value(1)) - .andExpect(jsonPath("$.page.size").value(20)) - .andExpect(jsonPath("$.page.number").value(0)) - .andExpect(jsonPath("$.content[0].template_identifier").value(TEMPLATE_IDENTIFIER)); - } + mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "monitoring-service") + .param("page", "1").param("size", "5").param("sort", "template_identifier,asc") + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.content.length()").value(1)) + .andExpect(jsonPath("$.content[0].name").value("Monitoring Service 6")) + .andExpect(jsonPath("$.page.total_elements").value(6)) + .andExpect(jsonPath("$.page.total_pages").value(2)) + .andExpect(jsonPath("$.page.size").value(5)) + .andExpect(jsonPath("$.page.number").value(1)); } - /// Tests for GET /api/v1/entities/{template-identifier}/identifier/{identifier} - /// endpoint (lookup by template and identifier). - @Nested - @DisplayName("GET /api/v1/entities/{template-identifier}/identifier/{identifier} - Get Entities by template identifier and entity identifier") - class GetEntitiesByTemplateAndEntityIdentifierTests { + @Test + @DisplayName("Should return paginated entities with default pagination") + @WithMockUser + void getEntities_invalid_pagination_200() throws Exception { + mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content.length()").value(5)) + .andExpect(jsonPath("$.page.total_elements").value(5)) + .andExpect(jsonPath("$.page.total_pages").value(1)) + .andExpect(jsonPath("$.page.size").value(20)) + .andExpect(jsonPath("$.page.number").value(0)) + .andExpect(jsonPath("$.content[0].template_identifier").value(TEMPLATE_IDENTIFIER)); + } + } - @Test - @DisplayName("Should return entity by template identifier and identifier") - @WithMockUser - void getEntityByTemplateAndIdentifier_200() throws Exception { - mockMvc.perform(get(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, ENTITY_IDENTIFIER) - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(APPLICATION_JSON)) - .andExpect(jsonPath("$.identifier").value(ENTITY_IDENTIFIER)) - .andExpect(jsonPath("$.template_identifier").value(TEMPLATE_IDENTIFIER)); - } + /// Tests for GET /api/v1/entities/{template-identifier}/identifier/{identifier} + /// endpoint (lookup by template and identifier). + @Nested + @DisplayName("GET /api/v1/entities/{template-identifier}/identifier/{identifier} - Get Entities by template identifier and entity identifier") + class GetEntitiesByTemplateAndEntityIdentifierTests { - @Test - @DisplayName("Should return 404 for non-existent entity") - @WithMockUser - void getEntityByTemplateAndIdentifier_404_non_existent_entity() throws Exception { - mockMvc.perform(get(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, "non-existent-identifier") - .accept(APPLICATION_JSON)) - .andExpect(status().isNotFound()); - } + @Test + @DisplayName("Should return entity by template identifier and identifier") + @WithMockUser + void getEntityByTemplateAndIdentifier_200() throws Exception { + mockMvc + .perform(get(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, ENTITY_IDENTIFIER) + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.identifier").value(ENTITY_IDENTIFIER)) + .andExpect(jsonPath("$.template_identifier").value(TEMPLATE_IDENTIFIER)); + } - @Test - @DisplayName("Should return 404 for non-existent entity template") - @WithMockUser - void getEntityByTemplateAndIdentifier_404_non_existent_template() throws Exception { - mockMvc.perform(get(ENTITIES_BY_IDENTIFIER_PATH, "non-existent-template", "non-existent-identifier") - .accept(APPLICATION_JSON)) - .andExpect(status().isNotFound()); - } + @Test + @DisplayName("Should return 404 for non-existent entity") + @WithMockUser + void getEntityByTemplateAndIdentifier_404_non_existent_entity() throws Exception { + mockMvc + .perform(get(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, "non-existent-identifier") + .accept(APPLICATION_JSON)) + .andExpect(status().isNotFound()); } - @Nested - @DisplayName("POST /api/v1/entities/{template-identifier} - Get Entities by template identifier and entity identifier") - class PostEntitiesTests { + @Test + @DisplayName("Should return 404 for non-existent entity template") + @WithMockUser + void getEntityByTemplateAndIdentifier_404_non_existent_template() throws Exception { + mockMvc.perform( + get(ENTITIES_BY_IDENTIFIER_PATH, "non-existent-template", "non-existent-identifier") + .accept(APPLICATION_JSON)) + .andExpect(status().isNotFound()); + } + } - @Test - @WithMockUser() - @DisplayName("Should create entity and return 201") - void postEntity_201() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent(ENTITY_JSON_FILES_TEST_PATH + "postEntity_201.json"))) - .andExpect(status().isCreated()) - .andReturn(); - } + @Nested + @DisplayName("POST /api/v1/entities/{template-identifier} - Get Entities by template identifier and entity identifier") + class PostEntitiesTests { - @Test - @WithMockUser() - @DisplayName("Should return 400 when required template properties are missing") - void postEntity_400_when_required_properties_missing() throws Exception { - var payload = """ - { - "name": "web-service-missing-required", - "identifier": "web-service-missing-required", - "properties": { - "port": "8080" - } - } - """; + @Test + @WithMockUser() + @DisplayName("Should create entity and return 201") + void postEntity_201() throws Exception { + mockMvc + .perform( + MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content( + getJsonTestFileContent(ENTITY_JSON_FILES_TEST_PATH + "postEntity_201.json"))) + .andExpect(status().isCreated()).andReturn(); + } - mockMvc.perform(MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(payload)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("BAD_REQUEST")) - .andExpect(jsonPath("$.error_description").value(org.hamcrest.Matchers.containsString("Property 'applicationName' is required"))); - } + @Test + @WithMockUser() + @DisplayName("Should return 400 when required template properties are missing") + void postEntity_400_when_required_properties_missing() throws Exception { + var payload = """ + { + "name": "web-service-missing-required", + "identifier": "web-service-missing-required", + "properties": { + "port": "8080" + } + } + """; - @Test - @WithMockUser() - @DisplayName("Should return 400 when property type does not match template") - void postEntity_400_when_property_type_mismatch() throws Exception { - var payload = """ - { - "name": "web-service-invalid-type", - "identifier": "web-service-invalid-type", - "properties": { - "applicationName": "catalog-api", - "ownerEmail": "owner@example.com", - "port": "not-a-number", - "environment": "DEV", - "version": "1.2.3", - "teamName": "platform-team", - "baseUrl": "https://catalog.example.com", - "protocol": "HTTP", - "programmingLanguage": "JAVA" - } - } - """; + mockMvc + .perform(MockMvcRequestBuilders + .post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value( + org.hamcrest.Matchers.containsString("Property 'applicationName' is required"))); + } - mockMvc.perform(MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(payload)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("BAD_REQUEST")) - .andExpect(jsonPath("$.error_description").value(org.hamcrest.Matchers.containsString("Property 'port' must be of type NUMBER"))); - } + @Test + @WithMockUser() + @DisplayName("Should return 400 when property type does not match template") + void postEntity_400_when_property_type_mismatch() throws Exception { + var payload = """ + { + "name": "web-service-invalid-type", + "identifier": "web-service-invalid-type", + "properties": { + "applicationName": "catalog-api", + "ownerEmail": "owner@example.com", + "port": "not-a-number", + "environment": "DEV", + "version": "1.2.3", + "teamName": "platform-team", + "baseUrl": "https://catalog.example.com", + "protocol": "HTTP", + "programmingLanguage": "JAVA" + } + } + """; - @Test - @WithMockUser() - @DisplayName("Should return 400 when property rules are not respected") - void postEntity_400_when_property_rules_not_respected() throws Exception { - var payload = """ - { - "name": "web-service-invalid-rules", - "identifier": "web-service-invalid-rules", - "properties": { - "applicationName": "catalog-api", - "ownerEmail": "invalid-email", - "port": "80", - "environment": "DEV", - "version": "1.2.3", - "teamName": "platform-team", - "baseUrl": "invalid-url", - "protocol": "HTTP", - "programmingLanguage": "JAVA" - } - } - """; + mockMvc + .perform(MockMvcRequestBuilders + .post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value( + org.hamcrest.Matchers.containsString("Property 'port' must be of type NUMBER"))); + } - mockMvc.perform(MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(payload)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("BAD_REQUEST")) - .andExpect(jsonPath("$.error_description").value(org.hamcrest.Matchers.allOf( - org.hamcrest.Matchers.containsString("Property 'ownerEmail' does not match expected format"), - org.hamcrest.Matchers.containsString("Property 'ownerEmail' does not match required format EMAIL"), - org.hamcrest.Matchers.containsString("Property 'baseUrl' does not match expected format"), - org.hamcrest.Matchers.containsString("Property 'baseUrl' does not match required format URL"), - org.hamcrest.Matchers.containsString("Property 'port' value must be greater than or equal to 1024") - ))); - } + @Test + @WithMockUser() + @DisplayName("Should return 400 when property rules are not respected") + void postEntity_400_when_property_rules_not_respected() throws Exception { + var payload = """ + { + "name": "web-service-invalid-rules", + "identifier": "web-service-invalid-rules", + "properties": { + "applicationName": "catalog-api", + "ownerEmail": "invalid-email", + "port": "80", + "environment": "DEV", + "version": "1.2.3", + "teamName": "platform-team", + "baseUrl": "invalid-url", + "protocol": "HTTP", + "programmingLanguage": "JAVA" + } + } + """; + mockMvc + .perform(MockMvcRequestBuilders + .post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(payload)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(org.hamcrest.Matchers.allOf( + org.hamcrest.Matchers + .containsString("Property 'ownerEmail' does not match expected format"), + org.hamcrest.Matchers + .containsString("Property 'ownerEmail' does not match required format EMAIL"), + org.hamcrest.Matchers + .containsString("Property 'baseUrl' does not match expected format"), + org.hamcrest.Matchers + .containsString("Property 'baseUrl' does not match required format URL"), + org.hamcrest.Matchers + .containsString("Property 'port' value must be greater than or equal to 1024")))); } + } + } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java index ca30cf21..38590cbf 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java @@ -34,190 +34,169 @@ @DisplayName("GET /api/v1/entities/{templateIdentifier}/{entityIdentifier}/graph") public class EntityGraphControllerTest extends AbstractIntegrationTest { - private static final String GRAPH_PATH = "/api/v1/entities/{templateId}/{entityId}/graph"; - private static final String TEMPLATE = "web-service"; - private static final String ENTITY_A = "graph-svc-a"; - private static final String ENTITY_B = "graph-svc-b"; - private static final String ENTITY_C = "graph-svc-c"; - - @Autowired - private MockMvc mockMvc; - - @Nested - @DisplayName("Without relation filter") - class NoFilter { - - @Test - @WithMockUser - @DisplayName("Should return all nodes and edges when no filter is applied (depth=3)") - void shouldReturnAllNodesAndEdgesWithNoFilter() throws Exception { - mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A) - .param("depth", "3") - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(APPLICATION_JSON)) - // All three nodes must be present - .andExpect(jsonPath("$.nodes[*].identifier", - containsInAnyOrder(ENTITY_A, ENTITY_B, ENTITY_C))) - // Three edges: a-[uses]->b, a-[monitors]->b, b-[uses]->c - .andExpect(jsonPath("$.edges", hasSize(3))); - } + private static final String GRAPH_PATH = "/api/v1/entities/{templateId}/{entityId}/graph"; + private static final String TEMPLATE = "web-service"; + private static final String ENTITY_A = "graph-svc-a"; + private static final String ENTITY_B = "graph-svc-b"; + private static final String ENTITY_C = "graph-svc-c"; + + @Autowired + private MockMvc mockMvc; + + @Nested + @DisplayName("Without relation filter") + class NoFilter { + + @Test + @WithMockUser + @DisplayName("Should return all nodes and edges when no filter is applied (depth=3)") + void shouldReturnAllNodesAndEdgesWithNoFilter() throws Exception { + mockMvc + .perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A).param("depth", "3").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) + // All three nodes must be present + .andExpect( + jsonPath("$.nodes[*].identifier", containsInAnyOrder(ENTITY_A, ENTITY_B, ENTITY_C))) + // Three edges: a-[uses]->b, a-[monitors]->b, b-[uses]->c + .andExpect(jsonPath("$.edges", hasSize(3))); + } + } + + @Nested + @DisplayName("With 'uses' relation filter") + class UsesFilter { + + @Test + @WithMockUser + @DisplayName("Should traverse full chain via 'uses' edges and exclude 'monitors' edge (depth=3)") + void shouldTraverseFullChainWithUsesFilter() throws Exception { + mockMvc + .perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A).param("depth", "3") + .param("relations", "uses").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) + // All three nodes are reachable via "uses" chain: a→b→c + .andExpect( + jsonPath("$.nodes[*].identifier", containsInAnyOrder(ENTITY_A, ENTITY_B, ENTITY_C))) + // Only the two "uses" edges: a-[uses]->b and b-[uses]->c + .andExpect(jsonPath("$.edges", hasSize(2))) + .andExpect(jsonPath("$.edges[*].type", containsInAnyOrder("uses", "uses"))); + } + + @Test + @WithMockUser + @DisplayName("Should still reach graph-svc-c at depth 2 when filtering by 'uses'") + void shouldReachNodeCAtDepthTwoWithUsesFilter() throws Exception { + // This specifically verifies that the filter applies recursively: + // at depth=2, a→b (level 1) and b→c (level 2) must both be traversed. + mockMvc + .perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A).param("depth", "2") + .param("relations", "uses").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) + .andExpect( + jsonPath("$.nodes[*].identifier", containsInAnyOrder(ENTITY_A, ENTITY_B, ENTITY_C))) + .andExpect(jsonPath("$.edges", hasSize(2))); + } + } + + @Nested + @DisplayName("With 'monitors' relation filter") + class MonitorsFilter { + + @Test + @WithMockUser + @DisplayName("Should return only graph-svc-a and graph-svc-b when filtering by 'monitors' (depth=3)") + void shouldReturnOnlyRootAndDirectTargetWithMonitorsFilter() throws Exception { + // "monitors" only exists at level 1 (a→b). Since b has no "monitors" edges, + // graph-svc-c must NOT appear in the result. + mockMvc + .perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A).param("depth", "3") + .param("relations", "monitors").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) + // Only a and b — c is unreachable via "monitors" + .andExpect(jsonPath("$.nodes", hasSize(2))) + .andExpect(jsonPath("$.nodes[*].identifier", containsInAnyOrder(ENTITY_A, ENTITY_B))) + // One edge only: a-[monitors]->b + .andExpect(jsonPath("$.edges", hasSize(1))) + .andExpect(jsonPath("$.edges[0].type").value("monitors")); + } + } + + @Nested + @DisplayName("Error cases") + class ErrorCases { + + @Test + @WithMockUser + @DisplayName("Should return 404 when entity does not exist") + void shouldReturn404ForUnknownEntity() throws Exception { + mockMvc.perform(get(GRAPH_PATH, TEMPLATE, "non-existent-entity").accept(APPLICATION_JSON)) + .andExpect(status().isNotFound()); } - @Nested - @DisplayName("With 'uses' relation filter") - class UsesFilter { - - @Test - @WithMockUser - @DisplayName("Should traverse full chain via 'uses' edges and exclude 'monitors' edge (depth=3)") - void shouldTraverseFullChainWithUsesFilter() throws Exception { - mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A) - .param("depth", "3") - .param("relations", "uses") - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(APPLICATION_JSON)) - // All three nodes are reachable via "uses" chain: a→b→c - .andExpect(jsonPath("$.nodes[*].identifier", - containsInAnyOrder(ENTITY_A, ENTITY_B, ENTITY_C))) - // Only the two "uses" edges: a-[uses]->b and b-[uses]->c - .andExpect(jsonPath("$.edges", hasSize(2))) - .andExpect(jsonPath("$.edges[*].type", - containsInAnyOrder("uses", "uses"))); - } - - @Test - @WithMockUser - @DisplayName("Should still reach graph-svc-c at depth 2 when filtering by 'uses'") - void shouldReachNodeCAtDepthTwoWithUsesFilter() throws Exception { - // This specifically verifies that the filter applies recursively: - // at depth=2, a→b (level 1) and b→c (level 2) must both be traversed. - mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A) - .param("depth", "2") - .param("relations", "uses") - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(APPLICATION_JSON)) - .andExpect(jsonPath("$.nodes[*].identifier", - containsInAnyOrder(ENTITY_A, ENTITY_B, ENTITY_C))) - .andExpect(jsonPath("$.edges", hasSize(2))); - } + @Test + @DisplayName("Should return 401 without authentication") + void shouldReturn401WithoutAuthentication() throws Exception { + mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A).accept(APPLICATION_JSON)) + .andExpect(status().isUnauthorized()); + } + } + + @Nested + @DisplayName("With 'properties' filter (include_data=true)") + class PropertyFilter { + + @Test + @WithMockUser + @DisplayName("Should include only requested property in each node's data when one property is requested") + void shouldIncludeOnlyRequestedProperty() throws Exception { + mockMvc + .perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A).param("depth", "3") + .param("include_data", "true").param("properties", "tier").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) + // All three nodes are still returned + .andExpect( + jsonPath("$.nodes[*].identifier", containsInAnyOrder(ENTITY_A, ENTITY_B, ENTITY_C))) + // Each node's data must contain "tier" … + .andExpect(jsonPath("$.nodes[0].data.tier").exists()) + // … but must NOT contain "version" + .andExpect(jsonPath("$.nodes[0].data.version").doesNotExist()); } - @Nested - @DisplayName("With 'monitors' relation filter") - class MonitorsFilter { - - @Test - @WithMockUser - @DisplayName("Should return only graph-svc-a and graph-svc-b when filtering by 'monitors' (depth=3)") - void shouldReturnOnlyRootAndDirectTargetWithMonitorsFilter() throws Exception { - // "monitors" only exists at level 1 (a→b). Since b has no "monitors" edges, - // graph-svc-c must NOT appear in the result. - mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A) - .param("depth", "3") - .param("relations", "monitors") - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(APPLICATION_JSON)) - // Only a and b — c is unreachable via "monitors" - .andExpect(jsonPath("$.nodes", hasSize(2))) - .andExpect(jsonPath("$.nodes[*].identifier", - containsInAnyOrder(ENTITY_A, ENTITY_B))) - // One edge only: a-[monitors]->b - .andExpect(jsonPath("$.edges", hasSize(1))) - .andExpect(jsonPath("$.edges[0].type").value("monitors")); - } + @Test + @WithMockUser + @DisplayName("Should include multiple requested properties in each node's data") + void shouldIncludeMultipleRequestedProperties() throws Exception { + mockMvc + .perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A).param("depth", "3") + .param("include_data", "true").param("properties", "tier") + .param("properties", "version").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.nodes[0].data.tier").exists()) + .andExpect(jsonPath("$.nodes[0].data.version").exists()); } - @Nested - @DisplayName("Error cases") - class ErrorCases { - - @Test - @WithMockUser - @DisplayName("Should return 404 when entity does not exist") - void shouldReturn404ForUnknownEntity() throws Exception { - mockMvc.perform(get(GRAPH_PATH, TEMPLATE, "non-existent-entity") - .accept(APPLICATION_JSON)) - .andExpect(status().isNotFound()); - } - - @Test - @DisplayName("Should return 401 without authentication") - void shouldReturn401WithoutAuthentication() throws Exception { - mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A) - .accept(APPLICATION_JSON)) - .andExpect(status().isUnauthorized()); - } + @Test + @WithMockUser + @DisplayName("Should return empty data when requested property does not exist on entity") + void shouldReturnEmptyDataForUnknownProperty() throws Exception { + mockMvc + .perform( + get(GRAPH_PATH, TEMPLATE, ENTITY_A).param("depth", "3").param("include_data", "true") + .param("properties", "non-existent-prop").accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + // data field is omitted from JSON when empty (@JsonInclude NON_EMPTY) + .andExpect(jsonPath("$.nodes[0].data").doesNotExist()); } - @Nested - @DisplayName("With 'properties' filter (include_data=true)") - class PropertyFilter { - - @Test - @WithMockUser - @DisplayName("Should include only requested property in each node's data when one property is requested") - void shouldIncludeOnlyRequestedProperty() throws Exception { - mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A) - .param("depth", "3") - .param("include_data", "true") - .param("properties", "tier") - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(APPLICATION_JSON)) - // All three nodes are still returned - .andExpect(jsonPath("$.nodes[*].identifier", - containsInAnyOrder(ENTITY_A, ENTITY_B, ENTITY_C))) - // Each node's data must contain "tier" … - .andExpect(jsonPath("$.nodes[0].data.tier").exists()) - // … but must NOT contain "version" - .andExpect(jsonPath("$.nodes[0].data.version").doesNotExist()); - } - - @Test - @WithMockUser - @DisplayName("Should include multiple requested properties in each node's data") - void shouldIncludeMultipleRequestedProperties() throws Exception { - mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A) - .param("depth", "3") - .param("include_data", "true") - .param("properties", "tier") - .param("properties", "version") - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(APPLICATION_JSON)) - .andExpect(jsonPath("$.nodes[0].data.tier").exists()) - .andExpect(jsonPath("$.nodes[0].data.version").exists()); - } - - @Test - @WithMockUser - @DisplayName("Should return empty data when requested property does not exist on entity") - void shouldReturnEmptyDataForUnknownProperty() throws Exception { - mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A) - .param("depth", "3") - .param("include_data", "true") - .param("properties", "non-existent-prop") - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - // data field is omitted from JSON when empty (@JsonInclude NON_EMPTY) - .andExpect(jsonPath("$.nodes[0].data").doesNotExist()); - } - - @Test - @WithMockUser - @DisplayName("Should include all properties when no property filter is supplied") - void shouldIncludeAllPropertiesWithoutFilter() throws Exception { - mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A) - .param("depth", "3") - .param("include_data", "true") - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.nodes[0].data.tier").exists()) - .andExpect(jsonPath("$.nodes[0].data.version").exists()); - } + @Test + @WithMockUser + @DisplayName("Should include all properties when no property filter is supplied") + void shouldIncludeAllPropertiesWithoutFilter() throws Exception { + mockMvc + .perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A).param("depth", "3") + .param("include_data", "true").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(jsonPath("$.nodes[0].data.tier").exists()) + .andExpect(jsonPath("$.nodes[0].data.version").exists()); } + } } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java index c6b2a23d..1e09d7d9 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java @@ -43,1069 +43,1018 @@ @Slf4j class EntityTemplateControllerTest extends AbstractIntegrationTest { - @Autowired - private MockMvc mockMvc; + @Autowired + private MockMvc mockMvc; + + @Autowired + private EntityTemplateRepositoryPort entityTemplateRepository; + private static final String ENTITY_TEMPLATE_PATH = "/api/v1/entity-templates"; + + /// Test suite for the GET /api/v1/entity-templates endpoint, covering paginated + /// retrieval of entity templates. + /// **Test coverage includes:** + /// - Default pagination behavior and response structure + /// - Authentication requirements for accessing the endpoint + /// - Custom pagination and sorting parameters + /// - Retrieval of a specific template by identifier + /// - Filtering templates by identifier using query parameters + /// **Testing rationale:** Each test ensures the API returns the expected HTTP + /// status codes, + /// response content, and pagination metadata for proper contract verification. + @Nested + @DisplayName("GET /api/v1/entity-templates - Get Templates Paginated") + @Order(1) + class GetTemplatesPaginatedTests { + + /// Tests the GET /api/v1/entity-templates/ endpoint with default pagination + /// parameters. + /// **This test verifies that:** + /// - The endpoint returns HTTP 200 OK status + /// - Response content type is application/json + /// - All 10 templates are returned in the content array + /// - Default pagination settings are applied (page 0, size 20) + /// - Template ordering is consistent (batch-job at index 1) + /// - Pagination metadata is correctly populated + /// @throws Exception if the MockMvc request fails + @Test + @DisplayName("Should return paginated templates with default pagination") + @WithMockUser + void getTemplates_paginated_200() throws Exception { + + mockMvc.perform(get("/api/v1/entity-templates").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content.length()").value(10)) + .andExpect(jsonPath("$.content[1].identifier").value("batch-job")) + .andExpect(jsonPath("$.page.total_elements").value(10)) + .andExpect(jsonPath("$.page.total_pages").value(1)) + .andExpect(jsonPath("$.page.size").value(20)) + .andExpect(jsonPath("$.page.number").value(0)); + } - @Autowired - private EntityTemplateRepositoryPort entityTemplateRepository; - private static final String ENTITY_TEMPLATE_PATH = "/api/v1/entity-templates"; + /// Tests that accessing the /api/v1/entity-templates/ endpoint without + /// authentication returns a 401 Unauthorized status. + /// @throws Exception if an error occurs during the request + @Test + @DisplayName("Should return 401 without authentication") + void getTemplates_paginated_401_without_user_token() throws Exception { + mockMvc.perform(get(ENTITY_TEMPLATE_PATH).accept(APPLICATION_JSON)) + .andExpect(status().isUnauthorized()); + } + + /// Tests the GET /api/v1/entity-templates/ endpoint with custom pagination + /// parameters. + /// This test verifies that: + /// - Custom pagination parameters are correctly applied (page=1, size=5, + /// @throws Exception if the MockMvc request fails + @Test + @DisplayName("Should return paginated templates with custom pagination") + @WithMockUser + void getTemplates_paginated_200_custom() throws Exception { + + mockMvc + .perform(get("/api/v1/entity-templates").param("page", "1").param("size", "5") + .param("sort", "identifier,asc").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.content.length()").value(5)) + .andExpect(jsonPath("$.content[0].identifier").value("frontend-app")) + .andExpect(jsonPath("$.page.total_elements").value(10)) + .andExpect(jsonPath("$.page.total_pages").value(2)) + .andExpect(jsonPath("$.page.size").value(5)) + .andExpect(jsonPath("$.page.number").value(1)); + } + + /// Tests the GET /api/v1/entity-templates/{identifier} endpoint for + /// retrieving a specific template. + /// This test verifies that: + /// @throws Exception if the MockMvc request fails + @Test + @DisplayName("Should return 200 even with invalid pagination parameters") + @WithMockUser + void getTemplates_paginated_200_invalid_pagination() throws Exception { + + mockMvc.perform(get("/api/v1/entity-templates/frontend-app").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(jsonPath("$.identifier").value("frontend-app")); + } + + /// Tests the GET /api/v1/entity-templates/ endpoint with identifier query + /// parameter. + /// This test verifies that: + /// - The endpoint returns HTTP 200 OK status when identifier parameter is + /// @throws Exception if the MockMvc request fails + @Test + @DisplayName("Should return 200 with valid identifier") + @WithMockUser + void getTemplates_paginated_200_with_valid_identifier() throws Exception { + mockMvc.perform(get("/api/v1/entity-templates").param("identifier", "web-service") + .accept(APPLICATION_JSON)).andExpect(status().isOk()); + } + + } + + @Nested + @DisplayName("POST /api/v1/entity-templates - Create Template") + @Order(2) + class PostTemplateTests { + + private static final String ENTITY_TEMPLATE_JSON_TEST_PATH = "integration_test/json/entity-template/v1/"; + + /// Tests the POST /api/v1/entity-templates endpoint for successful template + /// creation. + /// This test verifies that: + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Should create template and return 201") + void postTemplate_201() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_201.json"))) + .andExpect(status().isCreated()).andReturn(); + } + + /// Tests the POST /api/v1/entity-templates endpoint without authentication. + /// This test verifies that: + /// @throws Exception if the MockMvc request fails + @Test + @DisplayName("Should create template and return 401") + void postTemplate_401_without_user_token() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_201.json"))) + .andExpect(status().isUnauthorized()).andReturn(); + } + + /// Tests the POST /api/v1/entity-templates endpoint when the identifier field + /// is + /// missing. + /// This test verifies that: + /// - Validation error message matches expected template identifier mandatory + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 400 when identifier is missing") + void postTemplate_400_identifier_missing() throws Exception { + MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, + ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_identifier_missing.json", + ValidationMessages.TEMPLATE_IDENTIFIER_MANDATORY); + assertNotNull(res, "Test executed successfully"); + } + + /// Tests the POST /api/v1/entity-templates endpoint when identifier field is + /// blank. + /// This test verifies that: + /// - Validation error message matches expected template identifier mandatory + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 400 when identifier is blank") + void postTemplate_400_identifier_blank() throws Exception { + MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, + ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_identifier_blank.json", + ValidationMessages.TEMPLATE_IDENTIFIER_MANDATORY); + assertNotNull(res, "Test executed successfully"); + } + + /// Tests the POST /api/v1/entity-templates endpoint when name field + /// already exists. + /// This test verifies that: + /// - Validation error message contains expected template name already exists + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 409 when name already exists") + void postTemplate_409_name_already_exists() throws Exception { + MvcResult res = postConflictAndAssertContains(ENTITY_TEMPLATE_PATH, + ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_409_name_already_exists.json", + "The entity template name"); + assertNotNull(res, "Test executed successfully"); + } + + /// Tests the POST /api/v1/entity-templates endpoint when the name field is + /// missing. + /// This test verifies that: + /// - Validation error message matches expected template identifier mandatory + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 400 when name is missing") + void postTemplate_400_name_missing() throws Exception { + MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, + ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_name_missing.json", + ValidationMessages.TEMPLATE_NAME_MANDATORY); + assertNotNull(res, "Test executed successfully"); + } + + /// Tests the POST /api/v1/entity-templates endpoint when name field is + /// blank. + /// This test verifies that: + /// - Validation error message contains expected template name mandatory message + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 400 when name is blank") + void postTemplate_400_name_blank() throws Exception { + MvcResult res = postBadRequestAndAssertContains(ENTITY_TEMPLATE_PATH, + ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_name_blank.json", + ValidationMessages.TEMPLATE_NAME_MANDATORY); + assertNotNull(res, "Test executed successfully"); + } + + /// Tests the POST /api/v1/entity-templates endpoint when name field is + /// too long. + /// This test verifies that: + /// - Validation error message matches expected template name too long + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 400 when name is too long") + void postTemplate_400_name_too_long() throws Exception { + MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, + ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_name_wrong_size.json", + ValidationMessages.TEMPLATE_NAME_MAX_SIZE); + assertNotNull(res, "Test executed successfully"); + } + + /// Tests the POST /api/v1/entity-templates endpoint when name field does + /// not respect regex pattern. + /// This test verifies that: + /// - Validation error message matches expected template name pattern + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 400 when name does not respect regex pattern") + void postTemplate_400_name_invalid_pattern() throws Exception { + MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, + ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_name_invalid_pattern.json", + ValidationMessages.TEMPLATE_NAME_FORMAT); + assertNotNull(res, "Test executed successfully"); + } + + /// Tests the POST /api/v1/entity-templates endpoint when properties array is + /// empty. + /// This test verifies that: + /// - Validation error message indicates property definitions are + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 400 when property name is missing") + void postTemplate_400_property_name_missing() throws Exception { + MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, + ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_property_name_missing.json", + ValidationMessages.PROPERTY_NAME_MANDATORY); + assertNotNull(res, "Test executed successfully"); + } + + /// Tests the POST /api/v1/entity-templates endpoint when property name field is + /// blank. + /// This test verifies that: + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 400 when property name is blank") + void postTemplate_400_property_name_blank() throws Exception { + MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, + ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_property_name_blank.json", + ValidationMessages.PROPERTY_NAME_MANDATORY); + assertNotNull(res, "Test executed successfully"); + } + + /// Tests the POST /api/v1/entity-templates endpoint when property description + /// field is missing. + /// This test verifies that: + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 400 when property description is missing") + void postTemplate_400_property_description_missing() throws Exception { + MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, + ENTITY_TEMPLATE_JSON_TEST_PATH + + "postEntityTemplate_400_property_description_missing.json", + ValidationMessages.PROPERTY_DESCRIPTION_MANDATORY); + assertNotNull(res, "Test executed successfully"); + } + + /// Tests the POST /api/v1/entity-templates endpoint when property description + /// field is blank. + /// This test verifies that: + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 400 when property description is blank") + void postTemplate_400_property_description_blank() throws Exception { + MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, + ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_property_description_blank.json", + ValidationMessages.PROPERTY_DESCRIPTION_MANDATORY); + assertNotNull(res, "Test executed successfully"); + } + + /// Tests the POST /api/v1/entity-templates endpoint when property type field is + /// missing. + /// This test verifies that: + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 400 when property type is missing") + void postTemplate_400_property_type_missing() throws Exception { + MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, + ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_property_type_missing.json", + ValidationMessages.PROPERTY_TYPE_MANDATORY); + assertNotNull(res, "Test executed successfully"); + } + + /// Tests the POST /api/v1/entity-templates endpoint when trying to create a + /// template with duplicate identifier. + /// This test verifies that: + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 409 when identifier already exists") + void postTemplate_409_identifier_already_exists() throws Exception { + + // Then, try to create template with the existing identifier in database + mockMvc + .perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(ENTITY_TEMPLATE_JSON_TEST_PATH + + "postEntityTemplate_409_identifier_already_exists.json"))) + .andExpect(status().isConflict()).andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.error_description").exists()).andExpect( + jsonPath("$.error_description").value(TEMPLATE_ALREADY_EXISTS + ":web-service")); + } + + /// Tests the POST /api/v1/entity-templates endpoint when property type contains + /// an invalid enum value. + /// This test verifies that: + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 400 when property type has invalid enum value") + void postTemplate_400_property_type_invalid_enum() throws Exception { + MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, + ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_bad_property_type.json", + "Invalid value 'NOT IN ENUM' for property 'type'"); + assertNotNull(res, "Test executed successfully"); + } + + /// Tests the POST /api/v1/entity-templates endpoint when property format + /// contains an invalid enum value. + /// This test verifies that: + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 400 when property format has invalid enum value") + void postTemplate_400_property_format_invalid_enum() throws Exception { + MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, + ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_bad_property_format.json", + "Invalid value 'NOT A VALID FORMAT' for property 'format'"); + assertNotNull(res, "Test executed successfully"); + } + + /// Tests the POST /api/v1/entity-templates endpoint with invalid property + /// rules. + /// This test verifies that rules incompatible with property type are rejected. + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 400 when STRING property has numeric rules") + void postTemplate_400_string_property_with_numeric_rules() throws Exception { + MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, + ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_invalid_rules.json", + "Property 'property-test' of type STRING: Numeric rule max_value is not allowed for STRING properties"); + assertNotNull(res, "Test executed successfully"); + } + + /// Tests the POST /api/v1/entity-templates endpoint with no property + /// definitions. + /// This test verifies that: + /// - Templates can be created without any properties + /// - The endpoint returns HTTP 201 Created status + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Should create template without properties and return 201") + void postTemplate_201_without_properties() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(ENTITY_TEMPLATE_JSON_TEST_PATH + + "postEntityTemplate_201_without_properties.json"))) + .andExpect(status().isCreated()).andReturn(); + } + + /// Tests the POST /api/v1/entity-templates endpoint with empty property array. + /// This test verifies that: + /// - Templates can be created with an empty properties array + /// - The endpoint returns HTTP 201 Created status + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Should create template with empty properties array and return 201") + void postTemplate_201_with_empty_properties() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(ENTITY_TEMPLATE_JSON_TEST_PATH + + "postEntityTemplate_201_with_empty_properties.json"))) + .andExpect(status().isCreated()).andReturn(); + } + + /// Tests POST endpoint when duplicate property names are provided + /// (case-insensitive). + /// Verifies that PropertyNameAlreadyExistsException is thrown and returns 400 + /// Bad Request. + @Test + @WithMockUser() + @DisplayName("Should return 400 when creating template with duplicate property names (case-insensitive)") + void postTemplate_400_duplicate_property_names() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(ENTITY_TEMPLATE_JSON_TEST_PATH + + "postEntityTemplate_400_duplicate_property_names.json"))) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value( + "Property name 'applicationname' already exists within the template. Property names must be unique.")); + } + + /// Tests POST endpoint when duplicate relation names are provided + /// (case-insensitive). + /// Verifies that RelationNameAlreadyExistsException is thrown and returns 400 + /// Bad Request. + @Test + @WithMockUser() + @DisplayName("Should return 400 when creating template with duplicate relation names (case-insensitive)") + void postTemplate_400_duplicate_relation_names() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(ENTITY_TEMPLATE_JSON_TEST_PATH + + "postEntityTemplate_400_duplicate_relation_names.json"))) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value( + "Relation name 'belongsto' already exists within the template. Relation names must be unique.")); + } + + /// Tests POST endpoint when relation targets non-existent template. + /// Verifies that TargetTemplateNotFoundException is thrown and returns 400 Bad + /// Request. + @Test + @WithMockUser() + @DisplayName("Should return 400 when relation targets non-existent template") + void postTemplate_400_target_template_not_found() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(ENTITY_TEMPLATE_JSON_TEST_PATH + + "postEntityTemplate_400_target_template_not_found.json"))) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description") + .value("Target template with identifier 'non-existent-template' does not exist.")); + } - /// Test suite for the GET /api/v1/entity-templates endpoint, covering paginated - /// retrieval of entity templates. - /// **Test coverage includes:** - /// - Default pagination behavior and response structure - /// - Authentication requirements for accessing the endpoint - /// - Custom pagination and sorting parameters - /// - Retrieval of a specific template by identifier - /// - Filtering templates by identifier using query parameters - /// **Testing rationale:** Each test ensures the API returns the expected HTTP status codes, - /// response content, and pagination metadata for proper contract verification. - @Nested - @DisplayName("GET /api/v1/entity-templates - Get Templates Paginated") - @Order(1) - class GetTemplatesPaginatedTests { - - /// Tests the GET /api/v1/entity-templates/ endpoint with default pagination - /// parameters. - /// **This test verifies that:** - /// - The endpoint returns HTTP 200 OK status - /// - Response content type is application/json - /// - All 10 templates are returned in the content array - /// - Default pagination settings are applied (page 0, size 20) - /// - Template ordering is consistent (batch-job at index 1) - /// - Pagination metadata is correctly populated - /// @throws Exception if the MockMvc request fails - @Test - @DisplayName("Should return paginated templates with default pagination") - @WithMockUser - void getTemplates_paginated_200() throws Exception { - - mockMvc.perform(get("/api/v1/entity-templates") - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(APPLICATION_JSON)) - .andExpect(jsonPath("$.content").isArray()) - .andExpect(jsonPath("$.content.length()").value(10)) - .andExpect(jsonPath("$.content[1].identifier").value("batch-job")) - .andExpect(jsonPath("$.page.total_elements").value(10)) - .andExpect(jsonPath("$.page.total_pages").value(1)) - .andExpect(jsonPath("$.page.size").value(20)) - .andExpect(jsonPath("$.page.number").value(0)); - } - - /// Tests that accessing the /api/v1/entity-templates/ endpoint without - /// authentication returns a 401 Unauthorized status. - /// @throws Exception if an error occurs during the request - @Test - @DisplayName("Should return 401 without authentication") - void getTemplates_paginated_401_without_user_token() throws Exception { - mockMvc.perform(get(ENTITY_TEMPLATE_PATH) - .accept(APPLICATION_JSON)) - .andExpect(status().isUnauthorized()); - } - - /// Tests the GET /api/v1/entity-templates/ endpoint with custom pagination - /// parameters. - /// This test verifies that: - /// - Custom pagination parameters are correctly applied (page=1, size=5, - /// @throws Exception if the MockMvc request fails - @Test - @DisplayName("Should return paginated templates with custom pagination") - @WithMockUser - void getTemplates_paginated_200_custom() throws Exception { - - mockMvc.perform(get("/api/v1/entity-templates") - .param("page", "1") - .param("size", "5") - .param("sort", "identifier,asc") - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(APPLICATION_JSON)) - .andExpect(jsonPath("$.content.length()").value(5)) - .andExpect(jsonPath("$.content[0].identifier").value("frontend-app")) - .andExpect(jsonPath("$.page.total_elements").value(10)) - .andExpect(jsonPath("$.page.total_pages").value(2)) - .andExpect(jsonPath("$.page.size").value(5)) - .andExpect(jsonPath("$.page.number").value(1)); - } - - /// Tests the GET /api/v1/entity-templates/{identifier} endpoint for - /// retrieving a specific template. - /// This test verifies that: - /// @throws Exception if the MockMvc request fails - @Test - @DisplayName("Should return 200 even with invalid pagination parameters") - @WithMockUser - void getTemplates_paginated_200_invalid_pagination() throws Exception { - - mockMvc.perform(get("/api/v1/entity-templates/frontend-app") - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.identifier").value("frontend-app")); - } - - /// Tests the GET /api/v1/entity-templates/ endpoint with identifier query - /// parameter. - /// This test verifies that: - /// - The endpoint returns HTTP 200 OK status when identifier parameter is - /// @throws Exception if the MockMvc request fails - @Test - @DisplayName("Should return 200 with valid identifier") - @WithMockUser - void getTemplates_paginated_200_with_valid_identifier() throws Exception { - mockMvc.perform(get("/api/v1/entity-templates") - .param("identifier", "web-service") - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()); - } + /// Tests POST endpoint when a relation's targetTemplateIdentifier equals the + /// template's own identifier. + /// Verifies that RelationSelfReferenceException is thrown and returns 400 Bad + /// Request. + @Test + @WithMockUser() + @DisplayName("Should return 400 when a relation targets the template itself") + void postTemplate_400_relation_self_reference() throws Exception { + mockMvc + .perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(ENTITY_TEMPLATE_JSON_TEST_PATH + + "postTemplate_400_relation_target_references_itself.json"))) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(containsString( + "Relation 'circular' cannot reference its own template 'self-ref-template'"))); + } + + } + + @Nested + @DisplayName("PUT /api/v1/entity-templates - Update Template") + @Order(3) + class PutTemplateTests { + + @Test + void putTemplate_without_user_token_401() throws Exception { + String identifier = "web-service"; + mockMvc + .perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_200.json"))) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + @DisplayName("Should update existing property rules using PUT") + void putTemplate_shouldMergePropertyRules() throws Exception { + + mockMvc + .perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH).contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + + "postEntityTemplateWithoutRelationsDefinitions_201.json"))) + .andExpect(status().isCreated()); + + EntityTemplate initialTemplate = entityTemplateRepository.findByIdentifier("temp-test-99") + .orElseThrow(); + + PropertyDefinition initialProperty = initialTemplate.propertiesDefinitions().get(0); + UUID initialRulesId = initialProperty.rules().id(); + + mockMvc.perform(MockMvcRequestBuilders.put("/api/v1/entity-templates/temp-test-99") + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + + "putEntityTemplate_updateRules_200.json"))) + .andExpect(status().isOk()); + + EntityTemplate updatedTemplate = entityTemplateRepository.findByIdentifier("temp-test-99") + .orElseThrow(); + + assertThat(updatedTemplate.propertiesDefinitions()).hasSize(1); + + PropertyDefinition updatedProperty = updatedTemplate.propertiesDefinitions().get(0); + + assertThat(updatedProperty.name()).isEqualTo("property-test"); + + PropertyRules updatedRules = updatedProperty.rules(); + assertThat(updatedRules.format()).isNull(); + assertThat(updatedRules.regex()).isEqualTo("^[a-zA-Z0-9]+$"); + assertThat(updatedRules.maxLength()).isEqualTo(255); + assertThat(updatedRules.minLength()).isNull(); + + assertThat(updatedRules.id()).isEqualTo(initialRulesId); + assertThat(updatedTemplate.relationsDefinitions()).isEmpty(); } - @Nested - @DisplayName("POST /api/v1/entity-templates - Create Template") - @Order(2) - class PostTemplateTests { - - private static final String ENTITY_TEMPLATE_JSON_TEST_PATH = "integration_test/json/entity-template/v1/"; - - /// Tests the POST /api/v1/entity-templates endpoint for successful template - /// creation. - /// This test verifies that: - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Should create template and return 201") - void postTemplate_201() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent(ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_201.json"))) - .andExpect(status().isCreated()) - .andReturn(); - } - - /// Tests the POST /api/v1/entity-templates endpoint without authentication. - /// This test verifies that: - /// @throws Exception if the MockMvc request fails - @Test - @DisplayName("Should create template and return 401") - void postTemplate_401_without_user_token() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent(ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_201.json"))) - .andExpect(status().isUnauthorized()) - .andReturn(); - } - - /// Tests the POST /api/v1/entity-templates endpoint when the identifier field is - /// missing. - /// This test verifies that: - /// - Validation error message matches expected template identifier mandatory - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 400 when identifier is missing") - void postTemplate_400_identifier_missing() throws Exception { - MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, - ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_identifier_missing.json", - ValidationMessages.TEMPLATE_IDENTIFIER_MANDATORY); - assertNotNull(res, "Test executed successfully"); - } - - /// Tests the POST /api/v1/entity-templates endpoint when identifier field is - /// blank. - /// This test verifies that: - /// - Validation error message matches expected template identifier mandatory - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 400 when identifier is blank") - void postTemplate_400_identifier_blank() throws Exception { - MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, - ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_identifier_blank.json", - ValidationMessages.TEMPLATE_IDENTIFIER_MANDATORY); - assertNotNull(res, "Test executed successfully"); - } - - /// Tests the POST /api/v1/entity-templates endpoint when name field - /// already exists. - /// This test verifies that: - /// - Validation error message contains expected template name already exists - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 409 when name already exists") - void postTemplate_409_name_already_exists() throws Exception { - MvcResult res = postConflictAndAssertContains(ENTITY_TEMPLATE_PATH, - ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_409_name_already_exists.json", - "The entity template name"); - assertNotNull(res, "Test executed successfully"); - } - - - /// Tests the POST /api/v1/entity-templates endpoint when the name field is - /// missing. - /// This test verifies that: - /// - Validation error message matches expected template identifier mandatory - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 400 when name is missing") - void postTemplate_400_name_missing() throws Exception { - MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, - ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_name_missing.json", - ValidationMessages.TEMPLATE_NAME_MANDATORY); - assertNotNull(res, "Test executed successfully"); - } - - /// Tests the POST /api/v1/entity-templates endpoint when name field is - /// blank. - /// This test verifies that: - /// - Validation error message contains expected template name mandatory message - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 400 when name is blank") - void postTemplate_400_name_blank() throws Exception { - MvcResult res = postBadRequestAndAssertContains(ENTITY_TEMPLATE_PATH, - ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_name_blank.json", - ValidationMessages.TEMPLATE_NAME_MANDATORY); - assertNotNull(res, "Test executed successfully"); - } - - /// Tests the POST /api/v1/entity-templates endpoint when name field is - /// too long. - /// This test verifies that: - /// - Validation error message matches expected template name too long - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 400 when name is too long") - void postTemplate_400_name_too_long() throws Exception { - MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, - ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_name_wrong_size.json", - ValidationMessages.TEMPLATE_NAME_MAX_SIZE); - assertNotNull(res, "Test executed successfully"); - } - - /// Tests the POST /api/v1/entity-templates endpoint when name field does - /// not respect regex pattern. - /// This test verifies that: - /// - Validation error message matches expected template name pattern - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 400 when name does not respect regex pattern") - void postTemplate_400_name_invalid_pattern() throws Exception { - MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, - ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_name_invalid_pattern.json", - ValidationMessages.TEMPLATE_NAME_FORMAT); - assertNotNull(res, "Test executed successfully"); - } - - /// Tests the POST /api/v1/entity-templates endpoint when properties array is - /// empty. - /// This test verifies that: - /// - Validation error message indicates property definitions are - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 400 when property name is missing") - void postTemplate_400_property_name_missing() throws Exception { - MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, - ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_property_name_missing.json", - ValidationMessages.PROPERTY_NAME_MANDATORY); - assertNotNull(res, "Test executed successfully"); - } - - /// Tests the POST /api/v1/entity-templates endpoint when property name field is - /// blank. - /// This test verifies that: - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 400 when property name is blank") - void postTemplate_400_property_name_blank() throws Exception { - MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, - ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_property_name_blank.json", - ValidationMessages.PROPERTY_NAME_MANDATORY); - assertNotNull(res, "Test executed successfully"); - } - - /// Tests the POST /api/v1/entity-templates endpoint when property description - /// field is missing. - /// This test verifies that: - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 400 when property description is missing") - void postTemplate_400_property_description_missing() throws Exception { - MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, - ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_property_description_missing.json", - ValidationMessages.PROPERTY_DESCRIPTION_MANDATORY); - assertNotNull(res, "Test executed successfully"); - } - - /// Tests the POST /api/v1/entity-templates endpoint when property description - /// field is blank. - /// This test verifies that: - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 400 when property description is blank") - void postTemplate_400_property_description_blank() throws Exception { - MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, - ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_property_description_blank.json", - ValidationMessages.PROPERTY_DESCRIPTION_MANDATORY); - assertNotNull(res, "Test executed successfully"); - } - - /// Tests the POST /api/v1/entity-templates endpoint when property type field is - /// missing. - /// This test verifies that: - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 400 when property type is missing") - void postTemplate_400_property_type_missing() throws Exception { - MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, - ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_property_type_missing.json", - ValidationMessages.PROPERTY_TYPE_MANDATORY); - assertNotNull(res, "Test executed successfully"); - } - - /// Tests the POST /api/v1/entity-templates endpoint when trying to create a - /// template with duplicate identifier. - /// This test verifies that: - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 409 when identifier already exists") - void postTemplate_409_identifier_already_exists() throws Exception { - - // Then, try to create template with the existing identifier in database - mockMvc.perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_409_identifier_already_exists.json"))) - .andExpect(status().isConflict()) - .andExpect(content().contentType(APPLICATION_JSON)) - .andExpect(jsonPath("$.error_description").exists()) - .andExpect(jsonPath("$.error_description").value(TEMPLATE_ALREADY_EXISTS + ":web-service")); - } - - /// Tests the POST /api/v1/entity-templates endpoint when property type contains - /// an invalid enum value. - /// This test verifies that: - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 400 when property type has invalid enum value") - void postTemplate_400_property_type_invalid_enum() throws Exception { - MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, - ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_bad_property_type.json", - "Invalid value 'NOT IN ENUM' for property 'type'"); - assertNotNull(res, "Test executed successfully"); - } - - /// Tests the POST /api/v1/entity-templates endpoint when property format - /// contains an invalid enum value. - /// This test verifies that: - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 400 when property format has invalid enum value") - void postTemplate_400_property_format_invalid_enum() throws Exception { - MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, - ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_bad_property_format.json", - "Invalid value 'NOT A VALID FORMAT' for property 'format'"); - assertNotNull(res, "Test executed successfully"); - } - - /// Tests the POST /api/v1/entity-templates endpoint with invalid property rules. - /// This test verifies that rules incompatible with property type are rejected. - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 400 when STRING property has numeric rules") - void postTemplate_400_string_property_with_numeric_rules() throws Exception { - MvcResult res = postBadRequestAndAssertEquals(ENTITY_TEMPLATE_PATH, - ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_invalid_rules.json", - "Property 'property-test' of type STRING: Numeric rule max_value is not allowed for STRING properties"); - assertNotNull(res, "Test executed successfully"); - } - - /// Tests the POST /api/v1/entity-templates endpoint with no property definitions. - /// This test verifies that: - /// - Templates can be created without any properties - /// - The endpoint returns HTTP 201 Created status - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Should create template without properties and return 201") - void postTemplate_201_without_properties() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_201_without_properties.json"))) - .andExpect(status().isCreated()) - .andReturn(); - } - - /// Tests the POST /api/v1/entity-templates endpoint with empty property array. - /// This test verifies that: - /// - Templates can be created with an empty properties array - /// - The endpoint returns HTTP 201 Created status - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Should create template with empty properties array and return 201") - void postTemplate_201_with_empty_properties() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_201_with_empty_properties.json"))) - .andExpect(status().isCreated()) - .andReturn(); - } - - /// Tests POST endpoint when duplicate property names are provided (case-insensitive). - /// Verifies that PropertyNameAlreadyExistsException is thrown and returns 400 Bad Request. - @Test - @WithMockUser() - @DisplayName("Should return 400 when creating template with duplicate property names (case-insensitive)") - void postTemplate_400_duplicate_property_names() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_duplicate_property_names.json"))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("BAD_REQUEST")) - .andExpect(jsonPath("$.error_description").value("Property name 'applicationname' already exists within the template. Property names must be unique.")); - } - - /// Tests POST endpoint when duplicate relation names are provided (case-insensitive). - /// Verifies that RelationNameAlreadyExistsException is thrown and returns 400 Bad Request. - @Test - @WithMockUser() - @DisplayName("Should return 400 when creating template with duplicate relation names (case-insensitive)") - void postTemplate_400_duplicate_relation_names() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_duplicate_relation_names.json"))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("BAD_REQUEST")) - .andExpect(jsonPath("$.error_description").value("Relation name 'belongsto' already exists within the template. Relation names must be unique.")); - } - - /// Tests POST endpoint when relation targets non-existent template. - /// Verifies that TargetTemplateNotFoundException is thrown and returns 400 Bad Request. - @Test - @WithMockUser() - @DisplayName("Should return 400 when relation targets non-existent template") - void postTemplate_400_target_template_not_found() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - ENTITY_TEMPLATE_JSON_TEST_PATH + "postEntityTemplate_400_target_template_not_found.json"))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("BAD_REQUEST")) - .andExpect(jsonPath("$.error_description").value("Target template with identifier 'non-existent-template' does not exist.")); - } - - /// Tests POST endpoint when a relation's targetTemplateIdentifier equals the template's own identifier. - /// Verifies that RelationSelfReferenceException is thrown and returns 400 Bad Request. - @Test - @WithMockUser() - @DisplayName("Should return 400 when a relation targets the template itself") - void postTemplate_400_relation_self_reference() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - ENTITY_TEMPLATE_JSON_TEST_PATH + "postTemplate_400_relation_target_references_itself.json"))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("BAD_REQUEST")) - .andExpect(jsonPath("$.error_description").value( - containsString("Relation 'circular' cannot reference its own template 'self-ref-template'"))); - } + @Test + @WithMockUser + @DisplayName("Should update template with relations and return 200") + void putTemplate_updateRelations_200() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(""" + { + "identifier": "template-rel-test", + "name": "Template Rel Test", + "description": "Initial template", + "properties_definitions": [ + { + "name": "property1", + "description": "description", + "required": true, + "type": "STRING", + "rules": {} + } + ], + "relations_definitions": [ + { + "name": "owns", + "target_template_identifier": "microservice", + "required": true, + "to_many": true + } + ] + } + """)).andExpect(status().isCreated()); + + String updateJson = """ + { + "name": "Template Rel Test", + "description": "Updated template with new relation", + "properties_definitions": [ + { + "name": "property1", + "description": "Updated description", + "type": "STRING", + "required": true, + "rules": {} + } + ], + "relations_definitions": [ + { + "name": "owns", + "target_template_identifier": "microservice", + "required": false, + "to_many": false + }, + { + "name": "belongsTo", + "target_template_identifier": "database-service", + "required": true, + "to_many": false + } + ] + } + """; + + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/template-rel-test") + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(updateJson)) + .andExpect(status().isOk()); + + Optional updatedTemplateOpt = entityTemplateRepository + .findByIdentifier("template-rel-test"); + assertThat(updatedTemplateOpt).isPresent(); + + EntityTemplate updatedTemplate = updatedTemplateOpt.get(); + + // Vérifier description mise à jour + assertThat(updatedTemplate.description()).isEqualTo("Updated template with new relation"); + + // Vérifier properties + assertThat(updatedTemplate.propertiesDefinitions()).hasSize(1); + assertThat(updatedTemplate.propertiesDefinitions().get(0).description()) + .isEqualTo("Updated description"); + + // Vérifier relations + assertThat(updatedTemplate.relationsDefinitions()).hasSize(2); + + Map relationsMap = updatedTemplate.relationsDefinitions().stream() + .collect(Collectors.toMap(RelationDefinition::name, r -> r)); + + assertThat(relationsMap.get("owns").targetTemplateIdentifier()).isEqualTo("microservice"); + assertThat(relationsMap.get("owns").required()).isFalse(); + assertThat(relationsMap.get("owns").toMany()).isFalse(); + + assertThat(relationsMap.get("belongsTo").targetTemplateIdentifier()) + .isEqualTo("database-service"); + assertThat(relationsMap.get("belongsTo").required()).isTrue(); + assertThat(relationsMap.get("belongsTo").toMany()).isFalse(); + } + + @Test + @WithMockUser() + @DisplayName("Should update template and return 201") + void putTemplate_200() throws Exception { + String identifier = "web-service"; + mockMvc + .perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_200.json"))) + .andExpect(status().isOk()); + + Optional entityTemplateUpdated = entityTemplateRepository + .findByIdentifier("web-service"); + assertThat(entityTemplateUpdated).isPresent(); + assertThat(entityTemplateUpdated.get().propertiesDefinitions()).hasSize(2); + assertThat(entityTemplateUpdated.get().relationsDefinitions()).isEmpty(); + } + + /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint without + /// properties. + /// This test verifies that: + /// - Templates can be updated without any properties + /// - The endpoint returns HTTP 200 OK status + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Should update template without properties and return 200") + void putTemplate_200_without_properties() throws Exception { + String identifier = "web-service"; + mockMvc + .perform( + MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + + "putEntityTemplate_200_without_properties.json"))) + .andExpect(status().isOk()); + } + + /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint with empty + /// properties array. + /// This test verifies that: + /// - Templates can be updated with an empty properties array + /// - The endpoint returns HTTP 200 OK status + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Should update template with empty properties array and return 200") + void putTemplate_200_with_empty_properties() throws Exception { + String identifier = "web-service"; + mockMvc + .perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + + "putEntityTemplate_200_with_empty_properties.json"))) + .andExpect(status().isOk()); + } + @Test + @WithMockUser + void putTemplate_404_withUnknownIdentifier() throws Exception { + String identifier = "unknown-identifier"; + mockMvc + .perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_200.json"))) + .andExpect(status().isNotFound()).andExpect(content().string( + "{\"error\":\"NOT_FOUND\",\"error_description\":\"Template not found with identifier: unknown-identifier\"}")); } - @Nested - @DisplayName("PUT /api/v1/entity-templates - Update Template") - @Order(3) - class PutTemplateTests { - - @Test - void putTemplate_without_user_token_401() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - "integration_test/json/entity-template/v1/putEntityTemplate_200.json"))) - .andExpect(status().isUnauthorized()); - } - - @Test - @WithMockUser - @DisplayName("Should update existing property rules using PUT") - void putTemplate_shouldMergePropertyRules() throws Exception { - - mockMvc.perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH - + "postEntityTemplateWithoutRelationsDefinitions_201.json"))) - .andExpect(status().isCreated()); - - EntityTemplate initialTemplate = entityTemplateRepository - .findByIdentifier("temp-test-99") - .orElseThrow(); - - PropertyDefinition initialProperty = initialTemplate.propertiesDefinitions().get(0); - UUID initialRulesId = initialProperty.rules().id(); - - mockMvc.perform(MockMvcRequestBuilders.put("/api/v1/entity-templates/temp-test-99") - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH - + "putEntityTemplate_updateRules_200.json"))) - .andExpect(status().isOk()); - - EntityTemplate updatedTemplate = entityTemplateRepository - .findByIdentifier("temp-test-99") - .orElseThrow(); - - assertThat(updatedTemplate.propertiesDefinitions()).hasSize(1); - - PropertyDefinition updatedProperty = updatedTemplate.propertiesDefinitions().get(0); - - assertThat(updatedProperty.name()).isEqualTo("property-test"); - - PropertyRules updatedRules = updatedProperty.rules(); - assertThat(updatedRules.format()).isNull(); - assertThat(updatedRules.regex()).isEqualTo("^[a-zA-Z0-9]+$"); - assertThat(updatedRules.maxLength()).isEqualTo(255); - assertThat(updatedRules.minLength()).isNull(); - - assertThat(updatedRules.id()).isEqualTo(initialRulesId); - - assertThat(updatedTemplate.relationsDefinitions()).isEmpty(); - } - - @Test - @WithMockUser - @DisplayName("Should update template with relations and return 200") - void putTemplate_updateRelations_200() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(""" - { - "identifier": "template-rel-test", - "name": "Template Rel Test", - "description": "Initial template", - "properties_definitions": [ - { - "name": "property1", - "description": "description", - "required": true, - "type": "STRING", - "rules": {} - } - ], - "relations_definitions": [ - { - "name": "owns", - "target_template_identifier": "microservice", - "required": true, - "to_many": true - } - ] - } - """)) - .andExpect(status().isCreated()); - - String updateJson = """ - { - "name": "Template Rel Test", - "description": "Updated template with new relation", - "properties_definitions": [ - { - "name": "property1", - "description": "Updated description", - "type": "STRING", - "required": true, - "rules": {} - } - ], - "relations_definitions": [ - { - "name": "owns", - "target_template_identifier": "microservice", - "required": false, - "to_many": false - }, - { - "name": "belongsTo", - "target_template_identifier": "database-service", - "required": true, - "to_many": false - } - ] - } - """; - - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/template-rel-test") - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(updateJson)) - .andExpect(status().isOk()); - - Optional updatedTemplateOpt = entityTemplateRepository - .findByIdentifier("template-rel-test"); - assertThat(updatedTemplateOpt).isPresent(); - - EntityTemplate updatedTemplate = updatedTemplateOpt.get(); - - // Vérifier description mise à jour - assertThat(updatedTemplate.description()).isEqualTo("Updated template with new relation"); - - // Vérifier properties - assertThat(updatedTemplate.propertiesDefinitions()).hasSize(1); - assertThat(updatedTemplate.propertiesDefinitions().get(0).description()) - .isEqualTo("Updated description"); - - // Vérifier relations - assertThat(updatedTemplate.relationsDefinitions()).hasSize(2); - - Map relationsMap = updatedTemplate.relationsDefinitions() - .stream() - .collect(Collectors.toMap(RelationDefinition::name, r -> r)); - - assertThat(relationsMap.get("owns").targetTemplateIdentifier()).isEqualTo("microservice"); - assertThat(relationsMap.get("owns").required()).isFalse(); - assertThat(relationsMap.get("owns").toMany()).isFalse(); - - assertThat(relationsMap.get("belongsTo").targetTemplateIdentifier()).isEqualTo("database-service"); - assertThat(relationsMap.get("belongsTo").required()).isTrue(); - assertThat(relationsMap.get("belongsTo").toMany()).isFalse(); - } - - @Test - @WithMockUser() - @DisplayName("Should update template and return 201") - void putTemplate_200() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - "integration_test/json/entity-template/v1/putEntityTemplate_200.json"))) - .andExpect(status().isOk()); - - Optional entityTemplateUpdated = entityTemplateRepository.findByIdentifier("web-service"); - assertThat(entityTemplateUpdated).isPresent(); - assertThat(entityTemplateUpdated.get().propertiesDefinitions()).hasSize(2); - assertThat(entityTemplateUpdated.get().relationsDefinitions()).isEmpty(); - } - - /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint without properties. - /// This test verifies that: - /// - Templates can be updated without any properties - /// - The endpoint returns HTTP 200 OK status - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Should update template without properties and return 200") - void putTemplate_200_without_properties() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + "putEntityTemplate_200_without_properties.json"))) - .andExpect(status().isOk()); - } - - /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint with empty properties array. - /// This test verifies that: - /// - Templates can be updated with an empty properties array - /// - The endpoint returns HTTP 200 OK status - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Should update template with empty properties array and return 200") - void putTemplate_200_with_empty_properties() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + "putEntityTemplate_200_with_empty_properties.json"))) - .andExpect(status().isOk()); - } - - @Test - @WithMockUser - void putTemplate_404_withUnknownIdentifier() throws Exception { - String identifier = "unknown-identifier"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - "integration_test/json/entity-template/v1/putEntityTemplate_200.json"))) - .andExpect(status().isNotFound()) - .andExpect(content().string( - "{\"error\":\"NOT_FOUND\",\"error_description\":\"Template not found with identifier: unknown-identifier\"}")); - } - - @Test - @WithMockUser() - void putTemplate_400_propertyNameIsMissing() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - "integration_test/json/entity-template/v1/putEntityTemplate_400_propertyNameIsMissing.json"))) - .andExpect(status().isBadRequest()) - .andExpect(content().string( - "{\"error\":\"BAD_REQUEST\",\"error_description\":\"Property name is mandatory and cannot be blank\"}")); - } - - @Test - @WithMockUser() - void putTemplate_400_propertyNameIsBlank() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - "integration_test/json/entity-template/v1/putEntityTemplate_400_propertyNameIsBlank.json"))) - .andExpect(status().isBadRequest()) - .andExpect(content().string( - "{\"error\":\"BAD_REQUEST\",\"error_description\":\"Property name is mandatory and cannot be blank\"}")); - } - - @Test - @WithMockUser() - void putTemplate_400_propertyDescriptionIsBlank() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - "integration_test/json/entity-template/v1/putEntityTemplate_400_propertyDescriptionIsBlank.json"))) - .andExpect(status().isBadRequest()) - .andExpect(content().string( - "{\"error\":\"BAD_REQUEST\",\"error_description\":\"Property description is mandatory and cannot be blank\"}")); - } - - @Test - @WithMockUser() - void putTemplate_400_propertyDescriptionIsMissing() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - "integration_test/json/entity-template/v1/putEntityTemplate_400_propertyDescriptionIsMissing.json"))) - .andExpect(status().isBadRequest()) - .andExpect(content().string( - "{\"error\":\"BAD_REQUEST\",\"error_description\":\"Property description is mandatory and cannot be blank\"}")); - } - - @Test - @WithMockUser() - void putTemplate_400_propertyTypeIsMissing() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - "integration_test/json/entity-template/v1/putEntityTemplate_400_propertyTypeIsMissing.json"))) - .andExpect(status().isBadRequest()) - .andExpect(content().string( - "{\"error\":\"BAD_REQUEST\",\"error_description\":\"Property type is mandatory\"}")); - } - - @Test - @WithMockUser() - void putTemplate_409_whenIdentifierAlreadyExists() throws Exception { - String identifier = "web-service"; - Optional entityTemplateUpdated = entityTemplateRepository.findByIdentifier("microservice"); - assertThat(entityTemplateUpdated).isPresent(); - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - "integration_test/json/entity-template/v1/putEntityTemplate_409_withIdentifierAlreadyExists.json"))) - .andExpect(status().isConflict()) - .andExpect(content().string( - "{\"error\":\"CONFLICT\",\"error_description\":\"The entity template name Microservice already exists\"}")); - } - - /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint when the name field is - /// missing. - /// This test verifies that: - /// - Validation error message matches expected template name mandatory - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 400 when name is missing") - void putTemplate_400_name_missing() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent("integration_test/json/entity-template/v1/putEntityTemplate_400_name_missing.json"))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("BAD_REQUEST")) - .andExpect(jsonPath("$.error_description").value(ValidationMessages.TEMPLATE_NAME_MANDATORY)); - } - - /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint when name field is - /// blank. - /// This test verifies that: - /// - Validation error message contains expected template name mandatory message - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 400 when name is blank") - void putTemplate_400_name_blank() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent("integration_test/json/entity-template/v1/putEntityTemplate_400_name_blank.json"))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("BAD_REQUEST")) - .andExpect(jsonPath("$.error_description").value(containsString(ValidationMessages.TEMPLATE_NAME_MANDATORY))); - } - - /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint when name field - /// already exists. - /// This test verifies that: - /// - Validation error message contains expected template name already exists - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 409 when name already exists") - void putTemplate_409_name_already_exists() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent("integration_test/json/entity-template/v1/putEntityTemplate_409_name_already_exists.json"))) - .andExpect(status().isConflict()) - .andExpect(content().string("{\"error\":\"CONFLICT\",\"error_description\":\"The entity template name Microservice already exists\"}")); - } - - /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint when name field is - /// too long. - /// This test verifies that: - /// - Validation error message matches expected template name too long - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 400 when name is too long") - void putTemplate_400_name_too_long() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent("integration_test/json/entity-template/v1/putEntityTemplate_400_name_wrong_size.json"))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("BAD_REQUEST")) - .andExpect(jsonPath("$.error_description").value(ValidationMessages.TEMPLATE_NAME_MAX_SIZE)); - } - - /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint when name field does - /// not respect regex pattern. - /// This test verifies that: - /// - Validation error message matches expected template name pattern - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 400 when name does not respect regex pattern") - void putTemplate_400_name_invalid_pattern() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent("integration_test/json/entity-template/v1/putEntityTemplate_400_name_invalid_pattern.json"))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("BAD_REQUEST")) - .andExpect(jsonPath("$.error_description").value(ValidationMessages.TEMPLATE_NAME_FORMAT)); - } - - /// Tests that the PUT /api/v1/entity-templates/{identifier} endpoint rejects - /// requests with an identifier field in the request body. - /// **This test verifies that:** - /// - The endpoint returns HTTP 400 Bad Request when identifier is in body - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Should reject PUT request with identifier in body and return 400") - void putTemplate_400_identifier_in_body() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + "putEntityTemplate_400_identifier_in_body.json"))) - .andExpect(status().isBadRequest()); - } - - /// Tests PUT endpoint when attempting to change property type on existing property. - /// Verifies that PropertyTypeChangeException is thrown and returns 400 Bad Request. - @Test - @WithMockUser() - @DisplayName("Should return 400 when changing existing property type") - void putTemplate_400_type_change() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + "putEntityTemplate_200.json"))) - .andExpect(status().isOk()); - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + "putTemplate_400_type_change.json"))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("BAD_REQUEST")) - .andExpect(jsonPath("$.error_description").value("Cannot change type of property 'name' from STRING to NUMBER. Property types cannot be modified after creation. Please delete and recreate the property instead.")); - } - - /// Tests PUT endpoint when attempting to change targetTemplateIdentifier on an existing relation. - /// Verifies that RelationTargetTemplateChangeException is thrown and returns 400 Bad Request. - @Test - @WithMockUser() - @DisplayName("Should return 400 when changing existing relation targetTemplateIdentifier") - void putTemplate_400_target_template_identifier_change() throws Exception { - String identifier = "microservice"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + "putTemplate_400_target_template_identifier_change.json"))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("BAD_REQUEST")) - .andExpect(jsonPath("$.error_description").value( - containsString("Cannot change target template of relation 'dependencies' from 'service' to 'service-modified'"))); - } + @Test + @WithMockUser() + void putTemplate_400_propertyNameIsMissing() throws Exception { + String identifier = "web-service"; + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_400_propertyNameIsMissing.json"))) + .andExpect(status().isBadRequest()).andExpect(content().string( + "{\"error\":\"BAD_REQUEST\",\"error_description\":\"Property name is mandatory and cannot be blank\"}")); + } + + @Test + @WithMockUser() + void putTemplate_400_propertyNameIsBlank() throws Exception { + String identifier = "web-service"; + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_400_propertyNameIsBlank.json"))) + .andExpect(status().isBadRequest()).andExpect(content().string( + "{\"error\":\"BAD_REQUEST\",\"error_description\":\"Property name is mandatory and cannot be blank\"}")); + } + + @Test + @WithMockUser() + void putTemplate_400_propertyDescriptionIsBlank() throws Exception { + String identifier = "web-service"; + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_400_propertyDescriptionIsBlank.json"))) + .andExpect(status().isBadRequest()).andExpect(content().string( + "{\"error\":\"BAD_REQUEST\",\"error_description\":\"Property description is mandatory and cannot be blank\"}")); + } + + @Test + @WithMockUser() + void putTemplate_400_propertyDescriptionIsMissing() throws Exception { + String identifier = "web-service"; + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_400_propertyDescriptionIsMissing.json"))) + .andExpect(status().isBadRequest()).andExpect(content().string( + "{\"error\":\"BAD_REQUEST\",\"error_description\":\"Property description is mandatory and cannot be blank\"}")); + } + @Test + @WithMockUser() + void putTemplate_400_propertyTypeIsMissing() throws Exception { + String identifier = "web-service"; + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_400_propertyTypeIsMissing.json"))) + .andExpect(status().isBadRequest()).andExpect(content().string( + "{\"error\":\"BAD_REQUEST\",\"error_description\":\"Property type is mandatory\"}")); } - @Nested - @DisplayName("DELETE /api/v1/entity-templates/{id} - Delete Template") - @Order(4) - class DeleteTemplateTests { - - private static final String ENTITY_TEMPLATE_PATH = "/api/v1/entity-templates"; - - /// Tests the DELETE /api/v1/entity-templates/{id} endpoint for successful - /// template deletion. - /// This test verifies that: - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Should delete template and return 204") - void deleteTemplate_204() throws Exception { - // Use an existing template ID from test data - String templateId = "monitoring-service"; - - mockMvc.perform(MockMvcRequestBuilders.delete(ENTITY_TEMPLATE_PATH + "/" + templateId) - .accept(APPLICATION_JSON) - .with(csrf())) - .andExpect(status().isNoContent()); - - assertNotNull(templateId, "Test executed successfully"); - } - - /// Tests the DELETE /api/v1/entity-templates/{id} endpoint when template does - /// not exist. - /// This test verifies that: - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Should return 404 when template not found") - void deleteTemplate_404_not_found() throws Exception { - // Use a non-existent template ID - String nonExistentId = "non-existing-identifier"; - - mockMvc.perform(MockMvcRequestBuilders.delete(ENTITY_TEMPLATE_PATH + "/" + nonExistentId) - .accept(APPLICATION_JSON) - .with(csrf())) - .andExpect(status().isNotFound()) - .andExpect(content().contentType(APPLICATION_JSON)) - .andExpect(jsonPath("$.error").value("NOT_FOUND")) - .andExpect(jsonPath("$.error_description").exists()); - - assertNotNull(nonExistentId, "Test executed successfully"); - } - - /// Tests the DELETE /api/v1/entity-templates/{id} endpoint when authentication is missing. - /// This test verifies that: - /// @throws Exception if the MockMvc request fails - @Test - @DisplayName("Should return 401 when deleting without user token") - void deleteTemplate_401_without_user_token() throws Exception { - String templateId = "123e4567-e89b-12d3-a456-426614174001"; - mockMvc.perform(MockMvcRequestBuilders.delete(ENTITY_TEMPLATE_PATH + "/" + templateId) - .accept(APPLICATION_JSON) - .with(csrf())) - .andExpect(status().isUnauthorized()); - - } + @Test + @WithMockUser() + void putTemplate_409_whenIdentifierAlreadyExists() throws Exception { + String identifier = "web-service"; + Optional entityTemplateUpdated = entityTemplateRepository + .findByIdentifier("microservice"); + assertThat(entityTemplateUpdated).isPresent(); + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_409_withIdentifierAlreadyExists.json"))) + .andExpect(status().isConflict()).andExpect(content().string( + "{\"error\":\"CONFLICT\",\"error_description\":\"The entity template name Microservice already exists\"}")); + } + + /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint when the name + /// field is + /// missing. + /// This test verifies that: + /// - Validation error message matches expected template name mandatory + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 400 when name is missing") + void putTemplate_400_name_missing() throws Exception { + String identifier = "web-service"; + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_400_name_missing.json"))) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect( + jsonPath("$.error_description").value(ValidationMessages.TEMPLATE_NAME_MANDATORY)); + } + + /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint when name field + /// is + /// blank. + /// This test verifies that: + /// - Validation error message contains expected template name mandatory message + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 400 when name is blank") + void putTemplate_400_name_blank() throws Exception { + String identifier = "web-service"; + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_400_name_blank.json"))) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description") + .value(containsString(ValidationMessages.TEMPLATE_NAME_MANDATORY))); + } + + /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint when name field + /// already exists. + /// This test verifies that: + /// - Validation error message contains expected template name already exists + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 409 when name already exists") + void putTemplate_409_name_already_exists() throws Exception { + String identifier = "web-service"; + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_409_name_already_exists.json"))) + .andExpect(status().isConflict()).andExpect(content().string( + "{\"error\":\"CONFLICT\",\"error_description\":\"The entity template name Microservice already exists\"}")); + } + + /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint when name field + /// is + /// too long. + /// This test verifies that: + /// - Validation error message matches expected template name too long + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 400 when name is too long") + void putTemplate_400_name_too_long() throws Exception { + String identifier = "web-service"; + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_400_name_wrong_size.json"))) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect( + jsonPath("$.error_description").value(ValidationMessages.TEMPLATE_NAME_MAX_SIZE)); + } + + /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint when name field + /// does + /// not respect regex pattern. + /// This test verifies that: + /// - Validation error message matches expected template name pattern + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 400 when name does not respect regex pattern") + void putTemplate_400_name_invalid_pattern() throws Exception { + String identifier = "web-service"; + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_400_name_invalid_pattern.json"))) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect( + jsonPath("$.error_description").value(ValidationMessages.TEMPLATE_NAME_FORMAT)); + } + + /// Tests that the PUT /api/v1/entity-templates/{identifier} endpoint rejects + /// requests with an identifier field in the request body. + /// **This test verifies that:** + /// - The endpoint returns HTTP 400 Bad Request when identifier is in body + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Should reject PUT request with identifier in body and return 400") + void putTemplate_400_identifier_in_body() throws Exception { + String identifier = "web-service"; + mockMvc + .perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + + "putEntityTemplate_400_identifier_in_body.json"))) + .andExpect(status().isBadRequest()); + } + + /// Tests PUT endpoint when attempting to change property type on existing + /// property. + /// Verifies that PropertyTypeChangeException is thrown and returns 400 Bad + /// Request. + @Test + @WithMockUser() + @DisplayName("Should return 400 when changing existing property type") + void putTemplate_400_type_change() throws Exception { + String identifier = "web-service"; + mockMvc + .perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + "putEntityTemplate_200.json"))) + .andExpect(status().isOk()); + mockMvc + .perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + + "putTemplate_400_type_change.json"))) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value( + "Cannot change type of property 'name' from STRING to NUMBER. Property types cannot be modified after creation. Please delete and recreate the property instead.")); + } + + /// Tests PUT endpoint when attempting to change targetTemplateIdentifier on an + /// existing relation. + /// Verifies that RelationTargetTemplateChangeException is thrown and returns + /// 400 Bad Request. + @Test + @WithMockUser() + @DisplayName("Should return 400 when changing existing relation targetTemplateIdentifier") + void putTemplate_400_target_template_identifier_change() throws Exception { + String identifier = "microservice"; + mockMvc + .perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + + "putTemplate_400_target_template_identifier_change.json"))) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(containsString( + "Cannot change target template of relation 'dependencies' from 'service' to 'service-modified'"))); + } + + } + + @Nested + @DisplayName("DELETE /api/v1/entity-templates/{id} - Delete Template") + @Order(4) + class DeleteTemplateTests { + + private static final String ENTITY_TEMPLATE_PATH = "/api/v1/entity-templates"; + + /// Tests the DELETE /api/v1/entity-templates/{id} endpoint for successful + /// template deletion. + /// This test verifies that: + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Should delete template and return 204") + void deleteTemplate_204() throws Exception { + // Use an existing template ID from test data + String templateId = "monitoring-service"; + + mockMvc.perform(MockMvcRequestBuilders.delete(ENTITY_TEMPLATE_PATH + "/" + templateId) + .accept(APPLICATION_JSON).with(csrf())).andExpect(status().isNoContent()); + + assertNotNull(templateId, "Test executed successfully"); + } + + /// Tests the DELETE /api/v1/entity-templates/{id} endpoint when template does + /// not exist. + /// This test verifies that: + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Should return 404 when template not found") + void deleteTemplate_404_not_found() throws Exception { + // Use a non-existent template ID + String nonExistentId = "non-existing-identifier"; + + mockMvc + .perform(MockMvcRequestBuilders.delete(ENTITY_TEMPLATE_PATH + "/" + nonExistentId) + .accept(APPLICATION_JSON).with(csrf())) + .andExpect(status().isNotFound()).andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.error").value("NOT_FOUND")) + .andExpect(jsonPath("$.error_description").exists()); + + assertNotNull(nonExistentId, "Test executed successfully"); + } + + /// Tests the DELETE /api/v1/entity-templates/{id} endpoint when authentication + /// is missing. + /// This test verifies that: + /// @throws Exception if the MockMvc request fails + @Test + @DisplayName("Should return 401 when deleting without user token") + void deleteTemplate_401_without_user_token() throws Exception { + String templateId = "123e4567-e89b-12d3-a456-426614174001"; + mockMvc.perform(MockMvcRequestBuilders.delete(ENTITY_TEMPLATE_PATH + "/" + templateId) + .accept(APPLICATION_JSON).with(csrf())).andExpect(status().isUnauthorized()); + } + } } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/HealthControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/HealthControllerTest.java index a867c9c4..78ad565e 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/HealthControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/HealthControllerTest.java @@ -14,11 +14,10 @@ /// without authentication, ensuring system monitoring capabilities work correctly. class HealthControllerTest extends AbstractIntegrationTest { - @Test - void getHealthWithoutAuth() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.get("/actuator/health").accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andReturn(); - } + @Test + void getHealthWithoutAuth() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.get("/actuator/health").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andReturn(); + } } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java index 4a02053b..dc7e402d 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java @@ -10,6 +10,9 @@ import java.util.Set; import java.util.stream.Stream; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -33,9 +36,6 @@ import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse; -import jakarta.validation.ConstraintViolation; -import jakarta.validation.ConstraintViolationException; - /// Comprehensive unit tests for [ApiExceptionHandler]. /// /// Tests all exception handler methods and utility functions to ensure proper @@ -43,457 +43,479 @@ @DisplayName("ApiExceptionHandler Tests") class ApiExceptionHandlerTest { - private ApiExceptionHandler exceptionHandler; + private ApiExceptionHandler exceptionHandler; + + @BeforeEach + void setUp() throws Exception { + // Use reflection to create instance since constructor is private + Constructor constructor = ApiExceptionHandler.class + .getDeclaredConstructor(); + constructor.setAccessible(true); + exceptionHandler = constructor.newInstance(); + } + + @Nested + @DisplayName("Domain Exception Handling") + class DomainExceptionTests { + + /// Tests the handling of [EntityTemplateNotFoundException] by the + /// [ApiExceptionHandler]. + /// + /// **This test verifies that:** + /// - EntityTemplateNotFoundException is properly caught and handled + /// - HTTP 404 Not Found status is returned + /// - Error response contains the correct error status and description + /// - Original exception message is preserved in the response + @Test + @DisplayName("Should handle EntityTemplateNotFoundException with 404 status") + void shouldHandleEntityTemplateNotFoundException() { + // Given + String errorMessage = "Template with ID 'test-id' not found"; + EntityTemplateNotFoundException exception = new EntityTemplateNotFoundException(errorMessage); + + // When + ResponseEntity response = exceptionHandler + .handleTemplateNotFoundException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.NOT_FOUND.name(), body.getError()); + assertEquals(errorMessage, body.getErrorDescription()); + } + + /// Tests the handling of [EntityTemplateAlreadyExistsException] by the + /// [ApiExceptionHandler]. + /// + /// **This test verifies that:** + /// - EntityTemplateAlreadyExistsException is properly caught and handled + /// - HTTP 409 Conflict status is returned + /// - Error response contains the correct error status and formatted description + /// - Exception message is properly formatted with validation constants + @Test + @DisplayName("Should handle EntityTemplateAlreadyExistsException with 409 status") + void shouldHandleEntityTemplateAlreadyExistsException() { + // Given + String identifier = "duplicate-id"; + EntityTemplateAlreadyExistsException exception = new EntityTemplateAlreadyExistsException( + identifier); + String expectedMessage = "An Entity Template already exists with the same identifier:duplicate-id"; + + // When + ResponseEntity response = exceptionHandler + .handleEntityTemplateAlreadyExistsException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.CONFLICT.name(), body.getError()); + assertEquals(expectedMessage, body.getErrorDescription()); + } - @BeforeEach - void setUp() throws Exception { - // Use reflection to create instance since constructor is private - Constructor constructor = ApiExceptionHandler.class.getDeclaredConstructor(); - constructor.setAccessible(true); - exceptionHandler = constructor.newInstance(); + /// Tests the handling of [EntityAlreadyExistsException] by the + /// [ApiExceptionHandler]. + /// + /// **This test verifies that:** + /// - EntityAlreadyExistsException is properly caught and handled + /// - HTTP 409 Conflict status is returned + /// - Error response contains the original domain exception message + @Test + @DisplayName("Should handle EntityAlreadyExistsException with 409 status") + void shouldHandleEntityAlreadyExistsException() { + // Given + EntityAlreadyExistsException exception = new EntityAlreadyExistsException("my-web-service", + "api-gateway"); + + // When + ResponseEntity response = exceptionHandler + .handleEntityAlreadyExistsException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.CONFLICT.name(), body.getError()); + assertEquals(exception.getMessage(), body.getErrorDescription()); } - @Nested - @DisplayName("Domain Exception Handling") - class DomainExceptionTests { - - /// Tests the handling of [EntityTemplateNotFoundException] by the [ApiExceptionHandler]. - /// - /// **This test verifies that:** - /// - EntityTemplateNotFoundException is properly caught and handled - /// - HTTP 404 Not Found status is returned - /// - Error response contains the correct error status and description - /// - Original exception message is preserved in the response - @Test - @DisplayName("Should handle EntityTemplateNotFoundException with 404 status") - void shouldHandleEntityTemplateNotFoundException() { - // Given - String errorMessage = "Template with ID 'test-id' not found"; - EntityTemplateNotFoundException exception = new EntityTemplateNotFoundException(errorMessage); - - // When - ResponseEntity response = exceptionHandler.handleTemplateNotFoundException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.NOT_FOUND.name(), body.getError()); - assertEquals(errorMessage, body.getErrorDescription()); - } - - /// Tests the handling of [EntityTemplateAlreadyExistsException] by the [ApiExceptionHandler]. - /// - /// **This test verifies that:** - /// - EntityTemplateAlreadyExistsException is properly caught and handled - /// - HTTP 409 Conflict status is returned - /// - Error response contains the correct error status and formatted description - /// - Exception message is properly formatted with validation constants - @Test - @DisplayName("Should handle EntityTemplateAlreadyExistsException with 409 status") - void shouldHandleEntityTemplateAlreadyExistsException() { - // Given - String identifier = "duplicate-id"; - EntityTemplateAlreadyExistsException exception = new EntityTemplateAlreadyExistsException(identifier); - String expectedMessage = "An Entity Template already exists with the same identifier:duplicate-id"; - - // When - ResponseEntity response = exceptionHandler.handleEntityTemplateAlreadyExistsException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.CONFLICT.name(), body.getError()); - assertEquals(expectedMessage, body.getErrorDescription()); - } - - /// Tests the handling of [EntityAlreadyExistsException] by the [ApiExceptionHandler]. - /// - /// **This test verifies that:** - /// - EntityAlreadyExistsException is properly caught and handled - /// - HTTP 409 Conflict status is returned - /// - Error response contains the original domain exception message - @Test - @DisplayName("Should handle EntityAlreadyExistsException with 409 status") - void shouldHandleEntityAlreadyExistsException() { - // Given - EntityAlreadyExistsException exception = new EntityAlreadyExistsException("my-web-service", "api-gateway"); - - // When - ResponseEntity response = exceptionHandler.handleEntityAlreadyExistsException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.CONFLICT.name(), body.getError()); - assertEquals(exception.getMessage(), body.getErrorDescription()); - } - - @Test - @DisplayName("Should handle EntityValidationException with 400 status") - void shouldHandleEntityValidationException() { - EntityValidationException exception = new EntityValidationException(java.util.List.of("Invalid property")); - - ResponseEntity response = exceptionHandler.handleEntityValidationException(exception); - - assertNotNull(response); - assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); - assertEquals(exception.getMessage(), body.getErrorDescription()); - } - - /// Tests the handling of [EntityTemplateNameAlreadyExistsException] by the [ApiExceptionHandler]. - /// - /// **This test verifies that:** - /// - EntityTemplateNameAlreadyExistsException is properly caught and handled - /// - HTTP 409 Conflict status is returned - /// - Error response contains the correct error status and description - @Test - @DisplayName("Should handle EntityTemplateNameAlreadyExistsException with 409 status") - void shouldHandleEntityTemplateNameAlreadyExistsException() { - // Given - String name = "Duplicate Name"; - EntityTemplateNameAlreadyExistsException exception = new EntityTemplateNameAlreadyExistsException(name); - - // When - ResponseEntity response = exceptionHandler.handleEntityTemplateNameAlreadyExistsException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.CONFLICT.name(), body.getError()); - assertEquals(exception.getMessage(), body.getErrorDescription()); - } - - /// Tests the handling of [EntityNotFoundException] by the [ApiExceptionHandler]. - /// - /// **This test verifies that:** - /// - EntityNotFoundException is properly caught and handled - /// - HTTP 404 Not Found status is returned - /// - Error response contains the entity-specific context message - @Test - @DisplayName("Should handle EntityNotFoundException with 404 status") - void shouldHandleEntityNotFoundException() { - // Given - EntityNotFoundException exception = new EntityNotFoundException("web-service", "my-entity"); - - // When - ResponseEntity response = exceptionHandler.handleEntityNotFoundException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.NOT_FOUND.name(), body.getError()); - assertEquals(exception.getMessage(), body.getErrorDescription()); - } + @Test + @DisplayName("Should handle EntityValidationException with 400 status") + void shouldHandleEntityValidationException() { + EntityValidationException exception = new EntityValidationException( + java.util.List.of("Invalid property")); + + ResponseEntity response = exceptionHandler + .handleEntityValidationException(exception); + + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + assertEquals(exception.getMessage(), body.getErrorDescription()); } - @Nested - @DisplayName("Validation Exception Handling") - class ValidationExceptionTests { - - /// Tests the handling of [ConstraintViolationException] with a single validation violation. - /// - /// **This test verifies that:** - /// - ConstraintViolationException is properly caught and handled - /// - HTTP 400 Bad Request status is returned - /// - Single violation message is correctly extracted and returned - /// - Error response format matches expected structure - @Test - @DisplayName("Should handle ConstraintViolationException with single violation") - void shouldHandleConstraintViolationExceptionSingleViolation() { - // Given - ConstraintViolation violation = createMockConstraintViolation("Field must not be null"); - Set> violations = Set.of(violation); - ConstraintViolationException exception = new ConstraintViolationException("Validation failed", violations); - - // When - ResponseEntity response = exceptionHandler.handleConstraintViolationException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); - assertEquals("Field must not be null", body.getErrorDescription()); - } - - /// Tests the handling of [ConstraintViolationException] with multiple validation violations. - /// - /// **This test verifies that:** - /// - ConstraintViolationException with multiple violations is properly handled - /// - HTTP 400 Bad Request status is returned - /// - All violation messages are concatenated with comma separation - /// - Error response contains all validation error messages - @Test - @DisplayName("Should handle ConstraintViolationException with multiple violations") - void shouldHandleConstraintViolationExceptionMultipleViolations() { - // Given - ConstraintViolation violation1 = createMockConstraintViolation("Field1 must not be null"); - ConstraintViolation violation2 = createMockConstraintViolation("Field2 must not be blank"); - Set> violations = Set.of(violation1, violation2); - ConstraintViolationException exception = new ConstraintViolationException("Validation failed", violations); - - // When - ResponseEntity response = exceptionHandler.handleConstraintViolationException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); - - String errorDescription = body.getErrorDescription(); - assertTrue(errorDescription.contains("Field1 must not be null")); - assertTrue(errorDescription.contains("Field2 must not be blank")); - assertTrue(errorDescription.contains(", ")); - } - - /// Tests the handling of [MethodArgumentNotValidException] with field validation errors. - /// - /// **This test verifies that:** - /// - MethodArgumentNotValidException is properly caught and handled - /// - HTTP 400 Bad Request status is returned - /// - Field error messages from binding result are extracted and concatenated - /// - All field validation errors are included in the response with comma separation - /// - /// @throws Exception if reflection fails during test setup - @Test - @DisplayName("Should handle MethodArgumentNotValidException with field errors") - void shouldHandleMethodArgumentNotValidException() throws Exception { - // Given - Object target = new Object(); - BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(target, "testObject"); - bindingResult.addError(new FieldError("testObject", "field1", "Field1 is required")); - bindingResult.addError(new FieldError("testObject", "field2", "Field2 must be valid")); - - // Create a proper MethodParameter mock with required methods - MethodParameter methodParameter = mock(MethodParameter.class); - when(methodParameter.getExecutable()).thenReturn(this.getClass().getMethod("testMethod")); - - MethodArgumentNotValidException exception = new MethodArgumentNotValidException(methodParameter, bindingResult); - - // When - ResponseEntity response = exceptionHandler.handleMethodArgumentNotValidException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); - String errorDescription = body.getErrorDescription(); - assertTrue(errorDescription.contains("Field1 is required")); - assertTrue(errorDescription.contains("Field2 must be valid")); - assertTrue(errorDescription.contains(", ")); - } - - // Helper method for mocking - public void testMethod() { - // Empty method for testing purposes - } - - @SuppressWarnings("unchecked") - private ConstraintViolation createMockConstraintViolation(String message) { - ConstraintViolation violation = mock(ConstraintViolation.class); - when(violation.getMessage()).thenReturn(message); - return violation; - } + /// Tests the handling of [EntityTemplateNameAlreadyExistsException] by the + /// [ApiExceptionHandler]. + /// + /// **This test verifies that:** + /// - EntityTemplateNameAlreadyExistsException is properly caught and handled + /// - HTTP 409 Conflict status is returned + /// - Error response contains the correct error status and description + @Test + @DisplayName("Should handle EntityTemplateNameAlreadyExistsException with 409 status") + void shouldHandleEntityTemplateNameAlreadyExistsException() { + // Given + String name = "Duplicate Name"; + EntityTemplateNameAlreadyExistsException exception = new EntityTemplateNameAlreadyExistsException( + name); + + // When + ResponseEntity response = exceptionHandler + .handleEntityTemplateNameAlreadyExistsException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.CONFLICT.name(), body.getError()); + assertEquals(exception.getMessage(), body.getErrorDescription()); + } + + /// Tests the handling of [EntityNotFoundException] by the + /// [ApiExceptionHandler]. + /// + /// **This test verifies that:** + /// - EntityNotFoundException is properly caught and handled + /// - HTTP 404 Not Found status is returned + /// - Error response contains the entity-specific context message + @Test + @DisplayName("Should handle EntityNotFoundException with 404 status") + void shouldHandleEntityNotFoundException() { + // Given + EntityNotFoundException exception = new EntityNotFoundException("web-service", "my-entity"); + + // When + ResponseEntity response = exceptionHandler + .handleEntityNotFoundException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.NOT_FOUND.name(), body.getError()); + assertEquals(exception.getMessage(), body.getErrorDescription()); + } + } + + @Nested + @DisplayName("Validation Exception Handling") + class ValidationExceptionTests { + + /// Tests the handling of [ConstraintViolationException] with a single + /// validation violation. + /// + /// **This test verifies that:** + /// - ConstraintViolationException is properly caught and handled + /// - HTTP 400 Bad Request status is returned + /// - Single violation message is correctly extracted and returned + /// - Error response format matches expected structure + @Test + @DisplayName("Should handle ConstraintViolationException with single violation") + void shouldHandleConstraintViolationExceptionSingleViolation() { + // Given + ConstraintViolation violation = createMockConstraintViolation( + "Field must not be null"); + Set> violations = Set.of(violation); + ConstraintViolationException exception = new ConstraintViolationException("Validation failed", + violations); + + // When + ResponseEntity response = exceptionHandler + .handleConstraintViolationException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + assertEquals("Field must not be null", body.getErrorDescription()); } - @Nested - @DisplayName("HTTP Message Exception Handling") - class HttpMessageExceptionTests { - - /// Provides test data for [HttpMessageNotReadableException] scenarios. - /// Each argument contains: input message and expected error description. - static Stream httpMessageNotReadableExceptionTestData() { - return Stream.of( - Arguments.of( - "Required request body is missing: public ResponseEntity", - "Request body is required" - ), - Arguments.of( - "JSON parse error: Unexpected character", - "Invalid JSON format in request body" - ), - Arguments.of( - "Cannot deserialize value of type `PropertyType` from String \"INVALID_TYPE\": not one of the values accepted for Enum class", - "Invalid value 'INVALID_TYPE' for property 'type'" - ), - Arguments.of( - "Cannot deserialize value of type `PropertyFormat` from String \"INVALID_FORMAT\": not one of the values accepted for Enum class", - "Invalid value 'INVALID_FORMAT' for property 'format'" - ), - Arguments.of( - "Cannot deserialize value of type `UnknownEnum` from String \"VALUE\": not one of the values accepted for Enum class", - "Invalid enum value in request body" - ), - Arguments.of( - "Cannot deserialize value of type `com.example.SomeType`: some other error", - "Invalid type: expected SomeType" - ), - Arguments.of( - "Something completely unexpected happened", - "Invalid request body format" - ), - Arguments.of( - "Cannot deserialize value of type `PropertyType`: not one of the values accepted for Enum class", - "Invalid value for property 'type'" - ), - Arguments.of( - "Cannot deserialize value of type `UnknownEnum` from String \"VALUE\": not one of the values accepted for Enum class", - "Invalid enum value in request body" - ) - ); - } - - /// Tests the handling of [HttpMessageNotReadableException] when exception message is null. - /// - /// **This test verifies that:** - /// - HttpMessageNotReadableException with null message is properly handled - /// - HTTP 400 Bad Request status is returned - /// - Default error message is provided when original message is null - /// - Graceful handling of edge case scenarios - @Test - @DisplayName("Should handle HttpMessageNotReadableException with null message") - void shouldHandleHttpMessageNotReadableExceptionWithNullMessage() { - // Given - HttpMessageNotReadableException exception = mock(HttpMessageNotReadableException.class); - when(exception.getMessage()).thenReturn(null); - - // When - ResponseEntity response = exceptionHandler.handleHttpMessageNotReadableException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); - assertEquals("Invalid request body format", body.getErrorDescription()); - } - - /// Parameterized test for handling [HttpMessageNotReadableException] with various error scenarios. - /// - /// **This test verifies that different types of HttpMessageNotReadableException are properly - /// parsed and converted to user-friendly error messages:** - /// - Missing request body errors → "Request body is required" - /// - JSON parse errors → "Invalid JSON format in request body" - /// - PropertyType enum deserialization errors → Specific property and value information - /// - Unknown enum deserialization errors → Generic enum error message - /// - /// **Each test case validates that:** - /// - HTTP 400 Bad Request status is returned - /// - Original complex error message is parsed and simplified - /// - User-friendly error description is provided - /// - Error response structure is consistent - /// - /// @param originalMessage the original exception message to be processed - /// @param expectedErrorDescription the expected user-friendly error description - @ParameterizedTest - @MethodSource("httpMessageNotReadableExceptionTestData") - @DisplayName("Should handle HttpMessageNotReadableException with various error types") - void shouldHandleHttpMessageNotReadableExceptionWithVariousErrorTypes(String originalMessage, String expectedErrorDescription) { - // Given - HttpMessageNotReadableException exception = mock(HttpMessageNotReadableException.class); - when(exception.getMessage()).thenReturn(originalMessage); - - // When - ResponseEntity response = exceptionHandler.handleHttpMessageNotReadableException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); - assertEquals(expectedErrorDescription, body.getErrorDescription()); - } + /// Tests the handling of [ConstraintViolationException] with multiple + /// validation violations. + /// + /// **This test verifies that:** + /// - ConstraintViolationException with multiple violations is properly handled + /// - HTTP 400 Bad Request status is returned + /// - All violation messages are concatenated with comma separation + /// - Error response contains all validation error messages + @Test + @DisplayName("Should handle ConstraintViolationException with multiple violations") + void shouldHandleConstraintViolationExceptionMultipleViolations() { + // Given + ConstraintViolation violation1 = createMockConstraintViolation( + "Field1 must not be null"); + ConstraintViolation violation2 = createMockConstraintViolation( + "Field2 must not be blank"); + Set> violations = Set.of(violation1, violation2); + ConstraintViolationException exception = new ConstraintViolationException("Validation failed", + violations); + + // When + ResponseEntity response = exceptionHandler + .handleConstraintViolationException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + + String errorDescription = body.getErrorDescription(); + assertTrue(errorDescription.contains("Field1 must not be null")); + assertTrue(errorDescription.contains("Field2 must not be blank")); + assertTrue(errorDescription.contains(", ")); } - @Nested - @DisplayName("Generic Exception Handling") - class GenericExceptionTests { - - /// Tests the handling of generic Exception as a fallback mechanism. - /// - /// **This test verifies that:** - /// - Unexpected exceptions are caught by the generic handler - /// - HTTP 500 Internal Server Error status is returned - /// - Generic error message is provided to avoid exposing internal details - /// - Exception is properly logged for debugging purposes - @Test - @DisplayName("Should handle generic Exception with 500 status") - void shouldHandleGenericException() { - // Given - Exception exception = new RuntimeException("Unexpected error"); - - // When - ResponseEntity response = exceptionHandler.handleGenericException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.name(), body.getError()); - assertEquals("An unexpected error occurred. Please try again later.", body.getErrorDescription()); - } + /// Tests the handling of [MethodArgumentNotValidException] with field + /// validation errors. + /// + /// **This test verifies that:** + /// - MethodArgumentNotValidException is properly caught and handled + /// - HTTP 400 Bad Request status is returned + /// - Field error messages from binding result are extracted and concatenated + /// - All field validation errors are included in the response with comma + /// separation + /// + /// @throws Exception if reflection fails during test setup + @Test + @DisplayName("Should handle MethodArgumentNotValidException with field errors") + void shouldHandleMethodArgumentNotValidException() throws Exception { + // Given + Object target = new Object(); + BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(target, "testObject"); + bindingResult.addError(new FieldError("testObject", "field1", "Field1 is required")); + bindingResult.addError(new FieldError("testObject", "field2", "Field2 must be valid")); + + // Create a proper MethodParameter mock with required methods + MethodParameter methodParameter = mock(MethodParameter.class); + when(methodParameter.getExecutable()).thenReturn(this.getClass().getMethod("testMethod")); + + MethodArgumentNotValidException exception = new MethodArgumentNotValidException( + methodParameter, bindingResult); + + // When + ResponseEntity response = exceptionHandler + .handleMethodArgumentNotValidException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + String errorDescription = body.getErrorDescription(); + assertTrue(errorDescription.contains("Field1 is required")); + assertTrue(errorDescription.contains("Field2 must be valid")); + assertTrue(errorDescription.contains(", ")); + } + + // Helper method for mocking + public void testMethod() { + // Empty method for testing purposes + } + + @SuppressWarnings("unchecked") + private ConstraintViolation createMockConstraintViolation(String message) { + ConstraintViolation violation = mock(ConstraintViolation.class); + when(violation.getMessage()).thenReturn(message); + return violation; + } + } + + @Nested + @DisplayName("HTTP Message Exception Handling") + class HttpMessageExceptionTests { + + /// Provides test data for [HttpMessageNotReadableException] scenarios. + /// Each argument contains: input message and expected error description. + static Stream httpMessageNotReadableExceptionTestData() { + return Stream.of( + Arguments.of("Required request body is missing: public ResponseEntity", + "Request body is required"), + Arguments.of("JSON parse error: Unexpected character", + "Invalid JSON format in request body"), + Arguments.of( + "Cannot deserialize value of type `PropertyType` from String \"INVALID_TYPE\": not one of the values accepted for Enum class", + "Invalid value 'INVALID_TYPE' for property 'type'"), + Arguments.of( + "Cannot deserialize value of type `PropertyFormat` from String \"INVALID_FORMAT\": not one of the values accepted for Enum class", + "Invalid value 'INVALID_FORMAT' for property 'format'"), + Arguments.of( + "Cannot deserialize value of type `UnknownEnum` from String \"VALUE\": not one of the values accepted for Enum class", + "Invalid enum value in request body"), + Arguments.of("Cannot deserialize value of type `com.example.SomeType`: some other error", + "Invalid type: expected SomeType"), + Arguments.of("Something completely unexpected happened", "Invalid request body format"), + Arguments.of( + "Cannot deserialize value of type `PropertyType`: not one of the values accepted for Enum class", + "Invalid value for property 'type'"), + Arguments.of( + "Cannot deserialize value of type `UnknownEnum` from String \"VALUE\": not one of the values accepted for Enum class", + "Invalid enum value in request body")); + } + + /// Tests the handling of [HttpMessageNotReadableException] when exception + /// message is null. + /// + /// **This test verifies that:** + /// - HttpMessageNotReadableException with null message is properly handled + /// - HTTP 400 Bad Request status is returned + /// - Default error message is provided when original message is null + /// - Graceful handling of edge case scenarios + @Test + @DisplayName("Should handle HttpMessageNotReadableException with null message") + void shouldHandleHttpMessageNotReadableExceptionWithNullMessage() { + // Given + HttpMessageNotReadableException exception = mock(HttpMessageNotReadableException.class); + when(exception.getMessage()).thenReturn(null); + + // When + ResponseEntity response = exceptionHandler + .handleHttpMessageNotReadableException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + assertEquals("Invalid request body format", body.getErrorDescription()); + } + + /// Parameterized test for handling [HttpMessageNotReadableException] with + /// various error scenarios. + /// + /// **This test verifies that different types of HttpMessageNotReadableException + /// are properly + /// parsed and converted to user-friendly error messages:** + /// - Missing request body errors → "Request body is required" + /// - JSON parse errors → "Invalid JSON format in request body" + /// - PropertyType enum deserialization errors → Specific property and value + /// information + /// - Unknown enum deserialization errors → Generic enum error message + /// + /// **Each test case validates that:** + /// - HTTP 400 Bad Request status is returned + /// - Original complex error message is parsed and simplified + /// - User-friendly error description is provided + /// - Error response structure is consistent + /// + /// @param originalMessage the original exception message to be processed + /// @param expectedErrorDescription the expected user-friendly error description + @ParameterizedTest + @MethodSource("httpMessageNotReadableExceptionTestData") + @DisplayName("Should handle HttpMessageNotReadableException with various error types") + void shouldHandleHttpMessageNotReadableExceptionWithVariousErrorTypes(String originalMessage, + String expectedErrorDescription) { + // Given + HttpMessageNotReadableException exception = mock(HttpMessageNotReadableException.class); + when(exception.getMessage()).thenReturn(originalMessage); + + // When + ResponseEntity response = exceptionHandler + .handleHttpMessageNotReadableException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + assertEquals(expectedErrorDescription, body.getErrorDescription()); + } + } + + @Nested + @DisplayName("Generic Exception Handling") + class GenericExceptionTests { + + /// Tests the handling of generic Exception as a fallback mechanism. + /// + /// **This test verifies that:** + /// - Unexpected exceptions are caught by the generic handler + /// - HTTP 500 Internal Server Error status is returned + /// - Generic error message is provided to avoid exposing internal details + /// - Exception is properly logged for debugging purposes + @Test + @DisplayName("Should handle generic Exception with 500 status") + void shouldHandleGenericException() { + // Given + Exception exception = new RuntimeException("Unexpected error"); + + // When + ResponseEntity response = exceptionHandler.handleGenericException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.name(), body.getError()); + assertEquals("An unexpected error occurred. Please try again later.", + body.getErrorDescription()); + } + } + + @Nested + @DisplayName("ErrorResponse Class Tests") + class ErrorResponseTests { + + /// Tests the creation of [ErrorResponse] using the all-arguments constructor. + /// + /// **This test verifies that:** + /// - ErrorResponse can be instantiated with HttpStatus and description + /// - All fields are properly initialized with provided values + /// - Getter methods return the expected values + /// - Object is successfully created and accessible + @Test + @DisplayName("Should create ErrorResponse with all args constructor") + void shouldCreateErrorResponseWithAllArgsConstructor() { + // Given + HttpStatus status = HttpStatus.BAD_REQUEST; + String description = "Test error message"; + + // When + ErrorResponse errorResponse = new ErrorResponse(status.name(), description); + + // Then + assertNotNull(errorResponse); + assertEquals(status.name(), errorResponse.getError()); + assertEquals(description, errorResponse.getErrorDescription()); } - @Nested - @DisplayName("ErrorResponse Class Tests") - class ErrorResponseTests { - - /// Tests the creation of [ErrorResponse] using the all-arguments constructor. - /// - /// **This test verifies that:** - /// - ErrorResponse can be instantiated with HttpStatus and description - /// - All fields are properly initialized with provided values - /// - Getter methods return the expected values - /// - Object is successfully created and accessible - @Test - @DisplayName("Should create ErrorResponse with all args constructor") - void shouldCreateErrorResponseWithAllArgsConstructor() { - // Given - HttpStatus status = HttpStatus.BAD_REQUEST; - String description = "Test error message"; - - // When - ErrorResponse errorResponse = new ErrorResponse(status.name(), description); - - // Then - assertNotNull(errorResponse); - assertEquals(status.name(), errorResponse.getError()); - assertEquals(description, errorResponse.getErrorDescription()); - } - - /// Tests the creation of [ErrorResponse] using the no-arguments constructor. - /// - /// **This test verifies that:** - /// - ErrorResponse can be instantiated without parameters - /// - Object is successfully created with default/null field values - /// - Constructor works with `@NoArgsConstructor(force = true)` annotation - /// - Provides flexibility for frameworks requiring default constructors - @Test - @DisplayName("Should create ErrorResponse with no args constructor") - void shouldCreateErrorResponseWithNoArgsConstructor() { - ErrorResponse errorResponse = new ErrorResponse(); - assertNotNull(errorResponse); - } + /// Tests the creation of [ErrorResponse] using the no-arguments constructor. + /// + /// **This test verifies that:** + /// - ErrorResponse can be instantiated without parameters + /// - Object is successfully created with default/null field values + /// - Constructor works with `@NoArgsConstructor(force = true)` annotation + /// - Provides flexibility for frameworks requiring default constructors + @Test + @DisplayName("Should create ErrorResponse with no args constructor") + void shouldCreateErrorResponseWithNoArgsConstructor() { + ErrorResponse errorResponse = new ErrorResponse(); + assertNotNull(errorResponse); } + } } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/EntityTemplateMapperTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/EntityTemplateMapperTest.java index 0771e993..0ce3e066 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/EntityTemplateMapperTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/EntityTemplateMapperTest.java @@ -30,609 +30,465 @@ @DisplayName("EntityTemplateMapper Tests") class EntityTemplateMapperTest { - private EntityTemplateMapper mapper; + private EntityTemplateMapper mapper; + + @BeforeEach + void setUp() { + mapper = new EntityTemplateMapper(); + } + + @Nested + @DisplayName("EntityTemplate Mapping Tests") + class EntityTemplateMappingTests { + + @Test + @DisplayName("Should map EntityTemplateCreateDtoIn to EntityTemplate") + void shouldMapDtoInToEntity() { + // Given + var propertyRules = PropertyRulesDtoIn.builder().format(PropertyFormat.URL) + .enumValues(new String[]{}).regex("").maxLength(200).minLength(1).maxValue(0).minValue(0) + .build(); + + var propertyDefinition = PropertyDefinitionDtoIn.builder().name("applicationName") + .description("Name of the application").type(PropertyType.STRING).required(true) + .rules(propertyRules).build(); + + var relationDefinition = RelationDefinitionDtoIn.builder().name("dependencies") + .targetTemplateIdentifier("service").required(false).toMany(true).build(); + + var commonFields = EntityTemplateDtoInCommonFields.builder().description("A service template") + .propertiesDefinitions(List.of(propertyDefinition)) + .relationsDefinitions(List.of(relationDefinition)).build(); + + var dto = EntityTemplateCreateDtoIn.builder().identifier("service-template") + .commonFields(commonFields).build(); + + // When + EntityTemplate result = mapper.fromDtoToEntityTemplate(dto); + + // Then + assertThat(result).isNotNull(); + assertThat(result.identifier()).isEqualTo("service-template"); + assertThat(result.description()).isEqualTo("A service template"); + assertThat(result.propertiesDefinitions()).hasSize(1); + assertThat(result.relationsDefinitions()).hasSize(1); + + // Check property definition + PropertyDefinition mappedProperty = result.propertiesDefinitions().get(0); + assertThat(mappedProperty.name()).isEqualTo("applicationName"); + assertThat(mappedProperty.description()).isEqualTo("Name of the application"); + assertThat(mappedProperty.type()).isEqualTo(PropertyType.STRING); + assertThat(mappedProperty.required()).isTrue(); + + // Check relation definition + RelationDefinition mappedRelation = result.relationsDefinitions().get(0); + assertThat(mappedRelation.name()).isEqualTo("dependencies"); + assertThat(mappedRelation.targetTemplateIdentifier()).isEqualTo("service"); + assertThat(mappedRelation.required()).isFalse(); + assertThat(mappedRelation.toMany()).isTrue(); + } + + @Test + @DisplayName("Should handle null EntityTemplateCreateDtoIn") + void shouldHandleNullDtoIn() { + // When + EntityTemplate result = mapper.fromDtoToEntityTemplate((EntityTemplateCreateDtoIn) null); + + // Then + assertThat(result).isNull(); + } + + @Test + @DisplayName("Should map EntityTemplate to EntityTemplateDtoOut") + void shouldMapEntityToDtoOut() { + // Given + var propertyRules = new PropertyRules(UUID.randomUUID(), PropertyFormat.URL, List.of(), "", + 200, 1, 0, 0); + + var propertyDefinition = new PropertyDefinition(UUID.randomUUID(), "applicationName", + "Name of the application", PropertyType.STRING, true, propertyRules); + + var relationDefinition = new RelationDefinition(UUID.randomUUID(), "dependencies", "service", + false, true); + + var entity = new EntityTemplate(UUID.randomUUID(), "service-template", "Service Template", + "A service template", List.of(propertyDefinition), List.of(relationDefinition)); + + // When + EntityTemplateDtoOut result = mapper.fromEntityTemplatetoDto(entity); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getIdentifier()).isEqualTo("service-template"); + assertThat(result.getDescription()).isEqualTo("A service template"); + assertThat(result.getPropertiesDefinitions()).hasSize(1); + assertThat(result.getRelationsDefinitions()).hasSize(1); + + // Check property definition + PropertyDefinitionDtoOut mappedProperty = result.getPropertiesDefinitions().get(0); + assertThat(mappedProperty.getName()).isEqualTo("applicationName"); + assertThat(mappedProperty.getDescription()).isEqualTo("Name of the application"); + assertThat(mappedProperty.getType()).isEqualTo(PropertyType.STRING); + assertThat(mappedProperty.isRequired()).isTrue(); + + // Check relation definition + RelationDefinitionDtoOut mappedRelation = result.getRelationsDefinitions().get(0); + assertThat(mappedRelation.getName()).isEqualTo("dependencies"); + assertThat(mappedRelation.getTargetTemplateIdentifier()).isEqualTo("service"); + assertThat(mappedRelation.isRequired()).isFalse(); + assertThat(mappedRelation.isToMany()).isTrue(); + } + + @Test + @DisplayName("Should handle null EntityTemplate") + void shouldHandleNullEntity() { + // When + EntityTemplateDtoOut result = mapper.fromEntityTemplatetoDto((EntityTemplate) null); + + // Then + assertThat(result).isNull(); + } + + @Test + @DisplayName("Should map list of EntityTemplate to list of EntityTemplateDtoOut") + void shouldMapEntityListToDtoOutList() { + // Given + var entity1 = new EntityTemplate(UUID.randomUUID(), "template1", "Template 1", + "Description 1", List.of(), List.of()); + + var entity2 = new EntityTemplate(UUID.randomUUID(), "template2", "Template 2", + "Description 2", List.of(), List.of()); + + List entities = List.of(entity1, entity2); + + // When + List result = mapper.fromEntityTemplatesToDtos(entities); + + // Then + assertThat(result).hasSize(2); + assertThat(result.get(0).getIdentifier()).isEqualTo("template1"); + assertThat(result.get(1).getIdentifier()).isEqualTo("template2"); + } + + @Test + @DisplayName("Should handle null list of EntityTemplate") + void shouldHandleNullEntityList() { + // When + List result = mapper.fromEntityTemplatesToDtos(null); + + // Then + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("PropertyDefinition Mapping Tests") + class PropertyDefinitionMappingTests { + + @Test + @DisplayName("Should map PropertyDefinitionDtoIn to PropertyDefinition") + void shouldMapPropertyDtoInToEntity() { + // Given + var rules = PropertyRulesDtoIn.builder().format(PropertyFormat.EMAIL) + .enumValues(new String[]{"value1", "value2"}).regex(".*@.*").maxLength(100).minLength(5) + .maxValue(10).minValue(1).build(); + + var dto = PropertyDefinitionDtoIn.builder().name("email").description("User email address") + .type(PropertyType.STRING).required(true).rules(rules).build(); + + // When + PropertyDefinition result = mapper.toToPropertyDefinition(dto); + + // Then + assertThat(result).isNotNull(); + assertThat(result.name()).isEqualTo("email"); + assertThat(result.description()).isEqualTo("User email address"); + assertThat(result.type()).isEqualTo(PropertyType.STRING); + assertThat(result.required()).isTrue(); + assertThat(result.rules()).isNotNull(); + assertThat(result.rules().format()).isEqualTo(PropertyFormat.EMAIL); + } + + @Test + @DisplayName("Should handle null PropertyDefinitionDtoIn") + void shouldHandleNullPropertyDtoIn() { + // When + PropertyDefinition result = mapper.toToPropertyDefinition((PropertyDefinitionDtoIn) null); + + // Then + assertThat(result).isNull(); + } + + @Test + @DisplayName("Should map PropertyDefinition to PropertyDefinitionDtoOut") + void shouldMapPropertyEntityToDtoOut() { + // Given + var rules = new PropertyRules(UUID.randomUUID(), PropertyFormat.EMAIL, + List.of("value1", "value2"), ".*@.*", 100, 5, 10, 1); + + var entity = new PropertyDefinition(UUID.randomUUID(), "email", "User email address", + PropertyType.STRING, true, rules); + + // When + PropertyDefinitionDtoOut result = mapper.toDto(entity); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getName()).isEqualTo("email"); + assertThat(result.getDescription()).isEqualTo("User email address"); + assertThat(result.getType()).isEqualTo(PropertyType.STRING); + assertThat(result.isRequired()).isTrue(); + assertThat(result.getRules()).isNotNull(); + } + + @Test + @DisplayName("Should handle null PropertyDefinition") + void shouldHandleNullPropertyEntity() { + // When + PropertyDefinitionDtoOut result = mapper.toDto((PropertyDefinition) null); + + // Then + assertThat(result).isNull(); + } + } + + @Nested + @DisplayName("PropertyRules Mapping Tests") + class PropertyRulesMappingTests { + + @Test + @DisplayName("Should map PropertyRulesDtoIn to PropertyRules") + void shouldMapRulesDtoInToEntity() { + // Given + var dto = PropertyRulesDtoIn.builder().format(PropertyFormat.URL) + .enumValues(new String[]{"HTTP", "HTTPS"}).regex("^https?://.*").maxLength(500) + .minLength(10).maxValue(100).minValue(1).build(); + + // When + PropertyRules result = mapper.toPropertyRules(dto); + + // Then + assertThat(result).isNotNull(); + assertThat(result.format()).isEqualTo(PropertyFormat.URL); + assertThat(result.enumValues()).containsExactly("HTTP", "HTTPS"); + assertThat(result.regex()).isEqualTo("^https?://.*"); + assertThat(result.maxLength()).isEqualTo(500); + assertThat(result.minLength()).isEqualTo(10); + assertThat(result.maxValue()).isEqualTo(100); + assertThat(result.minValue()).isEqualTo(1); + } + + @Test + @DisplayName("Should normalize enum_values to uppercase") + void shouldNormalizeEnumValuesToUppercase() { + // Given + var dto = PropertyRulesDtoIn.builder() + .enumValues(new String[]{"EMAil", "postal_code", "ACTIVE"}).build(); + + // When + PropertyRules result = mapper.toPropertyRules(dto); + + // Then + assertThat(result.enumValues()).containsExactly("EMAIL", "POSTAL_CODE", "ACTIVE"); + } - @BeforeEach - void setUp() { - mapper = new EntityTemplateMapper(); + @Test + @DisplayName("Should handle null PropertyRulesDtoIn") + void shouldHandleNullRulesDtoIn() { + // When + PropertyRules result = mapper.toPropertyRules((PropertyRulesDtoIn) null); + + // Then + assertThat(result).isNull(); + } + + @Test + @DisplayName("Should map PropertyRules to PropertyRulesDtoOut") + void shouldMapRulesEntityToDtoOut() { + // Given + var entity = new PropertyRules(UUID.randomUUID(), PropertyFormat.URL, + List.of("http", "https"), "^https?://.*", 500, 10, 100, 1); + + // When + PropertyRulesDtoOut result = mapper.toDto(entity); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getFormat()).isEqualTo(PropertyFormat.URL); + assertThat(result.getEnumValues()).containsExactly("http", "https"); + assertThat(result.getRegex()).isEqualTo("^https?://.*"); + assertThat(result.getMaxLength()).isEqualTo(500); + assertThat(result.getMinLength()).isEqualTo(10); + assertThat(result.getMaxValue()).isEqualTo(100); + assertThat(result.getMinValue()).isEqualTo(1); + } + + @Test + @DisplayName("Should handle null PropertyRules") + void shouldHandleNullRulesEntity() { + // When + PropertyRulesDtoOut result = mapper.toDto((PropertyRules) null); + + // Then + assertThat(result).isNull(); + } + } + + @Nested + @DisplayName("RelationDefinition Mapping Tests") + class RelationDefinitionMappingTests { + + @Test + @DisplayName("Should map RelationDefinitionDtoIn to RelationDefinition") + void shouldMapRelationDtoInToEntity() { + // Given + var dto = RelationDefinitionDtoIn.builder().name("parentService") + .targetTemplateIdentifier("service").required(true).toMany(false).build(); + + // When + RelationDefinition result = mapper.toRelationDefinition(dto); + + // Then + assertThat(result).isNotNull(); + assertThat(result.name()).isEqualTo("parentService"); + assertThat(result.targetTemplateIdentifier()).isEqualTo("service"); + assertThat(result.required()).isTrue(); + assertThat(result.toMany()).isFalse(); + } + + @Test + @DisplayName("Should handle null RelationDefinitionDtoIn") + void shouldHandleNullRelationDtoIn() { + // When + RelationDefinition result = mapper.toRelationDefinition((RelationDefinitionDtoIn) null); + + // Then + assertThat(result).isNull(); + } + + @Test + @DisplayName("Should map RelationDefinition to RelationDefinitionDtoOut") + void shouldMapRelationEntityToDtoOut() { + // Given + var entity = new RelationDefinition(UUID.randomUUID(), "childServices", "service", false, + true); + + // When + RelationDefinitionDtoOut result = mapper.toDto(entity); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getName()).isEqualTo("childServices"); + assertThat(result.getTargetTemplateIdentifier()).isEqualTo("service"); + assertThat(result.isRequired()).isFalse(); + assertThat(result.isToMany()).isTrue(); } - @Nested - @DisplayName("EntityTemplate Mapping Tests") - class EntityTemplateMappingTests { - - @Test - @DisplayName("Should map EntityTemplateCreateDtoIn to EntityTemplate") - void shouldMapDtoInToEntity() { - // Given - var propertyRules = PropertyRulesDtoIn.builder() - .format(PropertyFormat.URL) - .enumValues(new String[]{}) - .regex("") - .maxLength(200) - .minLength(1) - .maxValue(0) - .minValue(0) - .build(); - - var propertyDefinition = PropertyDefinitionDtoIn.builder() - .name("applicationName") - .description("Name of the application") - .type(PropertyType.STRING) - .required(true) - .rules(propertyRules) - .build(); - - var relationDefinition = RelationDefinitionDtoIn.builder() - .name("dependencies") - .targetTemplateIdentifier("service") - .required(false) - .toMany(true) - .build(); - - var commonFields = EntityTemplateDtoInCommonFields.builder() - .description("A service template") - .propertiesDefinitions(List.of(propertyDefinition)) - .relationsDefinitions(List.of(relationDefinition)) - .build(); - - var dto = EntityTemplateCreateDtoIn.builder() - .identifier("service-template") - .commonFields(commonFields) - .build(); - - // When - EntityTemplate result = mapper.fromDtoToEntityTemplate(dto); - - // Then - assertThat(result).isNotNull(); - assertThat(result.identifier()).isEqualTo("service-template"); - assertThat(result.description()).isEqualTo("A service template"); - assertThat(result.propertiesDefinitions()).hasSize(1); - assertThat(result.relationsDefinitions()).hasSize(1); - - // Check property definition - PropertyDefinition mappedProperty = result.propertiesDefinitions().get(0); - assertThat(mappedProperty.name()).isEqualTo("applicationName"); - assertThat(mappedProperty.description()).isEqualTo("Name of the application"); - assertThat(mappedProperty.type()).isEqualTo(PropertyType.STRING); - assertThat(mappedProperty.required()).isTrue(); - - // Check relation definition - RelationDefinition mappedRelation = result.relationsDefinitions().get(0); - assertThat(mappedRelation.name()).isEqualTo("dependencies"); - assertThat(mappedRelation.targetTemplateIdentifier()).isEqualTo("service"); - assertThat(mappedRelation.required()).isFalse(); - assertThat(mappedRelation.toMany()).isTrue(); - } - - @Test - @DisplayName("Should handle null EntityTemplateCreateDtoIn") - void shouldHandleNullDtoIn() { - // When - EntityTemplate result = mapper.fromDtoToEntityTemplate((EntityTemplateCreateDtoIn) null); - - // Then - assertThat(result).isNull(); - } - - @Test - @DisplayName("Should map EntityTemplate to EntityTemplateDtoOut") - void shouldMapEntityToDtoOut() { - // Given - var propertyRules = new PropertyRules( - UUID.randomUUID(), - PropertyFormat.URL, - List.of(), - "", - 200, - 1, - 0, - 0 - ); - - var propertyDefinition = new PropertyDefinition( - UUID.randomUUID(), - "applicationName", - "Name of the application", - PropertyType.STRING, - true, - propertyRules - ); - - var relationDefinition = new RelationDefinition( - UUID.randomUUID(), - "dependencies", - "service", - false, - true - ); - - var entity = new EntityTemplate( - UUID.randomUUID(), - "service-template", - "Service Template", - "A service template", - List.of(propertyDefinition), - List.of(relationDefinition) - ); - - // When - EntityTemplateDtoOut result = mapper.fromEntityTemplatetoDto(entity); - - // Then - assertThat(result).isNotNull(); - assertThat(result.getIdentifier()).isEqualTo("service-template"); - assertThat(result.getDescription()).isEqualTo("A service template"); - assertThat(result.getPropertiesDefinitions()).hasSize(1); - assertThat(result.getRelationsDefinitions()).hasSize(1); - - // Check property definition - PropertyDefinitionDtoOut mappedProperty = result.getPropertiesDefinitions().get(0); - assertThat(mappedProperty.getName()).isEqualTo("applicationName"); - assertThat(mappedProperty.getDescription()).isEqualTo("Name of the application"); - assertThat(mappedProperty.getType()).isEqualTo(PropertyType.STRING); - assertThat(mappedProperty.isRequired()).isTrue(); - - // Check relation definition - RelationDefinitionDtoOut mappedRelation = result.getRelationsDefinitions().get(0); - assertThat(mappedRelation.getName()).isEqualTo("dependencies"); - assertThat(mappedRelation.getTargetTemplateIdentifier()).isEqualTo("service"); - assertThat(mappedRelation.isRequired()).isFalse(); - assertThat(mappedRelation.isToMany()).isTrue(); - } - - @Test - @DisplayName("Should handle null EntityTemplate") - void shouldHandleNullEntity() { - // When - EntityTemplateDtoOut result = mapper.fromEntityTemplatetoDto((EntityTemplate) null); - - // Then - assertThat(result).isNull(); - } - - @Test - @DisplayName("Should map list of EntityTemplate to list of EntityTemplateDtoOut") - void shouldMapEntityListToDtoOutList() { - // Given - var entity1 = new EntityTemplate( - UUID.randomUUID(), - "template1", - "Template 1", - "Description 1", - List.of(), - List.of() - ); - - var entity2 = new EntityTemplate( - UUID.randomUUID(), - "template2", - "Template 2", - "Description 2", - List.of(), - List.of() - ); - - List entities = List.of(entity1, entity2); - - // When - List result = mapper.fromEntityTemplatesToDtos(entities); - - // Then - assertThat(result).hasSize(2); - assertThat(result.get(0).getIdentifier()).isEqualTo("template1"); - assertThat(result.get(1).getIdentifier()).isEqualTo("template2"); - } - - @Test - @DisplayName("Should handle null list of EntityTemplate") - void shouldHandleNullEntityList() { - // When - List result = mapper.fromEntityTemplatesToDtos(null); - - // Then - assertThat(result).isEmpty(); - } + @Test + @DisplayName("Should handle null RelationDefinition") + void shouldHandleNullRelationEntity() { + // When + RelationDefinitionDtoOut result = mapper.toDto((RelationDefinition) null); + + // Then + assertThat(result).isNull(); + } + } + + @Nested + @DisplayName("List Mapping Tests") + class ListMappingTests { + + @Test + @DisplayName("Should map list of PropertyDefinitionDtoIn to list of PropertyDefinition") + void shouldMapPropertyDtoInListToEntityList() { + // Given + var dto1 = PropertyDefinitionDtoIn.builder().name("prop1").description("Property 1") + .type(PropertyType.STRING).required(true).build(); + + var dto2 = PropertyDefinitionDtoIn.builder().name("prop2").description("Property 2") + .type(PropertyType.NUMBER).required(false).build(); + + List dtos = List.of(dto1, dto2); + + // When + List result = mapper.toPropertyDefinitionEntities(dtos); + + // Then + assertThat(result).hasSize(2); + assertThat(result.get(0).name()).isEqualTo("prop1"); + assertThat(result.get(1).name()).isEqualTo("prop2"); } - @Nested - @DisplayName("PropertyDefinition Mapping Tests") - class PropertyDefinitionMappingTests { - - @Test - @DisplayName("Should map PropertyDefinitionDtoIn to PropertyDefinition") - void shouldMapPropertyDtoInToEntity() { - // Given - var rules = PropertyRulesDtoIn.builder() - .format(PropertyFormat.EMAIL) - .enumValues(new String[]{"value1", "value2"}) - .regex(".*@.*") - .maxLength(100) - .minLength(5) - .maxValue(10) - .minValue(1) - .build(); - - var dto = PropertyDefinitionDtoIn.builder() - .name("email") - .description("User email address") - .type(PropertyType.STRING) - .required(true) - .rules(rules) - .build(); - - // When - PropertyDefinition result = mapper.toToPropertyDefinition(dto); - - // Then - assertThat(result).isNotNull(); - assertThat(result.name()).isEqualTo("email"); - assertThat(result.description()).isEqualTo("User email address"); - assertThat(result.type()).isEqualTo(PropertyType.STRING); - assertThat(result.required()).isTrue(); - assertThat(result.rules()).isNotNull(); - assertThat(result.rules().format()).isEqualTo(PropertyFormat.EMAIL); - } - - @Test - @DisplayName("Should handle null PropertyDefinitionDtoIn") - void shouldHandleNullPropertyDtoIn() { - // When - PropertyDefinition result = mapper.toToPropertyDefinition((PropertyDefinitionDtoIn) null); - - // Then - assertThat(result).isNull(); - } - - @Test - @DisplayName("Should map PropertyDefinition to PropertyDefinitionDtoOut") - void shouldMapPropertyEntityToDtoOut() { - // Given - var rules = new PropertyRules( - UUID.randomUUID(), - PropertyFormat.EMAIL, - List.of("value1", "value2"), - ".*@.*", - 100, - 5, - 10, - 1 - ); - - var entity = new PropertyDefinition( - UUID.randomUUID(), - "email", - "User email address", - PropertyType.STRING, - true, - rules - ); - - // When - PropertyDefinitionDtoOut result = mapper.toDto(entity); - - // Then - assertThat(result).isNotNull(); - assertThat(result.getName()).isEqualTo("email"); - assertThat(result.getDescription()).isEqualTo("User email address"); - assertThat(result.getType()).isEqualTo(PropertyType.STRING); - assertThat(result.isRequired()).isTrue(); - assertThat(result.getRules()).isNotNull(); - } - - @Test - @DisplayName("Should handle null PropertyDefinition") - void shouldHandleNullPropertyEntity() { - // When - PropertyDefinitionDtoOut result = mapper.toDto((PropertyDefinition) null); - - // Then - assertThat(result).isNull(); - } + @Test + @DisplayName("Should map list of PropertyDefinition to list of PropertyDefinitionDtoOut") + void shouldMapPropertyEntityListToDtoOutList() { + // Given + var entity1 = new PropertyDefinition(UUID.randomUUID(), "prop1", "Property 1", + PropertyType.STRING, true, null); + + var entity2 = new PropertyDefinition(UUID.randomUUID(), "prop2", "Property 2", + PropertyType.NUMBER, false, null); + + List entities = List.of(entity1, entity2); + + // When + List result = mapper.toPropertyDefinitionDtos(entities); + + // Then + assertThat(result).hasSize(2); + assertThat(result.get(0).getName()).isEqualTo("prop1"); + assertThat(result.get(1).getName()).isEqualTo("prop2"); } - @Nested - @DisplayName("PropertyRules Mapping Tests") - class PropertyRulesMappingTests { - - @Test - @DisplayName("Should map PropertyRulesDtoIn to PropertyRules") - void shouldMapRulesDtoInToEntity() { - // Given - var dto = PropertyRulesDtoIn.builder() - .format(PropertyFormat.URL) - .enumValues(new String[]{"HTTP", "HTTPS"}) - .regex("^https?://.*") - .maxLength(500) - .minLength(10) - .maxValue(100) - .minValue(1) - .build(); - - // When - PropertyRules result = mapper.toPropertyRules(dto); - - // Then - assertThat(result).isNotNull(); - assertThat(result.format()).isEqualTo(PropertyFormat.URL); - assertThat(result.enumValues()).containsExactly("HTTP", "HTTPS"); - assertThat(result.regex()).isEqualTo("^https?://.*"); - assertThat(result.maxLength()).isEqualTo(500); - assertThat(result.minLength()).isEqualTo(10); - assertThat(result.maxValue()).isEqualTo(100); - assertThat(result.minValue()).isEqualTo(1); - } - - @Test - @DisplayName("Should normalize enum_values to uppercase") - void shouldNormalizeEnumValuesToUppercase() { - // Given - var dto = PropertyRulesDtoIn.builder() - .enumValues(new String[]{"EMAil", "postal_code", "ACTIVE"}) - .build(); - - // When - PropertyRules result = mapper.toPropertyRules(dto); - - // Then - assertThat(result.enumValues()).containsExactly("EMAIL", "POSTAL_CODE", "ACTIVE"); - } - - @Test - @DisplayName("Should handle null PropertyRulesDtoIn") - void shouldHandleNullRulesDtoIn() { - // When - PropertyRules result = mapper.toPropertyRules((PropertyRulesDtoIn) null); - - // Then - assertThat(result).isNull(); - } - - @Test - @DisplayName("Should map PropertyRules to PropertyRulesDtoOut") - void shouldMapRulesEntityToDtoOut() { - // Given - var entity = new PropertyRules( - UUID.randomUUID(), - PropertyFormat.URL, - List.of("http", "https"), - "^https?://.*", - 500, - 10, - 100, - 1 - ); - - // When - PropertyRulesDtoOut result = mapper.toDto(entity); - - // Then - assertThat(result).isNotNull(); - assertThat(result.getFormat()).isEqualTo(PropertyFormat.URL); - assertThat(result.getEnumValues()).containsExactly("http", "https"); - assertThat(result.getRegex()).isEqualTo("^https?://.*"); - assertThat(result.getMaxLength()).isEqualTo(500); - assertThat(result.getMinLength()).isEqualTo(10); - assertThat(result.getMaxValue()).isEqualTo(100); - assertThat(result.getMinValue()).isEqualTo(1); - } - - @Test - @DisplayName("Should handle null PropertyRules") - void shouldHandleNullRulesEntity() { - // When - PropertyRulesDtoOut result = mapper.toDto((PropertyRules) null); - - // Then - assertThat(result).isNull(); - } + @Test + @DisplayName("Should map list of RelationDefinitionDtoIn to list of RelationDefinition") + void shouldMapRelationDtoInListToEntityList() { + // Given + var dto1 = RelationDefinitionDtoIn.builder().name("rel1").targetTemplateIdentifier("target1") + .required(true).toMany(false).build(); + + var dto2 = RelationDefinitionDtoIn.builder().name("rel2").targetTemplateIdentifier("target2") + .required(false).toMany(true).build(); + + List dtos = List.of(dto1, dto2); + + // When + List result = mapper.toRelationDefinitionEntities(dtos); + + // Then + assertThat(result).hasSize(2); + assertThat(result.get(0).name()).isEqualTo("rel1"); + assertThat(result.get(1).name()).isEqualTo("rel2"); } - @Nested - @DisplayName("RelationDefinition Mapping Tests") - class RelationDefinitionMappingTests { - - @Test - @DisplayName("Should map RelationDefinitionDtoIn to RelationDefinition") - void shouldMapRelationDtoInToEntity() { - // Given - var dto = RelationDefinitionDtoIn.builder() - .name("parentService") - .targetTemplateIdentifier("service") - .required(true) - .toMany(false) - .build(); - - // When - RelationDefinition result = mapper.toRelationDefinition(dto); - - // Then - assertThat(result).isNotNull(); - assertThat(result.name()).isEqualTo("parentService"); - assertThat(result.targetTemplateIdentifier()).isEqualTo("service"); - assertThat(result.required()).isTrue(); - assertThat(result.toMany()).isFalse(); - } - - @Test - @DisplayName("Should handle null RelationDefinitionDtoIn") - void shouldHandleNullRelationDtoIn() { - // When - RelationDefinition result = mapper.toRelationDefinition((RelationDefinitionDtoIn) null); - - // Then - assertThat(result).isNull(); - } - - @Test - @DisplayName("Should map RelationDefinition to RelationDefinitionDtoOut") - void shouldMapRelationEntityToDtoOut() { - // Given - var entity = new RelationDefinition( - UUID.randomUUID(), - "childServices", - "service", - false, - true - ); - - // When - RelationDefinitionDtoOut result = mapper.toDto(entity); - - // Then - assertThat(result).isNotNull(); - assertThat(result.getName()).isEqualTo("childServices"); - assertThat(result.getTargetTemplateIdentifier()).isEqualTo("service"); - assertThat(result.isRequired()).isFalse(); - assertThat(result.isToMany()).isTrue(); - } - - @Test - @DisplayName("Should handle null RelationDefinition") - void shouldHandleNullRelationEntity() { - // When - RelationDefinitionDtoOut result = mapper.toDto((RelationDefinition) null); - - // Then - assertThat(result).isNull(); - } + @Test + @DisplayName("Should map list of RelationDefinition to list of RelationDefinitionDtoOut") + void shouldMapRelationEntityListToDtoOutList() { + // Given + var entity1 = new RelationDefinition(UUID.randomUUID(), "rel1", "target1", true, false); + + var entity2 = new RelationDefinition(UUID.randomUUID(), "rel2", "target2", false, true); + + List entities = List.of(entity1, entity2); + + // When + List result = mapper.toRelationDefinitionDtos(entities); + + // Then + assertThat(result).hasSize(2); + assertThat(result.get(0).getName()).isEqualTo("rel1"); + assertThat(result.get(1).getName()).isEqualTo("rel2"); } - @Nested - @DisplayName("List Mapping Tests") - class ListMappingTests { - - @Test - @DisplayName("Should map list of PropertyDefinitionDtoIn to list of PropertyDefinition") - void shouldMapPropertyDtoInListToEntityList() { - // Given - var dto1 = PropertyDefinitionDtoIn.builder() - .name("prop1") - .description("Property 1") - .type(PropertyType.STRING) - .required(true) - .build(); - - var dto2 = PropertyDefinitionDtoIn.builder() - .name("prop2") - .description("Property 2") - .type(PropertyType.NUMBER) - .required(false) - .build(); - - List dtos = List.of(dto1, dto2); - - // When - List result = mapper.toPropertyDefinitionEntities(dtos); - - // Then - assertThat(result).hasSize(2); - assertThat(result.get(0).name()).isEqualTo("prop1"); - assertThat(result.get(1).name()).isEqualTo("prop2"); - } - - @Test - @DisplayName("Should map list of PropertyDefinition to list of PropertyDefinitionDtoOut") - void shouldMapPropertyEntityListToDtoOutList() { - // Given - var entity1 = new PropertyDefinition( - UUID.randomUUID(), - "prop1", - "Property 1", - PropertyType.STRING, - true, - null - ); - - var entity2 = new PropertyDefinition( - UUID.randomUUID(), - "prop2", - "Property 2", - PropertyType.NUMBER, - false, - null - ); - - List entities = List.of(entity1, entity2); - - // When - List result = mapper.toPropertyDefinitionDtos(entities); - - // Then - assertThat(result).hasSize(2); - assertThat(result.get(0).getName()).isEqualTo("prop1"); - assertThat(result.get(1).getName()).isEqualTo("prop2"); - } - - @Test - @DisplayName("Should map list of RelationDefinitionDtoIn to list of RelationDefinition") - void shouldMapRelationDtoInListToEntityList() { - // Given - var dto1 = RelationDefinitionDtoIn.builder() - .name("rel1") - .targetTemplateIdentifier("target1") - .required(true) - .toMany(false) - .build(); - - var dto2 = RelationDefinitionDtoIn.builder() - .name("rel2") - .targetTemplateIdentifier("target2") - .required(false) - .toMany(true) - .build(); - - List dtos = List.of(dto1, dto2); - - // When - List result = mapper.toRelationDefinitionEntities(dtos); - - // Then - assertThat(result).hasSize(2); - assertThat(result.get(0).name()).isEqualTo("rel1"); - assertThat(result.get(1).name()).isEqualTo("rel2"); - } - - @Test - @DisplayName("Should map list of RelationDefinition to list of RelationDefinitionDtoOut") - void shouldMapRelationEntityListToDtoOutList() { - // Given - var entity1 = new RelationDefinition( - UUID.randomUUID(), - "rel1", - "target1", - true, - false - ); - - var entity2 = new RelationDefinition( - UUID.randomUUID(), - "rel2", - "target2", - false, - true - ); - - List entities = List.of(entity1, entity2); - - // When - List result = mapper.toRelationDefinitionDtos(entities); - - // Then - assertThat(result).hasSize(2); - assertThat(result.get(0).getName()).isEqualTo("rel1"); - assertThat(result.get(1).getName()).isEqualTo("rel2"); - } - - @Test - @DisplayName("Should handle null lists") - void shouldHandleNullLists() { - // When & Then - assertThat(mapper.toPropertyDefinitionEntities(null)).isEmpty(); - assertThat(mapper.toPropertyDefinitionDtos(null)).isEmpty(); - assertThat(mapper.toRelationDefinitionEntities(null)).isEmpty(); - assertThat(mapper.toRelationDefinitionDtos(null)).isEmpty(); - } + @Test + @DisplayName("Should handle null lists") + void shouldHandleNullLists() { + // When & Then + assertThat(mapper.toPropertyDefinitionEntities(null)).isEmpty(); + assertThat(mapper.toPropertyDefinitionDtos(null)).isEmpty(); + assertThat(mapper.toRelationDefinitionEntities(null)).isEmpty(); + assertThat(mapper.toRelationDefinitionDtos(null)).isEmpty(); } + } } From d8b88303576ec0954b8543b92a8ce154ea3b8fa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Thu, 28 May 2026 18:42:08 +0200 Subject: [PATCH 23/53] feat(core): add a entity graph service and endpoint --- .../domain/constant/ValidationMessages.java | 156 +- .../exception/InvalidQueryDslException.java | 6 +- .../entity/EntityAlreadyExistsException.java | 14 +- .../entity/EntityNotFoundException.java | 22 +- .../entity/EntityValidationException.java | 29 +- ...pertyDefinitionRulesConflictException.java | 18 +- .../idp_core/domain/model/entity/Entity.java | 29 +- .../domain/model/entity/EntityFilter.java | 24 +- .../domain/model/entity/FilterCriterion.java | 8 +- .../domain/model/entity/Property.java | 11 +- .../model/entity_template/EntityTemplate.java | 33 +- .../domain/model/enums/FilterKeyType.java | 8 +- .../domain/model/enums/FilterOperator.java | 5 +- .../domain/port/EntityRepositoryPort.java | 24 +- .../service/EntityQueryParserService.java | 406 ++-- .../domain/service/entity/EntityService.java | 220 +- .../entity/EntityValidationService.java | 142 +- .../domain/service/entity/Violations.java | 44 +- .../PropertyDefinitionValidationService.java | 530 ++--- .../property/PropertyValidationService.java | 224 +- .../service/relation/RelationService.java | 31 +- .../relation/RelationValidationService.java | 160 +- .../api/configuration/CorsProperties.java | 15 +- .../api/configuration/SwaggerDescription.java | 304 +-- .../api/controller/EntityController.java | 253 +- .../api/dto/in/EntityCreateDtoIn.java | 25 +- .../api/dto/in/EntityDtoInCommonFields.java | 67 +- .../api/dto/in/EntityUpdateDtoIn.java | 17 +- .../api/handler/ApiExceptionHandler.java | 659 +++--- .../api/mapper/entity/EntityDtoInMapper.java | 83 +- .../api/mapper/entity/EntityDtoOutMapper.java | 455 ++-- .../persistence/PostgresEntityAdapter.java | 121 +- .../model/entity/EntityJpaEntity.java | 40 +- .../repository/JpaEntityRepository.java | 194 +- .../specification/EntitySpecification.java | 366 ++- .../service/EntityQueryParserServiceTest.java | 991 ++++---- .../service/entity/EntityServiceTest.java | 413 ++-- .../entity/EntityValidationServiceTest.java | 196 +- .../PropertyValidationServiceTest.java | 693 +++--- .../RelationValidationServiceTest.java | 322 ++- .../api/controller/EntityControllerTest.java | 1130 +++++---- .../EntityTemplateControllerTest.java | 2055 ++++++++--------- .../api/handler/ApiExceptionHandlerTest.java | 911 ++++---- .../mapper/entity/EntityDtoInMapperTest.java | 144 +- .../EntitySpecificationTest.java | 85 +- .../db/test/R__1_Insert_test_data.sql | 99 - .../test/R__2_Insert_entities_test_data.sql | 152 +- 47 files changed, 5999 insertions(+), 5935 deletions(-) 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 56c9dffd..219b42b3 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 @@ -6,93 +6,89 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public class ValidationMessages { - // Entity Template validation messages - public static final String TEMPLATE_ALREADY_EXISTS = "An Entity Template already exists with the same identifier"; - public static final String TEMPLATE_IDENTIFIER_NOT_FOUND = "Target template with identifier '%s' does not exist."; - public static final String TEMPLATE_IDENTIFIER_MANDATORY = "Entity Template identifier is mandatory and cannot be blank"; - public static final String TEMPLATE_IDENTIFIER_CANNOT_CHANGE = "Entity Template identifier cannot be changed. Current identifier: "; - public static final String TEMPLATE_NAME_ALREADY_EXISTS = "The entity template name %s already exists"; - public static final String TEMPLATE_NAME_MANDATORY = "Entity template name is mandatory and cannot be blank"; - public static final String TEMPLATE_NAME_MAX_SIZE = "Entity template name must not exceed 255 characters"; - public static final String TEMPLATE_NAME_FORMAT = "Entity template name must only use alphanumeric characters, spaces, hyphens or underscores"; + // Entity Template validation messages + public static final String TEMPLATE_ALREADY_EXISTS = "An Entity Template already exists with the same identifier"; + public static final String TEMPLATE_IDENTIFIER_NOT_FOUND = "Target template with identifier '%s' does not exist."; + public static final String TEMPLATE_IDENTIFIER_MANDATORY = "Entity Template identifier is mandatory and cannot be blank"; + public static final String TEMPLATE_IDENTIFIER_CANNOT_CHANGE = "Entity Template identifier cannot be changed. Current identifier: "; + public static final String TEMPLATE_NAME_ALREADY_EXISTS = "The entity template name %s already exists"; + public static final String TEMPLATE_NAME_MANDATORY = "Entity template name is mandatory and cannot be blank"; + public static final String TEMPLATE_NAME_MAX_SIZE = "Entity template name must not exceed 255 characters"; + public static final String TEMPLATE_NAME_FORMAT = "Entity template name must only use alphanumeric characters, spaces, hyphens or underscores"; - // Property Definition validation messages - public static final String PROPERTY_NAME_MANDATORY = "Property name is mandatory and cannot be blank"; - public static final String PROPERTY_NAME_ALREADY_EXISTS = "Property name '%s' already exists within the template. Property names must be unique."; - public static final String PROPERTY_DESCRIPTION_MANDATORY = "Property description is mandatory and cannot be blank"; - public static final String PROPERTY_TYPE_MANDATORY = "Property type is mandatory"; - public static final String PROPERTY_VALUE_MANDATORY = "Property value is mandatory and cannot be blank"; - public static final String PROPERTY_TYPE_CANNOT_CHANGE = "Cannot change type of property '%s' from %s to %s. Property types cannot be modified after creation. Please delete and recreate the property instead."; - public static final String PROPERTY_REQUIRED_MISSING = "Property '%s' is required by template '%s'"; - public static final String PROPERTY_NOT_DEFINED_IN_TEMPLATE = "Property '%s' is not defined in template '%s'"; - public static final String PROPERTY_TYPE_MISMATCH = "Property '%s' must be of type %s"; - public static final String PROPERTY_MIN_LENGTH_VIOLATION = "Property '%s' length must be greater than or equal to %d"; - public static final String PROPERTY_MAX_LENGTH_VIOLATION = "Property '%s' length must be lower than or equal to %d"; - public static final String PROPERTY_MIN_VALUE_VIOLATION = "Property '%s' value must be greater than or equal to %d"; - public static final String PROPERTY_MAX_VALUE_VIOLATION = "Property '%s' value must be lower than or equal to %d"; - public static final String PROPERTY_REGEX_VIOLATION = "Property '%s' does not match expected format"; - public static final String PROPERTY_ENUM_VIOLATION = "Property '%s' must be one of %s"; - public static final String PROPERTY_FORMAT_VIOLATION = "Property '%s' does not match required format %s"; + // Property Definition validation messages + public static final String PROPERTY_NAME_MANDATORY = "Property name is mandatory and cannot be blank"; + public static final String PROPERTY_NAME_ALREADY_EXISTS = "Property name '%s' already exists within the template. Property names must be unique."; + public static final String PROPERTY_DESCRIPTION_MANDATORY = "Property description is mandatory and cannot be blank"; + public static final String PROPERTY_TYPE_MANDATORY = "Property type is mandatory"; + public static final String PROPERTY_VALUE_MANDATORY = "Property value is mandatory and cannot be blank"; + public static final String PROPERTY_TYPE_CANNOT_CHANGE = "Cannot change type of property '%s' from %s to %s. Property types cannot be modified after creation. Please delete and recreate the property instead."; + public static final String PROPERTY_REQUIRED_MISSING = "Property '%s' is required by template '%s'"; + public static final String PROPERTY_NOT_DEFINED_IN_TEMPLATE = "Property '%s' is not defined in template '%s'"; + public static final String PROPERTY_TYPE_MISMATCH = "Property '%s' must be of type %s"; + public static final String PROPERTY_MIN_LENGTH_VIOLATION = "Property '%s' length must be greater than or equal to %d"; + public static final String PROPERTY_MAX_LENGTH_VIOLATION = "Property '%s' length must be lower than or equal to %d"; + public static final String PROPERTY_MIN_VALUE_VIOLATION = "Property '%s' value must be greater than or equal to %d"; + public static final String PROPERTY_MAX_VALUE_VIOLATION = "Property '%s' value must be lower than or equal to %d"; + public static final String PROPERTY_REGEX_VIOLATION = "Property '%s' does not match expected format"; + public static final String PROPERTY_ENUM_VIOLATION = "Property '%s' must be one of %s"; + public static final String PROPERTY_FORMAT_VIOLATION = "Property '%s' does not match required format %s"; - // Property Rules validation messages - templates and specific constraints - public static final String PROPERTY_RULES_RULE_NOT_ALLOWED_FOR_TYPE = "{rule} rule is not allowed for {type} property type"; - public static final String PROPERTY_RULES_MIN_MAX_CONSTRAINT_VIOLATED = "min_{constraint} must be less than or equal to max_{constraint}"; - public static final String PROPERTY_RULES_MIN_LENGTH_NON_NEGATIVE = "min_length must be greater than or equal to 0"; - public static final String PROPERTY_RULES_MAX_LENGTH_POSITIVE = "max_length must be greater than 0"; - public static final String PROPERTY_RULES_BOOLEAN_NOT_ALLOWED = "Boolean properties do not accept any rules"; - public static final String PROPERTY_RULES_NUMERIC_RULE_NOT_ALLOWED = "Numeric rule {rule} is not allowed for STRING properties"; - public static final String PROPERTY_RULES_MUTUALLY_EXCLUSIVE = "{rule1} and {rule2} are mutually exclusive for STRING properties"; - public static final String PROPERTY_RULES_REGEX_INVALID = "Invalid regex pattern: %s"; + // Property Rules validation messages - templates and specific constraints + public static final String PROPERTY_RULES_RULE_NOT_ALLOWED_FOR_TYPE = "{rule} rule is not allowed for {type} property type"; + public static final String PROPERTY_RULES_MIN_MAX_CONSTRAINT_VIOLATED = "min_{constraint} must be less than or equal to max_{constraint}"; + public static final String PROPERTY_RULES_MIN_LENGTH_NON_NEGATIVE = "min_length must be greater than or equal to 0"; + public static final String PROPERTY_RULES_MAX_LENGTH_POSITIVE = "max_length must be greater than 0"; + public static final String PROPERTY_RULES_BOOLEAN_NOT_ALLOWED = "Boolean properties do not accept any rules"; + public static final String PROPERTY_RULES_NUMERIC_RULE_NOT_ALLOWED = "Numeric rule {rule} is not allowed for STRING properties"; + public static final String PROPERTY_RULES_MUTUALLY_EXCLUSIVE = "{rule1} and {rule2} are mutually exclusive for STRING properties"; + public static final String PROPERTY_RULES_REGEX_INVALID = "Invalid regex pattern: %s"; - // Relation Definition validation messages - public static final String RELATION_NAME_MANDATORY = "Relation name is mandatory and cannot be blank"; - public static final String RELATION_TARGET_IDENTIFIER_MANDATORY = "Target template identifier is mandatory and cannot be blank"; - public static final String RELATION_NAME_MANDATORY_SIMPLE = "Relation name is mandatory"; - public static final String RELATION_NAME_ALREADY_EXISTS = "Relation name '%s' already exists within the template. Relation names must be unique."; - public static final String RELATION_TARGET_IDENTIFIER_MANDATORY_SIMPLE = "Relation target identifier is mandatory"; - public static final String RELATION_TARGET_IDENTIFIERS_NOT_NULL = "Target entity identifiers cannot be null"; - public static final String RELATION_NOT_DEFINED_IN_TEMPLATE = "Relation '%s' is not defined in template '%s'"; - public static final String RELATION_REQUIRED_MISSING = "Relation '%s' is required by template '%s'"; - public static final String RELATION_TOO_MANY_TARGETS = "Relation '%s' allows only one target in template '%s'"; - public static final String RELATION_TARGET_ENTITY_NOT_FOUND = "Relation '%s': target entity '%s' does not exist"; - public static final String RELATION_TARGET_TEMPLATE_CANNOT_CHANGE = "Cannot change target template of relation '%s' from '%s' to '%s'. Target template cannot be modified after creation. Please delete and recreate the relation instead."; - public static final String RELATION_CANNOT_TARGET_ITSELF = "Relation '%s' cannot reference its own template '%s' as the target."; + // Relation Definition validation messages + public static final String RELATION_NAME_MANDATORY = "Relation name is mandatory and cannot be blank"; + public static final String RELATION_TARGET_IDENTIFIER_MANDATORY = "Target template identifier is mandatory and cannot be blank"; + public static final String RELATION_NAME_MANDATORY_SIMPLE = "Relation name is mandatory"; + public static final String RELATION_NAME_ALREADY_EXISTS = "Relation name '%s' already exists within the template. Relation names must be unique."; + public static final String RELATION_TARGET_IDENTIFIER_MANDATORY_SIMPLE = "Relation target identifier is mandatory"; + public static final String RELATION_TARGET_IDENTIFIERS_NOT_NULL = "Target entity identifiers cannot be null"; + public static final String RELATION_NOT_DEFINED_IN_TEMPLATE = "Relation '%s' is not defined in template '%s'"; + public static final String RELATION_REQUIRED_MISSING = "Relation '%s' is required by template '%s'"; + public static final String RELATION_TOO_MANY_TARGETS = "Relation '%s' allows only one target in template '%s'"; + public static final String RELATION_TARGET_ENTITY_NOT_FOUND = "Relation '%s': target entity '%s' does not exist"; + public static final String RELATION_TARGET_TEMPLATE_CANNOT_CHANGE = "Cannot change target template of relation '%s' from '%s' to '%s'. Target template cannot be modified after creation. Please delete and recreate the relation instead."; + public static final String RELATION_CANNOT_TARGET_ITSELF = "Relation '%s' cannot reference its own template '%s' as the target."; - // Entity input validation messages - public static final String ENTITY_NAME_MANDATORY = "Entity name is mandatory and cannot be blank"; - public static final String ENTITY_IDENTIFIER_MANDATORY = "Entity identifier is mandatory and cannot be blank"; + // Entity input validation messages + public static final String ENTITY_NAME_MANDATORY = "Entity name is mandatory and cannot be blank"; + public static final String ENTITY_IDENTIFIER_MANDATORY = "Entity identifier is mandatory and cannot be blank"; - // Entity creation validation messages - public static final String ENTITY_NOT_FOUND = "Entity not found with template identifier %s and entity identifier '%s'"; - public static final String ENTITY_ALREADY_EXISTS = "Entity with name '%s' already exists for template '%s'"; - public static final String ENTITY_VALIDATION_FAILED = "Entity validation failed: "; + // Entity creation validation messages + public static final String ENTITY_NOT_FOUND = "Entity not found with template identifier %s and entity identifier '%s'"; + public static final String ENTITY_ALREADY_EXISTS = "Entity with name '%s' already exists for template '%s'"; + public static final String ENTITY_VALIDATION_FAILED = "Entity validation failed: "; - // Helper method to construct rules incompatibility message - public static String rulesAreIncompatible(String rule1, String rule2) { - return PROPERTY_RULES_MUTUALLY_EXCLUSIVE - .replace("{rule1}", rule1) - .replace("{rule2}", rule2); - } + // Helper method to construct rules incompatibility message + public static String rulesAreIncompatible(String rule1, String rule2) { + return PROPERTY_RULES_MUTUALLY_EXCLUSIVE.replace("{rule1}", rule1).replace("{rule2}", rule2); + } - // Helper method to construct rule-not-allowed message - public static String ruleNotAllowed(String rule, String propertyType) { - return PROPERTY_RULES_RULE_NOT_ALLOWED_FOR_TYPE - .replace("{rule}", rule) - .replace("{type}", propertyType); - } + // Helper method to construct rule-not-allowed message + public static String ruleNotAllowed(String rule, String propertyType) { + return PROPERTY_RULES_RULE_NOT_ALLOWED_FOR_TYPE.replace("{rule}", rule).replace("{type}", + propertyType); + } - // Helper method to construct min/max constraint violation message - public static String minMaxConstraintViolated(String constraint) { - return PROPERTY_RULES_MIN_MAX_CONSTRAINT_VIOLATED - .replace("{constraint}", constraint); - } + // Helper method to construct min/max constraint violation message + public static String minMaxConstraintViolated(String constraint) { + return PROPERTY_RULES_MIN_MAX_CONSTRAINT_VIOLATED.replace("{constraint}", constraint); + } - // Filter query validation messages - public static final String FILTER_TOO_MANY_CRITERIA = "Filter query exceeds maximum of %d criteria"; - public static final String FILTER_VALUE_TOO_LONG = "Filter value must not exceed %d characters in criterion '%s'"; - public static final String FILTER_KEY_TOO_LONG = "Filter key must not exceed %d characters in criterion '%s'"; - public static final String FILTER_INVALID_FORMAT = "Invalid query format, expected field:operator:value"; - public static final String FILTER_DUPLICATE_CRITERION = "Multiple filters for the same property are not supported"; - public static final String FILTER_TYPE_MISMATCH = "Operation '%s' is not applicable for field '%s'."; - public static final String FILTER_PROPERTY_TYPE_NOT_NUMERIC = "Operation '%s' is not applicable for property '%s': only NUMBER properties support comparison operators."; + // Filter query validation messages + public static final String FILTER_TOO_MANY_CRITERIA = "Filter query exceeds maximum of %d criteria"; + public static final String FILTER_VALUE_TOO_LONG = "Filter value must not exceed %d characters in criterion '%s'"; + public static final String FILTER_KEY_TOO_LONG = "Filter key must not exceed %d characters in criterion '%s'"; + public static final String FILTER_INVALID_FORMAT = "Invalid query format, expected field:operator:value"; + public static final String FILTER_DUPLICATE_CRITERION = "Multiple filters for the same property are not supported"; + public static final String FILTER_TYPE_MISMATCH = "Operation '%s' is not applicable for field '%s'."; + public static final String FILTER_PROPERTY_TYPE_NOT_NUMERIC = "Operation '%s' is not applicable for property '%s': only NUMBER properties support comparison operators."; } 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 index 895d45fd..943d6e41 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/InvalidQueryDslException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/InvalidQueryDslException.java @@ -6,7 +6,7 @@ /// 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); - } + public InvalidQueryDslException(String message) { + super(message); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityAlreadyExistsException.java index 82437486..fb139cfb 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityAlreadyExistsException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityAlreadyExistsException.java @@ -16,11 +16,11 @@ /// - Maintains template-entity relationship integrity public class EntityAlreadyExistsException extends RuntimeException { - /// Constructs a new exception with template and entity identifiers. - /// - /// @param templateIdentifier the identifier of the template - /// @param entityName the duplicate entity name - public EntityAlreadyExistsException(String templateIdentifier, String entityName) { - super(String.format(ENTITY_ALREADY_EXISTS, entityName, templateIdentifier)); - } + /// Constructs a new exception with template and entity identifiers. + /// + /// @param templateIdentifier the identifier of the template + /// @param entityName the duplicate entity name + public EntityAlreadyExistsException(String templateIdentifier, String entityName) { + super(String.format(ENTITY_ALREADY_EXISTS, entityName, templateIdentifier)); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityNotFoundException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityNotFoundException.java index 42c60f67..cea5f8eb 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityNotFoundException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityNotFoundException.java @@ -16,15 +16,17 @@ /// - Maintains template-entity relationship integrity public class EntityNotFoundException extends RuntimeException { - /// Constructs a new exception with template and entity identifiers. - /// - /// **Why this exists:** Provides standardized error message format that includes - /// both template and entity context for clear debugging and API error responses. - /// - /// @param templateIdentifier the identifier of the template - /// @param entityIdentifier the identifier of the entity - public EntityNotFoundException(String templateIdentifier, String entityIdentifier) { - super(String.format(ENTITY_NOT_FOUND, templateIdentifier, entityIdentifier)); - } + /// Constructs a new exception with template and entity identifiers. + /// + /// **Why this exists:** Provides standardized error message format that + /// includes + /// both template and entity context for clear debugging and API error + /// responses. + /// + /// @param templateIdentifier the identifier of the template + /// @param entityIdentifier the identifier of the entity + public EntityNotFoundException(String templateIdentifier, String entityIdentifier) { + super(String.format(ENTITY_NOT_FOUND, templateIdentifier, entityIdentifier)); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityValidationException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityValidationException.java index 42756f0e..00381203 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityValidationException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityValidationException.java @@ -23,21 +23,20 @@ @Getter public class EntityValidationException extends RuntimeException { - /** - * -- GETTER -- - * Returns the list of individual validation violation messages. - * /// - * /// - * @return immutable list of violation messages - */ - private final List violations; + /** + * -- GETTER -- Returns the list of individual validation violation messages. + * /// /// + * + * @return immutable list of violation messages + */ + private final List violations; - /// Constructs a new exception with a list of validation violation messages. - /// - /// @param violations the list of validation error messages - public EntityValidationException(List violations) { - super(ENTITY_VALIDATION_FAILED + String.join("; ", violations)); - this.violations = List.copyOf(violations); - } + /// Constructs a new exception with a list of validation violation messages. + /// + /// @param violations the list of validation error messages + public EntityValidationException(List violations) { + super(ENTITY_VALIDATION_FAILED + String.join("; ", violations)); + this.violations = List.copyOf(violations); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java b/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java index 3ce489ed..737c7c84 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/property/PropertyDefinitionRulesConflictException.java @@ -13,13 +13,13 @@ /// - Property template updates introducing rule conflicts public class PropertyDefinitionRulesConflictException extends RuntimeException { - /// Constructs a new exception for rule type conflict. - /// - /// @param propertyName the name of the property with invalid rules - /// @param propertyType the data type of the property - /// @param violationMessage detailed explanation of what rule is invalid - public PropertyDefinitionRulesConflictException(String propertyName, PropertyType propertyType, String violationMessage) { - super("Property '" + propertyName + "' of type " + propertyType + - ": " + violationMessage); - } + /// Constructs a new exception for rule type conflict. + /// + /// @param propertyName the name of the property with invalid rules + /// @param propertyType the data type of the property + /// @param violationMessage detailed explanation of what rule is invalid + public PropertyDefinitionRulesConflictException(String propertyName, PropertyType propertyType, + String violationMessage) { + super("Property '" + propertyName + "' of type " + propertyType + ": " + violationMessage); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java index 7cbe3c32..a0597082 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java @@ -7,10 +7,10 @@ import java.util.List; import java.util.UUID; -import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; - import jakarta.validation.constraints.NotBlank; +import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; + /// Domain entity representing a concrete instance of an [EntityTemplate]. /// /// Business invariants: @@ -23,20 +23,19 @@ /// schema, containing actual values that comply with the template's structure /// and rules. -public record Entity( - UUID id, +public record Entity(UUID id, - @NotBlank(message = TEMPLATE_IDENTIFIER_MANDATORY) String templateIdentifier, - @NotBlank(message = ENTITY_NAME_MANDATORY) String name, - @NotBlank(message = ENTITY_IDENTIFIER_MANDATORY) String identifier, + @NotBlank(message = TEMPLATE_IDENTIFIER_MANDATORY) String templateIdentifier, + @NotBlank(message = ENTITY_NAME_MANDATORY) String name, + @NotBlank(message = ENTITY_IDENTIFIER_MANDATORY) String identifier, - List properties, + List properties, - List relations) { - /// Compact constructor defensively copies mutable collections to keep the - /// record immutable. - public Entity { - properties = properties != null ? List.copyOf(properties) : List.of(); - relations = relations != null ? List.copyOf(relations) : List.of(); - } + List relations) { + /// Compact constructor defensively copies mutable collections to keep the + /// record immutable. + public Entity { + properties = properties != null ? List.copyOf(properties) : List.of(); + relations = relations != null ? List.copyOf(relations) : List.of(); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityFilter.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityFilter.java index 97514a4b..52d6735a 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityFilter.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityFilter.java @@ -12,18 +12,18 @@ /// Use [EntityFilter#empty()] to represent the absence of any filter constraint. public record EntityFilter(List criteria) { - /// Constructs an [EntityFilter] with a defensive copy of the criteria list. - public EntityFilter { - criteria = criteria != null ? List.copyOf(criteria) : List.of(); - } + /// Constructs an [EntityFilter] with a defensive copy of the criteria list. + public EntityFilter { + criteria = criteria != null ? List.copyOf(criteria) : List.of(); + } - /// Returns an [EntityFilter] with no criteria (matches all entities). - public static EntityFilter empty() { - return new EntityFilter(List.of()); - } + /// Returns an [EntityFilter] with no criteria (matches all entities). + public static EntityFilter empty() { + return new EntityFilter(List.of()); + } - /// Returns true when no criteria have been defined. - public boolean isEmpty() { - return criteria.isEmpty(); - } + /// Returns true when no criteria have been defined. + public boolean isEmpty() { + return criteria.isEmpty(); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/FilterCriterion.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/FilterCriterion.java index 045c91df..213b7f5a 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/FilterCriterion.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/FilterCriterion.java @@ -13,10 +13,6 @@ /// - [FilterKeyType#RELATION_PROPERTY] — a property (`identifier` or `name`) of the target entity of a named relation. `key` format: `relationName.propertyName` (e.g., `api-link.identifier`) /// /// Multiple [FilterCriterion] instances combined in an [EntityFilter] are applied with implicit AND logic. -public record FilterCriterion( - FilterKeyType keyType, - String key, - FilterOperator operator, - String value -) { +public record FilterCriterion(FilterKeyType keyType, String key, FilterOperator operator, + String value) { } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java index 2af3571f..c71c02e9 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java @@ -4,12 +4,12 @@ import java.util.UUID; +import jakarta.validation.constraints.NotBlank; + import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; import com.decathlon.idp_core.domain.model.entity_template.PropertyRules; import com.decathlon.idp_core.domain.model.enums.PropertyType; -import jakarta.validation.constraints.NotBlank; - /// A concrete property instance belonging to an [Entity]. /// /// Represents actual business data values that conform to the constraints @@ -26,10 +26,9 @@ /// [PropertyType] definition (carried as [Object] so the original JSON type — /// String, Number, Boolean — is preserved for strict type-mismatch detection /// at validation time). -public record Property( - UUID id, +public record Property(UUID id, - @NotBlank(message = PROPERTY_NAME_MANDATORY) String name, + @NotBlank(message = PROPERTY_NAME_MANDATORY) String name, - String value) { + String value) { } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity_template/EntityTemplate.java b/src/main/java/com/decathlon/idp_core/domain/model/entity_template/EntityTemplate.java index 04967dbb..69078ce6 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity_template/EntityTemplate.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_template/EntityTemplate.java @@ -26,26 +26,25 @@ /// - Relation names must be unique within the template (if any) /// - All property definitions must have valid types and constraints /// - Relations must reference valid target template identifiers -public record EntityTemplate( - UUID id, +public record EntityTemplate(UUID id, - @NotBlank(message = TEMPLATE_IDENTIFIER_MANDATORY) - String identifier, + @NotBlank(message = TEMPLATE_IDENTIFIER_MANDATORY) String identifier, - @Size(max = 255, message = TEMPLATE_NAME_MAX_SIZE) - @NotBlank(message = TEMPLATE_NAME_MANDATORY) - @Pattern(regexp = ENTITY_TEMPLATE_NAME_REGEX, message = TEMPLATE_NAME_FORMAT) - String name, + @Size(max = 255, message = TEMPLATE_NAME_MAX_SIZE) @NotBlank(message = TEMPLATE_NAME_MANDATORY) @Pattern(regexp = ENTITY_TEMPLATE_NAME_REGEX, message = TEMPLATE_NAME_FORMAT) String name, - String description, + String description, - List propertiesDefinitions, + List propertiesDefinitions, - List relationsDefinitions -) { - /// Compact constructor defensively copies mutable collections to preserve immutability. - public EntityTemplate { - propertiesDefinitions = propertiesDefinitions != null ? List.copyOf(propertiesDefinitions) : List.of(); - relationsDefinitions = relationsDefinitions != null ? List.copyOf(relationsDefinitions) : List.of(); - } + List relationsDefinitions) { + /// Compact constructor defensively copies mutable collections to preserve + /// immutability. + public EntityTemplate { + propertiesDefinitions = propertiesDefinitions != null + ? List.copyOf(propertiesDefinitions) + : List.of(); + relationsDefinitions = relationsDefinitions != null + ? List.copyOf(relationsDefinitions) + : List.of(); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/enums/FilterKeyType.java b/src/main/java/com/decathlon/idp_core/domain/model/enums/FilterKeyType.java index fbd4e751..1738d9b3 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/enums/FilterKeyType.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/enums/FilterKeyType.java @@ -17,11 +17,5 @@ /// *source* entity in a reverse relation. Key format: `relationName.propertyName` /// (e.g. `relations_as_target.api-link.name:microservice`) public enum FilterKeyType { - ATTRIBUTE, - PROPERTY, - RELATION_NAME, - RELATION_ENTITY, - RELATION_PROPERTY, - RELATIONS_AS_TARGET_NAME, - RELATIONS_AS_TARGET_PROPERTY + ATTRIBUTE, PROPERTY, RELATION_NAME, RELATION_ENTITY, RELATION_PROPERTY, RELATIONS_AS_TARGET_NAME, RELATIONS_AS_TARGET_PROPERTY } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/enums/FilterOperator.java b/src/main/java/com/decathlon/idp_core/domain/model/enums/FilterOperator.java index 82a443b7..cbcc8676 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/enums/FilterOperator.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/enums/FilterOperator.java @@ -8,8 +8,5 @@ /// - [LESS_THAN] requires the field to be less than the value /// - [GREATER_THAN] requires the field to be greater than the value public enum FilterOperator { - EQUALS, - CONTAINS, - LESS_THAN, - GREATER_THAN + EQUALS, CONTAINS, LESS_THAN, GREATER_THAN } 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 8e31ffb2..0718ea94 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 @@ -29,23 +29,27 @@ /// appropriately for the underlying persistence technology. public interface EntityRepositoryPort { - Entity save(Entity entity); + Entity save(Entity entity); - Optional findById(UUID id); + Optional findById(UUID id); - Optional findByTemplateIdentifierAndIdentifier(String templateIdentifier, String identifier); + Optional findByTemplateIdentifierAndIdentifier(String templateIdentifier, + String identifier); - Optional findByTemplateIdentifierAndName(String templateIdentifier, String entityName); + Optional findByTemplateIdentifierAndName(String templateIdentifier, String entityName); - Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable); + Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable); - Page findByTemplateIdentifierWithFilter(String templateIdentifier, EntityFilter filter, Pageable pageable); + Page findByTemplateIdentifierWithFilter(String templateIdentifier, EntityFilter filter, + Pageable pageable); - List findByIdentifierIn(List identifiers); + List findByIdentifierIn(List identifiers); - List findByRelationIdIn(List relationIds); + List findByRelationIdIn(List relationIds); - void deletePropertiesByTemplateIdentifierAndPropertyName(String templateIdentifier, Collection propertyNames); + void deletePropertiesByTemplateIdentifierAndPropertyName(String templateIdentifier, + Collection propertyNames); - void deleteRelationsByTemplateIdentifierAndRelationName(String templateIdentifier, Collection relationNames); + void deleteRelationsByTemplateIdentifierAndRelationName(String templateIdentifier, + Collection relationNames); } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/EntityQueryParserService.java b/src/main/java/com/decathlon/idp_core/domain/service/EntityQueryParserService.java index 9333f5a3..59ec1717 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/EntityQueryParserService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/EntityQueryParserService.java @@ -6,10 +6,10 @@ import java.util.Set; import java.util.stream.Stream; -import com.decathlon.idp_core.domain.exception.InvalidQueryDslException; import org.springframework.stereotype.Service; import com.decathlon.idp_core.domain.constant.ValidationMessages; +import com.decathlon.idp_core.domain.exception.InvalidQueryDslException; import com.decathlon.idp_core.domain.model.entity.EntityFilter; import com.decathlon.idp_core.domain.model.entity.FilterCriterion; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; @@ -42,237 +42,241 @@ @Service public class EntityQueryParserService { - private static final String RELATION = "relation"; - private static final String RELATIONS_AS_TARGET = "relations_as_target"; - private static final String PROPERTY_PREFIX = "property."; - private static final String RELATION_PREFIX = "relation."; - private static final String RELATIONS_AS_TARGET_PREFIX = "relations_as_target."; - private static final Set VALID_ATTRIBUTE_NAMES = Set.of("identifier", "name"); - - private static final Set COMPARISON_INCOMPATIBLE_TYPES = Set.of( - FilterKeyType.ATTRIBUTE, - FilterKeyType.RELATION_NAME, - FilterKeyType.RELATION_ENTITY, - FilterKeyType.RELATION_PROPERTY, - FilterKeyType.RELATIONS_AS_TARGET_NAME, - FilterKeyType.RELATIONS_AS_TARGET_PROPERTY); - - static final int MAX_CRITERIA_COUNT = 10; - static final int MAX_KEY_VALUE_LENGTH = 255; - - /// Parses a query string into an [EntityFilter]. - /// - /// @param query the raw `q` parameter value; may be null or blank - /// @return an [EntityFilter] with parsed criteria, or [EntityFilter#empty()] when query is blank - /// @throws InvalidQueryDslException when the query string is malformed or exceeds safety limits - public EntityFilter parse(String query) { - if (query == null || query.isBlank()) { - return EntityFilter.empty(); - } - - List criteria = Stream.of(query.split(";")) - .filter(token -> !token.isBlank()) - .map(token -> parseCriterion(token.trim())) - .toList(); - - if (criteria.size() > MAX_CRITERIA_COUNT) { - throw new InvalidQueryDslException( - ValidationMessages.FILTER_TOO_MANY_CRITERIA.formatted(MAX_CRITERIA_COUNT)); - } + private static final String RELATION = "relation"; + private static final String RELATIONS_AS_TARGET = "relations_as_target"; + private static final String PROPERTY_PREFIX = "property."; + private static final String RELATION_PREFIX = "relation."; + private static final String RELATIONS_AS_TARGET_PREFIX = "relations_as_target."; + private static final Set VALID_ATTRIBUTE_NAMES = Set.of("identifier", "name"); + + private static final Set COMPARISON_INCOMPATIBLE_TYPES = Set.of( + FilterKeyType.ATTRIBUTE, FilterKeyType.RELATION_NAME, FilterKeyType.RELATION_ENTITY, + FilterKeyType.RELATION_PROPERTY, FilterKeyType.RELATIONS_AS_TARGET_NAME, + FilterKeyType.RELATIONS_AS_TARGET_PROPERTY); + + static final int MAX_CRITERIA_COUNT = 10; + static final int MAX_KEY_VALUE_LENGTH = 255; + + /// Parses a query string into an [EntityFilter]. + /// + /// @param query the raw `q` parameter value; may be null or blank + /// @return an [EntityFilter] with parsed criteria, or [EntityFilter#empty()] + /// when query is blank + /// @throws InvalidQueryDslException when the query string is malformed or + /// exceeds safety limits + public EntityFilter parse(String query) { + if (query == null || query.isBlank()) { + return EntityFilter.empty(); + } - validateNoDuplicates(criteria); + List criteria = Stream.of(query.split(";")).filter(token -> !token.isBlank()) + .map(token -> parseCriterion(token.trim())).toList(); - return new EntityFilter(criteria); + if (criteria.size() > MAX_CRITERIA_COUNT) { + throw new InvalidQueryDslException( + ValidationMessages.FILTER_TOO_MANY_CRITERIA.formatted(MAX_CRITERIA_COUNT)); } - private FilterCriterion parseCriterion(String token) { - int operatorIndex = findOperatorIndex(token) - .orElseThrow(() -> new InvalidQueryDslException(ValidationMessages.FILTER_INVALID_FORMAT)); + validateNoDuplicates(criteria); - var rawKey = token.substring(0, operatorIndex); - var operatorChar = token.charAt(operatorIndex); - var value = token.substring(operatorIndex + 1); + return new EntityFilter(criteria); + } - validateKey(rawKey, token); - validateValue(value, token); - validateLength(rawKey, value, token); + private FilterCriterion parseCriterion(String token) { + int operatorIndex = findOperatorIndex(token) + .orElseThrow(() -> new InvalidQueryDslException(ValidationMessages.FILTER_INVALID_FORMAT)); - var operator = toOperator(operatorChar); - var criterion = buildCriterion(rawKey, operator, value, token); - validateOperatorCompatibility(criterion.keyType(), operator, rawKey); - return criterion; - } + var rawKey = token.substring(0, operatorIndex); + var operatorChar = token.charAt(operatorIndex); + var value = token.substring(operatorIndex + 1); - private OptionalInt findOperatorIndex(String token) { - for (int i = 0; i < token.length(); i++) { - char c = token.charAt(i); - if (c == '=' || c == ':' || c == '<' || c == '>') { - return OptionalInt.of(i); - } - } - return OptionalInt.empty(); - } + validateKey(rawKey, token); + validateValue(value, token); + validateLength(rawKey, value, token); + + var operator = toOperator(operatorChar); + var criterion = buildCriterion(rawKey, operator, value, token); + validateOperatorCompatibility(criterion.keyType(), operator, rawKey); + return criterion; + } - private FilterOperator toOperator(char c) { - return switch (c) { - case '=' -> FilterOperator.EQUALS; - case ':' -> FilterOperator.CONTAINS; - case '<' -> FilterOperator.LESS_THAN; - case '>' -> FilterOperator.GREATER_THAN; - default -> throw new InvalidQueryDslException("Unknown operator character: " + c); - }; + private OptionalInt findOperatorIndex(String token) { + for (int i = 0; i < token.length(); i++) { + char c = token.charAt(i); + if (c == '=' || c == ':' || c == '<' || c == '>') { + return OptionalInt.of(i); + } + } + return OptionalInt.empty(); + } + + private FilterOperator toOperator(char c) { + return switch (c) { + case '=' -> FilterOperator.EQUALS; + case ':' -> FilterOperator.CONTAINS; + case '<' -> FilterOperator.LESS_THAN; + case '>' -> FilterOperator.GREATER_THAN; + default -> throw new InvalidQueryDslException("Unknown operator character: " + c); + }; + } + + private FilterCriterion buildCriterion(String rawKey, FilterOperator operator, String value, + String token) { + // Direct attribute filters (relation=X means filter by relation name) + if (RELATION.equals(rawKey)) { + validateKeyName(value, token); + return new FilterCriterion(FilterKeyType.RELATION_NAME, "", operator, value); } - private FilterCriterion buildCriterion(String rawKey, FilterOperator operator, String value, String token) { - // Direct attribute filters (relation=X means filter by relation name) - if (RELATION.equals(rawKey)) { - validateKeyName(value, token); - return new FilterCriterion(FilterKeyType.RELATION_NAME, "", operator, value); - } - - if (RELATIONS_AS_TARGET.equals(rawKey)) { - validateKeyName(value, token); - return new FilterCriterion(FilterKeyType.RELATIONS_AS_TARGET_NAME, "", operator, value); - } - - if (rawKey.startsWith(PROPERTY_PREFIX)) { - var keyName = rawKey.substring(PROPERTY_PREFIX.length()); - validateKeyName(keyName, token); - return new FilterCriterion(FilterKeyType.PROPERTY, keyName, operator, value); - } - - if (rawKey.startsWith(RELATIONS_AS_TARGET_PREFIX)) { - var relationPart = rawKey.substring(RELATIONS_AS_TARGET_PREFIX.length()); - validateKey(relationPart, token); - return buildRelationsAsTargetCriterion(relationPart, operator, value, token); - } - - if (rawKey.startsWith(RELATION_PREFIX)) { - var relationPart = rawKey.substring(RELATION_PREFIX.length()); - validateKey(relationPart, token); - return buildRelationCriterion(relationPart, operator, value, token); - } - - if (!VALID_ATTRIBUTE_NAMES.contains(rawKey)) { - throw new InvalidQueryDslException( - "Unknown attribute '%s' in filter criterion '%s'. Valid attributes: %s" - .formatted(rawKey, token, VALID_ATTRIBUTE_NAMES)); - } - return new FilterCriterion(FilterKeyType.ATTRIBUTE, rawKey, operator, value); + if (RELATIONS_AS_TARGET.equals(rawKey)) { + validateKeyName(value, token); + return new FilterCriterion(FilterKeyType.RELATIONS_AS_TARGET_NAME, "", operator, value); } - private FilterCriterion buildRelationsAsTargetCriterion(String relationPart, FilterOperator operator, String value, String token) { - int dotIndex = relationPart.indexOf('.'); - if (dotIndex <= 0) { - throw new InvalidQueryDslException( - "Invalid filter criterion '%s': relations_as_target requires the form 'relations_as_target..'" - .formatted(token)); - } - - var relationName = relationPart.substring(0, dotIndex); - var propertyName = relationPart.substring(dotIndex + 1); - validateKeyName(relationName, token); - validatePropertyName(propertyName, RELATIONS_AS_TARGET, token); - var compositeKey = relationName + "." + propertyName; - return new FilterCriterion(FilterKeyType.RELATIONS_AS_TARGET_PROPERTY, compositeKey, operator, value); + if (rawKey.startsWith(PROPERTY_PREFIX)) { + var keyName = rawKey.substring(PROPERTY_PREFIX.length()); + validateKeyName(keyName, token); + return new FilterCriterion(FilterKeyType.PROPERTY, keyName, operator, value); } - private FilterCriterion buildRelationCriterion(String relationPart, FilterOperator operator, String value, String token) { - int dotIndex = relationPart.indexOf('.'); - if (dotIndex > 0) { - var relationName = relationPart.substring(0, dotIndex); - var propertyName = relationPart.substring(dotIndex + 1); - validateKeyName(relationName, token); - validatePropertyName(propertyName, RELATION, token); - var compositeKey = relationName + "." + propertyName; - return new FilterCriterion(FilterKeyType.RELATION_PROPERTY, compositeKey, operator, value); - } - - // Default: relation entity filter - validateKeyName(relationPart, token); - return new FilterCriterion(FilterKeyType.RELATION_ENTITY, relationPart, operator, value); + if (rawKey.startsWith(RELATIONS_AS_TARGET_PREFIX)) { + var relationPart = rawKey.substring(RELATIONS_AS_TARGET_PREFIX.length()); + validateKey(relationPart, token); + return buildRelationsAsTargetCriterion(relationPart, operator, value, token); } - private void validateNoDuplicates(List criteria) { - Set seen = new HashSet<>(); - for (FilterCriterion criterion : criteria) { - String dedupeKey = criterion.keyType().name() + ":" + criterion.key(); - if (!seen.add(dedupeKey)) { - throw new InvalidQueryDslException(ValidationMessages.FILTER_DUPLICATE_CRITERION); - } - } + if (rawKey.startsWith(RELATION_PREFIX)) { + var relationPart = rawKey.substring(RELATION_PREFIX.length()); + validateKey(relationPart, token); + return buildRelationCriterion(relationPart, operator, value, token); } - private void validateOperatorCompatibility(FilterKeyType keyType, FilterOperator operator, String rawKey) { - if (COMPARISON_INCOMPATIBLE_TYPES.contains(keyType) && - (operator == FilterOperator.LESS_THAN || operator == FilterOperator.GREATER_THAN)) { - var opSymbol = operator == FilterOperator.LESS_THAN ? "<" : ">"; - throw new InvalidQueryDslException(ValidationMessages.FILTER_TYPE_MISMATCH.formatted(opSymbol, rawKey)); - } + if (!VALID_ATTRIBUTE_NAMES.contains(rawKey)) { + throw new InvalidQueryDslException( + "Unknown attribute '%s' in filter criterion '%s'. Valid attributes: %s".formatted(rawKey, + token, VALID_ATTRIBUTE_NAMES)); + } + return new FilterCriterion(FilterKeyType.ATTRIBUTE, rawKey, operator, value); + } + + private FilterCriterion buildRelationsAsTargetCriterion(String relationPart, + FilterOperator operator, String value, String token) { + int dotIndex = relationPart.indexOf('.'); + if (dotIndex <= 0) { + throw new InvalidQueryDslException( + "Invalid filter criterion '%s': relations_as_target requires the form 'relations_as_target..'" + .formatted(token)); } - /// Validates that all PROPERTY criteria using `<` or `>` operators - /// correspond to a NUMBER-typed property in the given template. - /// - /// This is a semantic check that requires the template to be available (i.e., it - /// cannot be performed in [#parse] which has no template context). - /// - /// @param filter the parsed query filter - /// @param template the entity template providing property type information - /// @throws InvalidQueryDslException when a comparison operator is used on a non-NUMBER property - public void validateFilterPropertyTypes(EntityFilter filter, EntityTemplate template) { - filter.criteria().stream() - .filter(c -> c.keyType() == FilterKeyType.PROPERTY) - .filter(c -> c.operator() == FilterOperator.LESS_THAN || c.operator() == FilterOperator.GREATER_THAN) - .forEach(c -> { - var propertyDef = template.propertiesDefinitions().stream() - .filter(p -> p.name().equals(c.key())) - .findFirst(); - if (propertyDef.isEmpty() || propertyDef.get().type() != PropertyType.NUMBER) { - var opSymbol = c.operator() == FilterOperator.LESS_THAN ? "<" : ">"; - throw new InvalidQueryDslException( - ValidationMessages.FILTER_PROPERTY_TYPE_NOT_NUMERIC.formatted(opSymbol, c.key())); - } - }); + var relationName = relationPart.substring(0, dotIndex); + var propertyName = relationPart.substring(dotIndex + 1); + validateKeyName(relationName, token); + validatePropertyName(propertyName, RELATIONS_AS_TARGET, token); + var compositeKey = relationName + "." + propertyName; + return new FilterCriterion(FilterKeyType.RELATIONS_AS_TARGET_PROPERTY, compositeKey, operator, + value); + } + + private FilterCriterion buildRelationCriterion(String relationPart, FilterOperator operator, + String value, String token) { + int dotIndex = relationPart.indexOf('.'); + if (dotIndex > 0) { + var relationName = relationPart.substring(0, dotIndex); + var propertyName = relationPart.substring(dotIndex + 1); + validateKeyName(relationName, token); + validatePropertyName(propertyName, RELATION, token); + var compositeKey = relationName + "." + propertyName; + return new FilterCriterion(FilterKeyType.RELATION_PROPERTY, compositeKey, operator, value); } - private void validateKey(String key, String token) { - if (key.isBlank()) { + // Default: relation entity filter + validateKeyName(relationPart, token); + return new FilterCriterion(FilterKeyType.RELATION_ENTITY, relationPart, operator, value); + } + + private void validateNoDuplicates(List criteria) { + Set seen = new HashSet<>(); + for (FilterCriterion criterion : criteria) { + String dedupeKey = criterion.keyType().name() + ":" + criterion.key(); + if (!seen.add(dedupeKey)) { + throw new InvalidQueryDslException(ValidationMessages.FILTER_DUPLICATE_CRITERION); + } + } + } + + private void validateOperatorCompatibility(FilterKeyType keyType, FilterOperator operator, + String rawKey) { + if (COMPARISON_INCOMPATIBLE_TYPES.contains(keyType) + && (operator == FilterOperator.LESS_THAN || operator == FilterOperator.GREATER_THAN)) { + var opSymbol = operator == FilterOperator.LESS_THAN ? "<" : ">"; + throw new InvalidQueryDslException( + ValidationMessages.FILTER_TYPE_MISMATCH.formatted(opSymbol, rawKey)); + } + } + + /// Validates that all PROPERTY criteria using `<` or `>` operators + /// correspond to a NUMBER-typed property in the given template. + /// + /// This is a semantic check that requires the template to be available (i.e., + /// it + /// cannot be performed in [#parse] which has no template context). + /// + /// @param filter the parsed query filter + /// @param template the entity template providing property type information + /// @throws InvalidQueryDslException when a comparison operator is used on a + /// non-NUMBER property + public void validateFilterPropertyTypes(EntityFilter filter, EntityTemplate template) { + filter.criteria().stream().filter(c -> c.keyType() == FilterKeyType.PROPERTY) + .filter(c -> c.operator() == FilterOperator.LESS_THAN + || c.operator() == FilterOperator.GREATER_THAN) + .forEach(c -> { + var propertyDef = template.propertiesDefinitions().stream() + .filter(p -> p.name().equals(c.key())).findFirst(); + if (propertyDef.isEmpty() || propertyDef.get().type() != PropertyType.NUMBER) { + var opSymbol = c.operator() == FilterOperator.LESS_THAN ? "<" : ">"; throw new InvalidQueryDslException( - "Invalid filter criterion '%s': key must not be blank".formatted(token)); - } + ValidationMessages.FILTER_PROPERTY_TYPE_NOT_NUMERIC.formatted(opSymbol, c.key())); + } + }); + } + + private void validateKey(String key, String token) { + if (key.isBlank()) { + throw new InvalidQueryDslException( + "Invalid filter criterion '%s': key must not be blank".formatted(token)); } + } - private void validateKeyName(String keyName, String token) { - if (keyName.isBlank()) { - throw new InvalidQueryDslException( - "Invalid filter criterion '%s': key name must not be blank".formatted(token)); - } + private void validateKeyName(String keyName, String token) { + if (keyName.isBlank()) { + throw new InvalidQueryDslException( + "Invalid filter criterion '%s': key name must not be blank".formatted(token)); } + } - private void validateValue(String value, String token) { - if (value.isBlank()) { - throw new InvalidQueryDslException( - "Invalid filter criterion '%s': value must not be blank".formatted(token)); - } + private void validateValue(String value, String token) { + if (value.isBlank()) { + throw new InvalidQueryDslException( + "Invalid filter criterion '%s': value must not be blank".formatted(token)); } + } - private void validatePropertyName(String propertyName, String contextType, String token) { - if (!VALID_ATTRIBUTE_NAMES.contains(propertyName)) { - throw new InvalidQueryDslException( - "Invalid property '%s' in criterion '%s': only 'identifier' and 'name' are supported for %s" - .formatted(propertyName, token, contextType)); - } + private void validatePropertyName(String propertyName, String contextType, String token) { + if (!VALID_ATTRIBUTE_NAMES.contains(propertyName)) { + throw new InvalidQueryDslException( + "Invalid property '%s' in criterion '%s': only 'identifier' and 'name' are supported for %s" + .formatted(propertyName, token, contextType)); } + } - private void validateLength(String rawKey, String value, String token) { - if (rawKey.length() > MAX_KEY_VALUE_LENGTH) { - throw new InvalidQueryDslException( - ValidationMessages.FILTER_KEY_TOO_LONG.formatted(MAX_KEY_VALUE_LENGTH, token)); - } - if (value.length() > MAX_KEY_VALUE_LENGTH) { - throw new InvalidQueryDslException( - ValidationMessages.FILTER_VALUE_TOO_LONG.formatted(MAX_KEY_VALUE_LENGTH, token)); - } + private void validateLength(String rawKey, String value, String token) { + if (rawKey.length() > MAX_KEY_VALUE_LENGTH) { + throw new InvalidQueryDslException( + ValidationMessages.FILTER_KEY_TOO_LONG.formatted(MAX_KEY_VALUE_LENGTH, token)); + } + if (value.length() > MAX_KEY_VALUE_LENGTH) { + throw new InvalidQueryDslException( + ValidationMessages.FILTER_VALUE_TOO_LONG.formatted(MAX_KEY_VALUE_LENGTH, token)); } + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java index a5b0c6ce..efb1de25 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java @@ -2,6 +2,9 @@ import java.util.List; +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; @@ -20,8 +23,6 @@ import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; -import jakarta.transaction.Transactional; -import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; /// Domain service orchestrating [Entity] business operations and validations. @@ -40,117 +41,122 @@ @Validated @RequiredArgsConstructor public class EntityService { - private final EntityRepositoryPort entityRepository; - private final EntityValidationService entityValidationService; - private final EntityTemplateValidationService entityTemplateValidationService; - private final EntityTemplateService entityTemplateService; - private final EntityQueryParserService entityQueryParserService; - - /// Retrieves entities filtered by template with optional query filter. - /// - /// **Contract:** Returns paginated entities conforming to the specified template - /// that additionally satisfy all criteria in filter (when provided). Template - /// existence is validated first. When filter is null or empty, the result - /// includes all entities for the template. - /// - /// @param pageable pagination configuration for large entity sets - /// @param templateIdentifier business identifier of the entity template - /// @param entityFilter the parsed query filter; null or [EntityFilter#empty()] for no filtering - /// @return paginated entities matching the template and all filter criteria - /// @throws EntityTemplateNotFoundException when template doesn't exist - @Transactional - public Page getEntitiesByTemplateIdentifier( - Pageable pageable, String templateIdentifier, EntityFilter entityFilter) { - EntityTemplate template = entityTemplateService.getEntityTemplateByIdentifier(templateIdentifier); - EntityFilter filter = entityFilter != null ? entityFilter : EntityFilter.empty(); - entityQueryParserService.validateFilterPropertyTypes(filter, template); - return entityRepository.findByTemplateIdentifierWithFilter(templateIdentifier, filter, pageable); - } + private final EntityRepositoryPort entityRepository; + private final EntityValidationService entityValidationService; + private final EntityTemplateValidationService entityTemplateValidationService; + private final EntityTemplateService entityTemplateService; + private final EntityQueryParserService entityQueryParserService; - /// Provides lightweight entity summaries for efficient bulk operations. - /// - /// **Contract:** Returns summary projections without full entity data, optimized - /// for UI lists and relationship resolution scenarios. - /// - /// @param identifiers business identifiers of entities to summarize - /// @return lightweight entity summaries for the specified identifiers - public List getEntitiesSummariesByIdentifiers(List identifiers) { - return entityRepository.findByIdentifierIn(identifiers); - } + /// Retrieves entities filtered by template with optional query filter. + /// + /// **Contract:** Returns paginated entities conforming to the specified + /// template + /// that additionally satisfy all criteria in filter (when provided). Template + /// existence is validated first. When filter is null or empty, the result + /// includes all entities for the template. + /// + /// @param pageable pagination configuration for large entity sets + /// @param templateIdentifier business identifier of the entity template + /// @param entityFilter the parsed query filter; null or [EntityFilter#empty()] + /// for no filtering + /// @return paginated entities matching the template and all filter criteria + /// @throws EntityTemplateNotFoundException when template doesn't exist + @Transactional + public Page getEntitiesByTemplateIdentifier(Pageable pageable, String templateIdentifier, + EntityFilter entityFilter) { + EntityTemplate template = entityTemplateService + .getEntityTemplateByIdentifier(templateIdentifier); + EntityFilter filter = entityFilter != null ? entityFilter : EntityFilter.empty(); + entityQueryParserService.validateFilterPropertyTypes(filter, template); + return entityRepository.findByTemplateIdentifierWithFilter(templateIdentifier, filter, + pageable); + } - /// Retrieves a specific entity with template and entity validation. - /// - /// **Contract:** Returns the entity identified by both template and entity - /// identifiers. Validates template existence first, then entity existence, - /// ensuring referential integrity. - /// - /// @param templateIdentifier business identifier of the entity template - /// @param entityIdentifier unique business identifier of the entity within - /// template - /// @return the entity matching both identifiers - /// @throws EntityTemplateNotFoundException when template doesn't exist - /// @throws EntityNotFoundException when entity doesn't exist - @Transactional - public Entity getEntityByTemplateIdentifierAndIdentifier(String templateIdentifier, String entityIdentifier) { - entityTemplateValidationService.validateTemplateExists(templateIdentifier); - return entityRepository.findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) - .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, - entityIdentifier)); - } + /// Provides lightweight entity summaries for efficient bulk operations. + /// + /// **Contract:** Returns summary projections without full entity data, + /// optimized + /// for UI lists and relationship resolution scenarios. + /// + /// @param identifiers business identifiers of entities to summarize + /// @return lightweight entity summaries for the specified identifiers + public List getEntitiesSummariesByIdentifiers(List identifiers) { + return entityRepository.findByIdentifierIn(identifiers); + } - /// Creates and persists a new entity with business validation. - /// - /// **Contract:** Resolves the referenced template (single round-trip — combined - /// existence check and fetch), enforces entity identifier uniqueness within the - /// template scope, then validates entity/property data integrity against the - /// resolved template before persisting. - /// - /// @param entity validated entity to create and persist - /// @return the persisted entity with generated identifiers - /// @throws EntityTemplateNotFoundException when the referenced template doesn't - /// exist - /// @throws EntityAlreadyExistsException when an entity with the same - /// identifier already exists for this - /// template - /// @throws EntityValidationException when entity, property, or relation - /// data is invalid - @Transactional - public Entity createEntity(@Valid Entity entity) { - EntityTemplate template = entityTemplateService.getEntityTemplateByIdentifier(entity.templateIdentifier()); - entityValidationService.validateForCreation(entity, template); - return entityRepository.save(entity); - } + /// Retrieves a specific entity with template and entity validation. + /// + /// **Contract:** Returns the entity identified by both template and entity + /// identifiers. Validates template existence first, then entity existence, + /// ensuring referential integrity. + /// + /// @param templateIdentifier business identifier of the entity template + /// @param entityIdentifier unique business identifier of the entity within + /// template + /// @return the entity matching both identifiers + /// @throws EntityTemplateNotFoundException when template doesn't exist + /// @throws EntityNotFoundException when entity doesn't exist + @Transactional + public Entity getEntityByTemplateIdentifierAndIdentifier(String templateIdentifier, + String entityIdentifier) { + entityTemplateValidationService.validateTemplateExists(templateIdentifier); + return entityRepository + .findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) + .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); + } - /// Updates an existing entity identified by template and entity identifiers. - /// - /// **Contract:** Validates template existence, then entity existence within the - /// template scope. Validates updated entity data against the template constraints - /// before persisting changes. - /// - /// @param templateIdentifier template identifier from the request path - /// @param entityIdentifier entity identifier from the request path - /// @param entity validated entity payload - /// @return persisted updated entity - /// @throws EntityTemplateNotFoundException when template doesn't exist - /// @throws EntityNotFoundException when target entity doesn't exist - /// @throws EntityValidationException when payload violates template constraints - @Transactional - public Entity updateEntity(String templateIdentifier, String entityIdentifier, @Valid Entity entity) { - EntityTemplate template = entityTemplateService.getEntityTemplateByIdentifier(templateIdentifier); - Entity existingEntity = entityRepository.findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) - .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); + /// Creates and persists a new entity with business validation. + /// + /// **Contract:** Resolves the referenced template (single round-trip — combined + /// existence check and fetch), enforces entity identifier uniqueness within the + /// template scope, then validates entity/property data integrity against the + /// resolved template before persisting. + /// + /// @param entity validated entity to create and persist + /// @return the persisted entity with generated identifiers + /// @throws EntityTemplateNotFoundException when the referenced template doesn't + /// exist + /// @throws EntityAlreadyExistsException when an entity with the same + /// identifier already exists for this + /// template + /// @throws EntityValidationException when entity, property, or relation + /// data is invalid + @Transactional + public Entity createEntity(@Valid Entity entity) { + EntityTemplate template = entityTemplateService + .getEntityTemplateByIdentifier(entity.templateIdentifier()); + entityValidationService.validateForCreation(entity, template); + return entityRepository.save(entity); + } - Entity entityToSave = new Entity( - existingEntity.id(), - templateIdentifier, - entity.name(), - entityIdentifier, - entity.properties(), - entity.relations()); + /// Updates an existing entity identified by template and entity identifiers. + /// + /// **Contract:** Validates template existence, then entity existence within the + /// template scope. Validates updated entity data against the template + /// constraints + /// before persisting changes. + /// + /// @param templateIdentifier template identifier from the request path + /// @param entityIdentifier entity identifier from the request path + /// @param entity validated entity payload + /// @return persisted updated entity + /// @throws EntityTemplateNotFoundException when template doesn't exist + /// @throws EntityNotFoundException when target entity doesn't exist + /// @throws EntityValidationException when payload violates template constraints + @Transactional + public Entity updateEntity(String templateIdentifier, String entityIdentifier, + @Valid Entity entity) { + EntityTemplate template = entityTemplateService + .getEntityTemplateByIdentifier(templateIdentifier); + Entity existingEntity = entityRepository + .findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) + .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); - entityValidationService.validateForUpdate(entityToSave, template); - return entityRepository.save(entityToSave); - } + Entity entityToSave = new Entity(existingEntity.id(), templateIdentifier, entity.name(), + entityIdentifier, entity.properties(), entity.relations()); + entityValidationService.validateForUpdate(entityToSave, template); + return entityRepository.save(entityToSave); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java index a37a6eef..f70d6683 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityValidationService.java @@ -29,72 +29,82 @@ @AllArgsConstructor public class EntityValidationService { - private final EntityRepositoryPort entityRepository; - private final PropertyValidationService propertyValidationService; - private final RelationValidationService relationValidationService; - - /// Validates intrinsic entity data integrity and template-driven rules. - /// - /// **Contract:** the caller is responsible for resolving the [EntityTemplate] - /// (typically via [com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort]) - /// and passing it in. This avoids a redundant database round-trip and clarifies - /// the dependency graph of the validation service. - /// - /// @param entity the entity to validate - /// @param template the already-resolved template the entity must conform to - /// @throws EntityValidationException when one or more validation rules are violated - /// @throws EntityAlreadyExistsException if an entity with the same identifier exists for the template - void validateForCreation(Entity entity, EntityTemplate template) { - validateUniqueness(entity); - validateAgainstTemplate(template, entity); - } - - /// Validates entity data for update operations. - /// - /// **Contract:** update keeps the existing aggregate identity and applies the - /// same template conformance rules as creation. Uniqueness check is not needed - /// when updating an already identified entity. - /// - /// @param entity the entity payload to validate - /// @param template the already-resolved template the entity must conform to - /// @throws EntityValidationException when one or more validation rules are violated - void validateForUpdate(Entity entity, EntityTemplate template) { - validateAgainstTemplate(template, entity); - } - - /// Validates entity properties against the template's property definitions, enforcing required fields and value rules. - /// @param template the entity template whose property definitions are used for validation - /// @param entity the entity being validated, containing the actual property values to check - /// @throws EntityValidationException if any property validation rules are violated, including missing required properties - private void validateAgainstTemplate(EntityTemplate template, - Entity entity) { - Violations violations = new Violations(); - - List definitions = Optional.ofNullable(template.propertiesDefinitions()).orElse(List.of()); - - Map propertiesByName = Optional.ofNullable(entity.properties()).orElse(List.of()).stream() - .filter(p -> p.name() != null) - .collect(Collectors.toMap(Property::name, p -> p, (left, _) -> left)); - - propertyValidationService.validatePropertiesAgainstTemplate(template, definitions, propertiesByName, violations); - - relationValidationService.validateRelationsAgainstTemplate(template, entity.relations(), violations); - - if (!violations.isEmpty()) { - throw new EntityValidationException(violations.asList()); - } + private final EntityRepositoryPort entityRepository; + private final PropertyValidationService propertyValidationService; + private final RelationValidationService relationValidationService; + + /// Validates intrinsic entity data integrity and template-driven rules. + /// + /// **Contract:** the caller is responsible for resolving the [EntityTemplate] + /// (typically via + /// [com.decathlon.idp_core.domain.port.EntityTemplateRepositoryPort]) + /// and passing it in. This avoids a redundant database round-trip and clarifies + /// the dependency graph of the validation service. + /// + /// @param entity the entity to validate + /// @param template the already-resolved template the entity must conform to + /// @throws EntityValidationException when one or more validation rules are + /// violated + /// @throws EntityAlreadyExistsException if an entity with the same identifier + /// exists for the template + void validateForCreation(Entity entity, EntityTemplate template) { + validateUniqueness(entity); + validateAgainstTemplate(template, entity); + } + + /// Validates entity data for update operations. + /// + /// **Contract:** update keeps the existing aggregate identity and applies the + /// same template conformance rules as creation. Uniqueness check is not needed + /// when updating an already identified entity. + /// + /// @param entity the entity payload to validate + /// @param template the already-resolved template the entity must conform to + /// @throws EntityValidationException when one or more validation rules are + /// violated + void validateForUpdate(Entity entity, EntityTemplate template) { + validateAgainstTemplate(template, entity); + } + + /// Validates entity properties against the template's property definitions, + /// enforcing required fields and value rules. + /// @param template the entity template whose property definitions are used for + /// validation + /// @param entity the entity being validated, containing the actual property + /// values to check + /// @throws EntityValidationException if any property validation rules are + /// violated, including missing required properties + private void validateAgainstTemplate(EntityTemplate template, Entity entity) { + Violations violations = new Violations(); + + List definitions = Optional.ofNullable(template.propertiesDefinitions()) + .orElse(List.of()); + + Map propertiesByName = Optional.ofNullable(entity.properties()) + .orElse(List.of()).stream().filter(p -> p.name() != null) + .collect(Collectors.toMap(Property::name, p -> p, (left, _) -> left)); + + propertyValidationService.validatePropertiesAgainstTemplate(template, definitions, + propertiesByName, violations); + + relationValidationService.validateRelationsAgainstTemplate(template, entity.relations(), + violations); + + if (!violations.isEmpty()) { + throw new EntityValidationException(violations.asList()); } - - - /// Checks for existing entity with same template and identifier to prevent duplicates. - /// @param entity the entity to check for existence - /// @throws EntityAlreadyExistsException if an entity with the same template and identifier already exists - private void validateUniqueness(final Entity entity) { - if (entity.identifier() != null - && entityRepository - .findByTemplateIdentifierAndIdentifier(entity.templateIdentifier(), entity.identifier()) - .isPresent()) { - throw new EntityAlreadyExistsException(entity.templateIdentifier(), entity.identifier()); - } + } + + /// Checks for existing entity with same template and identifier to prevent + /// duplicates. + /// @param entity the entity to check for existence + /// @throws EntityAlreadyExistsException if an entity with the same template and + /// identifier already exists + private void validateUniqueness(final Entity entity) { + if (entity.identifier() != null && entityRepository + .findByTemplateIdentifierAndIdentifier(entity.templateIdentifier(), entity.identifier()) + .isPresent()) { + throw new EntityAlreadyExistsException(entity.templateIdentifier(), entity.identifier()); } + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/Violations.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/Violations.java index a51293a6..2324ddc4 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/Violations.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/Violations.java @@ -9,33 +9,33 @@ /// validators stay focused on the rule they enforce rather than on string /// concatenation. Not thread-safe; intended for short-lived per-request use. public final class Violations { - private final List messages = new ArrayList<>(); + private final List messages = new ArrayList<>(); - void add(String message) { - messages.add(message); - } + void add(String message) { + messages.add(message); + } - public void add(String template, Object... args) { - messages.add(template.formatted(args)); - } + public void add(String template, Object... args) { + messages.add(template.formatted(args)); + } - void addIfBlank(String value, String message) { - if (value == null || value.isBlank()) { - messages.add(message); - } + void addIfBlank(String value, String message) { + if (value == null || value.isBlank()) { + messages.add(message); } + } - /// Adds a violation prefixed with the indexed collection name, e.g. - /// `Property[2]: Property name is mandatory`. - public void addIndexed(String collection, int index, String message) { - messages.add("%s[%d]: %s".formatted(collection, index, message)); - } + /// Adds a violation prefixed with the indexed collection name, e.g. + /// `Property[2]: Property name is mandatory`. + public void addIndexed(String collection, int index, String message) { + messages.add("%s[%d]: %s".formatted(collection, index, message)); + } - boolean isEmpty() { - return messages.isEmpty(); - } + boolean isEmpty() { + return messages.isEmpty(); + } - List asList() { - return List.copyOf(messages); - } + List asList() { + return List.copyOf(messages); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java index 79647cc9..d61a4bda 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/PropertyDefinitionValidationService.java @@ -1,20 +1,5 @@ package com.decathlon.idp_core.domain.service.entity_template; -import com.decathlon.idp_core.domain.exception.entity_template.PropertyDefinitionRulesConflictException; -import com.decathlon.idp_core.domain.exception.entity_template.PropertyNameAlreadyExistsException; -import com.decathlon.idp_core.domain.exception.entity_template.PropertyTypeChangeException; -import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; -import com.decathlon.idp_core.domain.model.entity_template.PropertyRules; -import com.decathlon.idp_core.domain.model.enums.PropertyType; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.HashSet; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_RULES_BOOLEAN_NOT_ALLOWED; import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_RULES_MAX_LENGTH_POSITIVE; import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_RULES_MIN_LENGTH_NON_NEGATIVE; @@ -23,6 +8,24 @@ import static com.decathlon.idp_core.domain.constant.ValidationMessages.ruleNotAllowed; import static com.decathlon.idp_core.domain.constant.ValidationMessages.rulesAreIncompatible; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; + +import com.decathlon.idp_core.domain.exception.entity_template.PropertyDefinitionRulesConflictException; +import com.decathlon.idp_core.domain.exception.entity_template.PropertyNameAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_template.PropertyTypeChangeException; +import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; +import com.decathlon.idp_core.domain.model.entity_template.PropertyRules; +import com.decathlon.idp_core.domain.model.enums.PropertyType; + +import lombok.RequiredArgsConstructor; + /// Domain service for validating property definitions and their compatibility with property types. /// /// **Business rules:** @@ -41,305 +44,264 @@ @RequiredArgsConstructor public class PropertyDefinitionValidationService { - private final PropertyRegexValidationService propertyRegexValidationService; + private final PropertyRegexValidationService propertyRegexValidationService; - // Rule name constants - public static final String REGEX = "regex"; - public static final String LENGTH = "length"; - public static final String VALUE = "value"; - public static final String FORMAT = "format"; - public static final String ENUM_VALUES = "enum_values"; - public static final String MAX_LENGTH = "max_length"; - public static final String MIN_LENGTH = "min_length"; - public static final String MAX_VALUE = "max_value"; - public static final String MIN_VALUE = "min_value"; + // Rule name constants + public static final String REGEX = "regex"; + public static final String LENGTH = "length"; + public static final String VALUE = "value"; + public static final String FORMAT = "format"; + public static final String ENUM_VALUES = "enum_values"; + public static final String MAX_LENGTH = "max_length"; + public static final String MIN_LENGTH = "min_length"; + public static final String MAX_VALUE = "max_value"; + public static final String MIN_VALUE = "min_value"; - /// Validates that all property names are unique within a template. - /// - /// **Contract:** Enforces the invariant that property names must be unique. Used - /// during template creation and updates to prevent duplicate property - /// definitions. - /// - /// @param properties the list of property definitions to validate - /// @throws PropertyNameAlreadyExistsException if duplicate property names - /// are found - public void validatePropertyNamesUniqueness(List properties) { - Set names = new HashSet<>(); - for (PropertyDefinition property : properties) { - if (property.name() != null) { - String normalizedName = property.name().toLowerCase(Locale.ROOT); - if (!names.add(normalizedName)) { - throw new PropertyNameAlreadyExistsException(property.name()); - } - } + /// Validates that all property names are unique within a template. + /// + /// **Contract:** Enforces the invariant that property names must be unique. + /// Used + /// during template creation and updates to prevent duplicate property + /// definitions. + /// + /// @param properties the list of property definitions to validate + /// @throws PropertyNameAlreadyExistsException if duplicate property names + /// are found + public void validatePropertyNamesUniqueness(List properties) { + Set names = new HashSet<>(); + for (PropertyDefinition property : properties) { + if (property.name() != null) { + String normalizedName = property.name().toLowerCase(Locale.ROOT); + if (!names.add(normalizedName)) { + throw new PropertyNameAlreadyExistsException(property.name()); } + } } + } - /// Validates that property types are not changed on existing properties. - /// - /// **Contract:** Enforces the invariant that property types cannot be modified - /// after initial creation. Any attempt to change a property type is forbidden. - /// Users must delete and recreate the property if they need to change its type. - /// - /// @param existingProperties the existing property definitions - /// @param incomingProperties the new/updated property definitions - /// @throws PropertyTypeChangeException if any property type change is attempted - public void validateTypeChanges(List existingProperties, List incomingProperties) { - if (existingProperties == null || existingProperties.isEmpty() || - incomingProperties == null || incomingProperties.isEmpty()) { - return; - } - Map updatedMap = incomingProperties.stream() - .collect(Collectors.toMap(p -> p.name().toLowerCase(Locale.ROOT), p -> p)); + /// Validates that property types are not changed on existing properties. + /// + /// **Contract:** Enforces the invariant that property types cannot be modified + /// after initial creation. Any attempt to change a property type is forbidden. + /// Users must delete and recreate the property if they need to change its type. + /// + /// @param existingProperties the existing property definitions + /// @param incomingProperties the new/updated property definitions + /// @throws PropertyTypeChangeException if any property type change is attempted + public void validateTypeChanges(List existingProperties, + List incomingProperties) { + if (existingProperties == null || existingProperties.isEmpty() || incomingProperties == null + || incomingProperties.isEmpty()) { + return; + } + Map updatedMap = incomingProperties.stream() + .collect(Collectors.toMap(p -> p.name().toLowerCase(Locale.ROOT), p -> p)); - for (PropertyDefinition existing : existingProperties) { - PropertyDefinition updated = updatedMap.get(existing.name().toLowerCase(Locale.ROOT)); - boolean propertyTypeChanged = updated != null && !existing.type().equals(updated.type()); + for (PropertyDefinition existing : existingProperties) { + PropertyDefinition updated = updatedMap.get(existing.name().toLowerCase(Locale.ROOT)); + boolean propertyTypeChanged = updated != null && !existing.type().equals(updated.type()); - if (propertyTypeChanged) { - throw new PropertyTypeChangeException( - existing.name(), - existing.type(), - updated.type()); - } - } + if (propertyTypeChanged) { + throw new PropertyTypeChangeException(existing.name(), existing.type(), updated.type()); + } } + } - /// Validates property rules are compatible with the property's data type. - /// - /// **Contract:** Performs comprehensive validation including: - /// - Rule type compatibility with property type - /// - Numeric constraint ordering (min ≤ max) - /// - Boolean properties reject all rules - /// - /// @param propertyDefinition the property definition containing type and rules - /// @throws PropertyDefinitionRulesConflictException when rules violate business invariants - public void validatePropertyDefinitionRules(PropertyDefinition propertyDefinition) { - if (propertyDefinition.rules() == null) { - return; - } + /// Validates property rules are compatible with the property's data type. + /// + /// **Contract:** Performs comprehensive validation including: + /// - Rule type compatibility with property type + /// - Numeric constraint ordering (min ≤ max) + /// - Boolean properties reject all rules + /// + /// @param propertyDefinition the property definition containing type and rules + /// @throws PropertyDefinitionRulesConflictException when rules violate business + /// invariants + public void validatePropertyDefinitionRules(PropertyDefinition propertyDefinition) { + if (propertyDefinition.rules() == null) { + return; + } - PropertyRules rules = propertyDefinition.rules(); - PropertyType type = propertyDefinition.type(); + PropertyRules rules = propertyDefinition.rules(); + PropertyType type = propertyDefinition.type(); - switch (type) { - case STRING: - validateStringPropertyRules(propertyDefinition.name(), rules); - break; - case NUMBER: - validateNumberPropertyRules(propertyDefinition.name(), rules); - break; - case BOOLEAN: - validateBooleanPropertyRules(propertyDefinition.name(), rules); - break; - default: - throw new IllegalArgumentException("Unknown property type: " + type); - } + switch (type) { + case STRING : + validateStringPropertyRules(propertyDefinition.name(), rules); + break; + case NUMBER : + validateNumberPropertyRules(propertyDefinition.name(), rules); + break; + case BOOLEAN : + validateBooleanPropertyRules(propertyDefinition.name(), rules); + break; + default : + throw new IllegalArgumentException("Unknown property type: " + type); } + } - /// Validates rules for STRING property type. - /// - /// **Allowed rules:** format, enum_values, regex, max_length, min_length - /// **Rejected rules:** max_value, min_value (numeric) - /// **Conflicting rules:** format, regex, and enum_values are mutually exclusive; - /// enum_values is also mutually exclusive with max_length and min_length - /// **Constraints:** 0 ≤ min_length ≤ max_length, regex must be valid - /// - /// @param propertyName name of the property (for error reporting) - /// @param rules the property rules to validate - /// @throws PropertyDefinitionRulesConflictException when rules defined violate any of the above constraints - private void validateStringPropertyRules(String propertyName, PropertyRules rules) { - validateStringIncompatibleRules(propertyName, rules); - validateStringConstraints(propertyName, rules); + /// Validates rules for STRING property type. + /// + /// **Allowed rules:** format, enum_values, regex, max_length, min_length + /// **Rejected rules:** max_value, min_value (numeric) + /// **Conflicting rules:** format, regex, and enum_values are mutually + /// exclusive; + /// enum_values is also mutually exclusive with max_length and min_length + /// **Constraints:** 0 ≤ min_length ≤ max_length, regex must be valid + /// + /// @param propertyName name of the property (for error reporting) + /// @param rules the property rules to validate + /// @throws PropertyDefinitionRulesConflictException when rules defined violate + /// any of the above constraints + private void validateStringPropertyRules(String propertyName, PropertyRules rules) { + validateStringIncompatibleRules(propertyName, rules); + validateStringConstraints(propertyName, rules); - // Validate regex pattern is valid - if (rules.regex() != null && !rules.regex().isBlank()) { - propertyRegexValidationService.validateRegexPattern(propertyName, rules.regex()); - } + // Validate regex pattern is valid + if (rules.regex() != null && !rules.regex().isBlank()) { + propertyRegexValidationService.validateRegexPattern(propertyName, rules.regex()); } + } - /// Validates numeric constraints for STRING property rules. - /// - /// **Constraints enforced:** - /// - min_length must be non-negative (≥ 0) - /// - max_length must be positive (> 0) - /// - min_length must be less than or equal to max_length - /// - /// @param propertyName name of the property (for error reporting) - /// @param rules the property rules to validate - /// @throws PropertyDefinitionRulesConflictException when any constraint is violated - private void validateStringConstraints(String propertyName, PropertyRules rules) { - // Validate min_length is non-negative - if (rules.minLength() != null && rules.minLength() < 0) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.STRING, - PROPERTY_RULES_MIN_LENGTH_NON_NEGATIVE - ); - } - // Validate max_length is not zero or negative - if (rules.maxLength() != null && rules.maxLength() <= 0) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.STRING, - PROPERTY_RULES_MAX_LENGTH_POSITIVE - ); - } - // Validate min_length is below or equal to max_length - if (rules.minLength() != null && rules.maxLength() != null && rules.minLength() > rules.maxLength()) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.STRING, - minMaxConstraintViolated(LENGTH) - ); - } + /// Validates numeric constraints for STRING property rules. + /// + /// **Constraints enforced:** + /// - min_length must be non-negative (≥ 0) + /// - max_length must be positive (> 0) + /// - min_length must be less than or equal to max_length + /// + /// @param propertyName name of the property (for error reporting) + /// @param rules the property rules to validate + /// @throws PropertyDefinitionRulesConflictException when any constraint is + /// violated + private void validateStringConstraints(String propertyName, PropertyRules rules) { + // Validate min_length is non-negative + if (rules.minLength() != null && rules.minLength() < 0) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + PROPERTY_RULES_MIN_LENGTH_NON_NEGATIVE); } + // Validate max_length is not zero or negative + if (rules.maxLength() != null && rules.maxLength() <= 0) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + PROPERTY_RULES_MAX_LENGTH_POSITIVE); + } + // Validate min_length is below or equal to max_length + if (rules.minLength() != null && rules.maxLength() != null + && rules.minLength() > rules.maxLength()) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + minMaxConstraintViolated(LENGTH)); + } + } - /// Validates rule compatibility and mutual exclusivity for STRING property rules. - /// - /// **Incompatibility rules enforced:** - /// - Numeric rules (max_value, min_value) are not allowed for STRING type - /// - format, regex, and enum_values are mutually exclusive - /// - enum_values and length constraints (max_length, min_length) are mutually exclusive - /// - /// @param propertyName name of the property (for error reporting) - /// @param rules the property rules to validate - /// @throws PropertyDefinitionRulesConflictException when incompatible rules are both present - private void validateStringIncompatibleRules(String propertyName, PropertyRules rules) { - // Reject numeric rules for STRING type - if (rules.maxValue() != null || rules.minValue() != null) { - String ruleName = rules.maxValue() != null ? MAX_VALUE : MIN_VALUE; - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.STRING, - PROPERTY_RULES_NUMERIC_RULE_NOT_ALLOWED.replace("{rule}", ruleName) - ); - } - - // format, regex, and enum_values are incompatible with each other - if (rules.format() != null && rules.enumValues() != null) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.STRING, - rulesAreIncompatible(FORMAT, ENUM_VALUES) - ); - } - if (rules.format() != null && rules.regex() != null) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.STRING, - rulesAreIncompatible(FORMAT, REGEX) - ); - } - if (rules.regex() != null && rules.enumValues() != null) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.STRING, - rulesAreIncompatible(REGEX, ENUM_VALUES) - ); - } + /// Validates rule compatibility and mutual exclusivity for STRING property + /// rules. + /// + /// **Incompatibility rules enforced:** + /// - Numeric rules (max_value, min_value) are not allowed for STRING type + /// - format, regex, and enum_values are mutually exclusive + /// - enum_values and length constraints (max_length, min_length) are mutually + /// exclusive + /// + /// @param propertyName name of the property (for error reporting) + /// @param rules the property rules to validate + /// @throws PropertyDefinitionRulesConflictException when incompatible rules are + /// both present + private void validateStringIncompatibleRules(String propertyName, PropertyRules rules) { + // Reject numeric rules for STRING type + if (rules.maxValue() != null || rules.minValue() != null) { + String ruleName = rules.maxValue() != null ? MAX_VALUE : MIN_VALUE; + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + PROPERTY_RULES_NUMERIC_RULE_NOT_ALLOWED.replace("{rule}", ruleName)); + } - // enum_values and length constraints are incompatible with each other - if (rules.enumValues() != null && rules.maxLength() != null) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.STRING, - rulesAreIncompatible(ENUM_VALUES, MAX_LENGTH) - ); - } - if (rules.enumValues() != null && rules.minLength() != null) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.STRING, - rulesAreIncompatible(ENUM_VALUES, MIN_LENGTH) - ); - } + // format, regex, and enum_values are incompatible with each other + if (rules.format() != null && rules.enumValues() != null) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + rulesAreIncompatible(FORMAT, ENUM_VALUES)); + } + if (rules.format() != null && rules.regex() != null) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + rulesAreIncompatible(FORMAT, REGEX)); + } + if (rules.regex() != null && rules.enumValues() != null) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + rulesAreIncompatible(REGEX, ENUM_VALUES)); + } + // enum_values and length constraints are incompatible with each other + if (rules.enumValues() != null && rules.maxLength() != null) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + rulesAreIncompatible(ENUM_VALUES, MAX_LENGTH)); + } + if (rules.enumValues() != null && rules.minLength() != null) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.STRING, + rulesAreIncompatible(ENUM_VALUES, MIN_LENGTH)); } - /// Validates rules for NUMBER property type. - /// - /// **Allowed rules:** max_value, min_value - /// **Rejected rules:** format, enum_values, regex, max_length, min_length (string) - /// **Constraints:** min_value ≤ max_value - /// - /// @param propertyName name of the property (for error reporting) - /// @param rules the property rules to validate - /// @throws PropertyDefinitionRulesConflictException when string rules are present - /// or min/max value constraints are violated - private void validateNumberPropertyRules(String propertyName, PropertyRules rules) { - if (rules.format() != null) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.NUMBER, - ruleNotAllowed(FORMAT, PropertyType.NUMBER.name()) - ); - } + } - if (rules.enumValues() != null) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.NUMBER, - ruleNotAllowed(ENUM_VALUES, PropertyType.NUMBER.name()) - ); - } + /// Validates rules for NUMBER property type. + /// + /// **Allowed rules:** max_value, min_value + /// **Rejected rules:** format, enum_values, regex, max_length, min_length + /// (string) + /// **Constraints:** min_value ≤ max_value + /// + /// @param propertyName name of the property (for error reporting) + /// @param rules the property rules to validate + /// @throws PropertyDefinitionRulesConflictException when string rules are + /// present + /// or min/max value constraints are violated + private void validateNumberPropertyRules(String propertyName, PropertyRules rules) { + if (rules.format() != null) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER, + ruleNotAllowed(FORMAT, PropertyType.NUMBER.name())); + } - if (rules.regex() != null) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.NUMBER, - ruleNotAllowed(REGEX, PropertyType.NUMBER.name()) - ); - } + if (rules.enumValues() != null) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER, + ruleNotAllowed(ENUM_VALUES, PropertyType.NUMBER.name())); + } - if (rules.minLength() != null) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.NUMBER, - ruleNotAllowed(MIN_LENGTH, PropertyType.NUMBER.name()) - ); - } + if (rules.regex() != null) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER, + ruleNotAllowed(REGEX, PropertyType.NUMBER.name())); + } - if (rules.maxLength() != null) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.NUMBER, - ruleNotAllowed(MAX_LENGTH, PropertyType.NUMBER.name()) - ); - } + if (rules.minLength() != null) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER, + ruleNotAllowed(MIN_LENGTH, PropertyType.NUMBER.name())); + } - if (rules.minValue() != null && rules.maxValue() != null && rules.minValue() > rules.maxValue()) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.NUMBER, - minMaxConstraintViolated(VALUE) - ); - } + if (rules.maxLength() != null) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER, + ruleNotAllowed(MAX_LENGTH, PropertyType.NUMBER.name())); + } + + if (rules.minValue() != null && rules.maxValue() != null + && rules.minValue() > rules.maxValue()) { + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.NUMBER, + minMaxConstraintViolated(VALUE)); } + } - /// Validates rules for BOOLEAN property type. - /// - /// **Allowed rules:** None - /// **Rejected rules:** All rules must be null or empty - /// - /// @param propertyName name of the property (for error reporting) - /// @param rules the property rules to validate - /// @throws PropertyDefinitionRulesConflictException when any rule is set for BOOLEAN - private void validateBooleanPropertyRules(String propertyName, PropertyRules rules) { - if (rules.format() != null || - rules.enumValues() != null || - rules.regex() != null || - rules.maxLength() != null || - rules.minLength() != null || - rules.maxValue() != null || - rules.minValue() != null) { + /// Validates rules for BOOLEAN property type. + /// + /// **Allowed rules:** None + /// **Rejected rules:** All rules must be null or empty + /// + /// @param propertyName name of the property (for error reporting) + /// @param rules the property rules to validate + /// @throws PropertyDefinitionRulesConflictException when any rule is set for + /// BOOLEAN + private void validateBooleanPropertyRules(String propertyName, PropertyRules rules) { + if (rules.format() != null || rules.enumValues() != null || rules.regex() != null + || rules.maxLength() != null || rules.minLength() != null || rules.maxValue() != null + || rules.minValue() != null) { - throw new PropertyDefinitionRulesConflictException( - propertyName, - PropertyType.BOOLEAN, - PROPERTY_RULES_BOOLEAN_NOT_ALLOWED - ); - } + throw new PropertyDefinitionRulesConflictException(propertyName, PropertyType.BOOLEAN, + PROPERTY_RULES_BOOLEAN_NOT_ALLOWED); } + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java index a01a2ddd..f1e8ff44 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/property/PropertyValidationService.java @@ -31,109 +31,127 @@ import com.decathlon.idp_core.domain.service.entity.Violations; /** - * Domain service validating entity property values against template definitions. + * Domain service validating entity property values against template + * definitions. */ @Service public class PropertyValidationService { - private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9+_.-]+@(.+)$"); - private static final Pattern URL_PATTERN = Pattern.compile("^https?://.*$"); - - /// Cache of compiled regex patterns keyed by their source string. - /// Avoids recompiling the same pattern on every property validation call. - private final Map patternCache = new ConcurrentHashMap<>(); - - /** - * Validates a concrete property value against its property definition. - * The value's runtime Java type is checked first against the expected - * [PropertyType] (STRING ⇒ {@link String}, NUMBER ⇒ {@link Number}, - * BOOLEAN ⇒ {@link Boolean}). When the type matches, the value is - * normalized to a string and the type-specific rules are evaluated. - * - * @param propertyDefinition property definition with expected type and optional rules - * @param rawValue raw property value preserving its original JSON type - * @return list of violations for this value; empty when valid - */ - public List validatePropertyValue(PropertyDefinition propertyDefinition, Object rawValue) { - return switch (propertyDefinition.type()) { - case STRING -> validateStringPropertyValue(propertyDefinition.name(), rawValue, propertyDefinition.rules()); - case NUMBER -> validateNumberPropertyValue(propertyDefinition.name(), rawValue, propertyDefinition.rules()); - case BOOLEAN -> validateBooleanPropertyValue(propertyDefinition.name(), rawValue); - }; + private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9+_.-]+@(.+)$"); + private static final Pattern URL_PATTERN = Pattern.compile("^https?://.*$"); + + /// Cache of compiled regex patterns keyed by their source string. + /// Avoids recompiling the same pattern on every property validation call. + private final Map patternCache = new ConcurrentHashMap<>(); + + /** + * Validates a concrete property value against its property definition. The + * value's runtime Java type is checked first against the expected + * [PropertyType] (STRING ⇒ {@link String}, NUMBER ⇒ {@link Number}, BOOLEAN ⇒ + * {@link Boolean}). When the type matches, the value is normalized to a string + * and the type-specific rules are evaluated. + * + * @param propertyDefinition + * property definition with expected type and optional rules + * @param rawValue + * raw property value preserving its original JSON type + * @return list of violations for this value; empty when valid + */ + public List validatePropertyValue(PropertyDefinition propertyDefinition, + Object rawValue) { + return switch (propertyDefinition.type()) { + case STRING -> validateStringPropertyValue(propertyDefinition.name(), rawValue, + propertyDefinition.rules()); + case NUMBER -> validateNumberPropertyValue(propertyDefinition.name(), rawValue, + propertyDefinition.rules()); + case BOOLEAN -> validateBooleanPropertyValue(propertyDefinition.name(), rawValue); + }; + } + + /// Validates that all required properties defined in the template are present + /// and conform to their definitions. + /// Also validates that all provided properties are actually defined in the + /// template. + /// For each property definition, checks if the corresponding property is + /// provided and non-blank. If a required property is missing, adds a violation. + /// If the property is present, validates its value against the definition's + /// rules and accumulates any violations found. + /// @param template the entity template whose property definitions are used for + /// validation + /// @param definitions the list of property definitions from the template + /// @param propertiesByName a map of provided properties keyed by their name for + /// quick lookup + /// @param violations the accumulator for any validation violations found during + /// the process + /// @throws EntityValidationException if any required property is missing or if + /// any property value violates its definition rules + /// @implNote This method focuses on validating the presence and correctness of + /// properties as defined by the template. It iterates through each property + /// definition, checks for the corresponding provided property, and applies the + /// appropriate validation logic based on the property's type and rules. + public void validatePropertiesAgainstTemplate(final EntityTemplate template, + final List definitions, final Map propertiesByName, + final Violations violations) { + var definedPropertyNames = definitions.stream().map(PropertyDefinition::name) + .collect(Collectors.toSet()); + + for (String providedPropertyName : propertiesByName.keySet()) { + if (!definedPropertyNames.contains(providedPropertyName)) { + violations.add(PROPERTY_NOT_DEFINED_IN_TEMPLATE, providedPropertyName, + template.identifier()); + } } - /// Validates that all required properties defined in the template are present and conform to their definitions. - /// Also validates that all provided properties are actually defined in the template. - /// For each property definition, checks if the corresponding property is provided and non-blank. If a required property is missing, adds a violation. If the property is present, validates its value against the definition's rules and accumulates any violations found. - /// @param template the entity template whose property definitions are used for validation - /// @param definitions the list of property definitions from the template - /// @param propertiesByName a map of provided properties keyed by their name for quick lookup - /// @param violations the accumulator for any validation violations found during the process - /// @throws EntityValidationException if any required property is missing or if any property value violates its definition rules - /// @implNote This method focuses on validating the presence and correctness of properties as defined by the template. It iterates through each property definition, checks for the corresponding provided property, and applies the appropriate validation logic based on the property's type and rules. - public void validatePropertiesAgainstTemplate(final EntityTemplate template, final List definitions, final Map propertiesByName, final Violations violations) { - var definedPropertyNames = definitions.stream() - .map(PropertyDefinition::name) - .collect(Collectors.toSet()); - - for (String providedPropertyName : propertiesByName.keySet()) { - if (!definedPropertyNames.contains(providedPropertyName)) { - violations.add(PROPERTY_NOT_DEFINED_IN_TEMPLATE, providedPropertyName, template.identifier()); - } - } - - for (PropertyDefinition definition : definitions) { - Property property = propertiesByName.get(definition.name()); - boolean missing = property == null - || property.value() == null - || (property.value().isBlank()); - - if (missing) { - if (definition.required()) { - violations.add(PROPERTY_REQUIRED_MISSING, definition.name(), template.identifier()); - } - continue; - } + for (PropertyDefinition definition : definitions) { + Property property = propertiesByName.get(definition.name()); + boolean missing = property == null || property.value() == null + || (property.value().isBlank()); - validatePropertyValue(definition, property.value()) - .forEach(violations::add); + if (missing) { + if (definition.required()) { + violations.add(PROPERTY_REQUIRED_MISSING, definition.name(), template.identifier()); } - } - + continue; + } - private List validateStringPropertyValue(String propertyName, Object rawValue, PropertyRules rules) { - if (!(rawValue instanceof String stringValue)) { - return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.STRING)); - } + validatePropertyValue(definition, property.value()).forEach(violations::add); + } + } - if (rules == null) { - return List.of(); - } + private List validateStringPropertyValue(String propertyName, Object rawValue, + PropertyRules rules) { + if (!(rawValue instanceof String stringValue)) { + return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.STRING)); + } - var violations = new ArrayList(); + if (rules == null) { + return List.of(); + } - if (rules.minLength() != null && stringValue.length() < rules.minLength()) { - violations.add(PROPERTY_MIN_LENGTH_VIOLATION.formatted(propertyName, rules.minLength())); - } - if (rules.maxLength() != null && stringValue.length() > rules.maxLength()) { - violations.add(PROPERTY_MAX_LENGTH_VIOLATION.formatted(propertyName, rules.maxLength())); - } - if (rules.regex() != null - && !patternCache.computeIfAbsent(rules.regex(), Pattern::compile).matcher(stringValue).matches()) { - violations.add(PROPERTY_REGEX_VIOLATION.formatted(propertyName)); - } - if (rules.enumValues() != null && !rules.enumValues().isEmpty() - && rules.enumValues().stream().noneMatch(enumValue -> enumValue.equalsIgnoreCase(stringValue))) { - violations.add(PROPERTY_ENUM_VIOLATION.formatted(propertyName, rules.enumValues())); - } - if (rules.format() != null && !matchesFormat(rules.format(), stringValue)) { - violations.add(PROPERTY_FORMAT_VIOLATION.formatted(propertyName, rules.format())); - } + var violations = new ArrayList(); - return List.copyOf(violations); + if (rules.minLength() != null && stringValue.length() < rules.minLength()) { + violations.add(PROPERTY_MIN_LENGTH_VIOLATION.formatted(propertyName, rules.minLength())); + } + if (rules.maxLength() != null && stringValue.length() > rules.maxLength()) { + violations.add(PROPERTY_MAX_LENGTH_VIOLATION.formatted(propertyName, rules.maxLength())); + } + if (rules.regex() != null && !patternCache.computeIfAbsent(rules.regex(), Pattern::compile) + .matcher(stringValue).matches()) { + violations.add(PROPERTY_REGEX_VIOLATION.formatted(propertyName)); + } + if (rules.enumValues() != null && !rules.enumValues().isEmpty() && rules.enumValues().stream() + .noneMatch(enumValue -> enumValue.equalsIgnoreCase(stringValue))) { + violations.add(PROPERTY_ENUM_VIOLATION.formatted(propertyName, rules.enumValues())); + } + if (rules.format() != null && !matchesFormat(rules.format(), stringValue)) { + violations.add(PROPERTY_FORMAT_VIOLATION.formatted(propertyName, rules.format())); } - private List validateNumberPropertyValue(String propertyName, Object rawValue, PropertyRules rules) { + return List.copyOf(violations); + } + + private List validateNumberPropertyValue(String propertyName, Object rawValue, PropertyRules rules) { final BigDecimal parsedValue; switch (rawValue) { case Number number -> parsedValue = new BigDecimal(number.toString()); @@ -165,21 +183,21 @@ private List validateNumberPropertyValue(String propertyName, Object raw return List.copyOf(violations); } - private List validateBooleanPropertyValue(String propertyName, Object rawValue) { - if (rawValue instanceof Boolean) { - return List.of(); - } - if (rawValue instanceof String string - && ("true".equalsIgnoreCase(string) || "false".equalsIgnoreCase(string))) { - return List.of(); - } - return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.BOOLEAN)); + private List validateBooleanPropertyValue(String propertyName, Object rawValue) { + if (rawValue instanceof Boolean) { + return List.of(); } - - private boolean matchesFormat(PropertyFormat format, String value) { - return switch (format) { - case EMAIL -> EMAIL_PATTERN.matcher(value).matches(); - case URL -> URL_PATTERN.matcher(value).matches(); - }; + if (rawValue instanceof String string + && ("true".equalsIgnoreCase(string) || "false".equalsIgnoreCase(string))) { + return List.of(); } + return List.of(PROPERTY_TYPE_MISMATCH.formatted(propertyName, PropertyType.BOOLEAN)); + } + + private boolean matchesFormat(PropertyFormat format, String value) { + return switch (format) { + case EMAIL -> EMAIL_PATTERN.matcher(value).matches(); + case URL -> URL_PATTERN.matcher(value).matches(); + }; + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/relation/RelationService.java b/src/main/java/com/decathlon/idp_core/domain/service/relation/RelationService.java index 02a81385..9f081606 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/relation/RelationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/relation/RelationService.java @@ -24,20 +24,21 @@ @AllArgsConstructor public class RelationService { - private final RelationRepositoryPort relationRepository; - - /// Finds all incoming relationships where specified entities are targets. - /// - /// **Contract:** Returns relationship summaries for dependency analysis and - /// impact assessment. Useful for understanding entity interconnections before - /// deletion or modification operations. - /// - /// @param targetEntityIdentifiers business identifiers of entities to analyze - /// @return relationship summaries showing incoming connections to - /// target entities - public List findRelationsSummariesByTargetEntityIdentifiers( - List targetEntityIdentifiers) { - return relationRepository.findRelationsSummariesByTargetEntityIdentifiers(targetEntityIdentifiers); - } + private final RelationRepositoryPort relationRepository; + + /// Finds all incoming relationships where specified entities are targets. + /// + /// **Contract:** Returns relationship summaries for dependency analysis and + /// impact assessment. Useful for understanding entity interconnections before + /// deletion or modification operations. + /// + /// @param targetEntityIdentifiers business identifiers of entities to analyze + /// @return relationship summaries showing incoming connections to + /// target entities + public List findRelationsSummariesByTargetEntityIdentifiers( + List targetEntityIdentifiers) { + return relationRepository + .findRelationsSummariesByTargetEntityIdentifiers(targetEntityIdentifiers); + } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/relation/RelationValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/relation/RelationValidationService.java index 87a6caa9..45408d79 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/relation/RelationValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/relation/RelationValidationService.java @@ -1,12 +1,14 @@ package com.decathlon.idp_core.domain.service.relation; import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_NOT_DEFINED_IN_TEMPLATE; import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_REQUIRED_MISSING; -import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TOO_MANY_TARGETS; import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TARGET_ENTITY_NOT_FOUND; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TOO_MANY_TARGETS; + import java.util.List; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; + import org.springframework.stereotype.Service; import com.decathlon.idp_core.domain.model.entity.EntitySummary; @@ -15,6 +17,7 @@ import com.decathlon.idp_core.domain.model.entity_template.RelationDefinition; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; import com.decathlon.idp_core.domain.service.entity.Violations; + import lombok.RequiredArgsConstructor; /// Domain service validating entity relations against template relation definitions. @@ -22,84 +25,89 @@ @RequiredArgsConstructor public class RelationValidationService { - private final EntityRepositoryPort entityRepository; - - /// Validates entity relations against the template's relation definitions, enforcing required relations and cardinality constraints. - /// @param template the entity template whose relation definitions are used for validation - /// @param providedRelations the actual relations provided in the entity to validate - /// @param violations the accumulator for any validation violations found during the process - public void validateRelationsAgainstTemplate(EntityTemplate template, - List providedRelations, - Violations violations) { - - List definitions = template.relationsDefinitions() != null ? template.relationsDefinitions() : List.of(); - List relations = providedRelations != null ? providedRelations : List.of(); - - Map definitionsByName = definitions.stream() - .filter(def -> def.name() != null) - .collect(Collectors.toMap(RelationDefinition::name, def -> def, - (existing, replacement) -> existing)); - - Map relationsByName = relations.stream() - .filter(rel -> rel.name() != null) - .collect(Collectors.toMap(Relation::name, rel -> rel, - (existing, replacement) -> existing)); - - for (Relation relation : relations) { - if (relation.name() != null && !definitionsByName.containsKey(relation.name())) { - violations.add(RELATION_NOT_DEFINED_IN_TEMPLATE, relation.name(), template.identifier()); - } else { - validateRelationTargetEntityExistence(relation, violations); - } - } - - for (RelationDefinition definition : definitions) { - Relation relation = relationsByName.get(definition.name()); - List validTargets = extractValidTargetIdentifiers(relation); - - if (definition.required() && validTargets.isEmpty()) { - violations.add(RELATION_REQUIRED_MISSING, definition.name(), template.identifier()); - } - - if (relation != null && !definition.toMany() && validTargets.size() > 1) { - violations.add(RELATION_TOO_MANY_TARGETS, definition.name(), template.identifier()); - } - } + private final EntityRepositoryPort entityRepository; + + /// Validates entity relations against the template's relation definitions, + /// enforcing required relations and cardinality constraints. + /// @param template the entity template whose relation definitions are used for + /// validation + /// @param providedRelations the actual relations provided in the entity to + /// validate + /// @param violations the accumulator for any validation violations found during + /// the process + public void validateRelationsAgainstTemplate(EntityTemplate template, + List providedRelations, Violations violations) { + + List definitions = template.relationsDefinitions() != null + ? template.relationsDefinitions() + : List.of(); + List relations = providedRelations != null ? providedRelations : List.of(); + + Map definitionsByName = definitions.stream() + .filter(def -> def.name() != null).collect(Collectors.toMap(RelationDefinition::name, + def -> def, (existing, replacement) -> existing)); + + Map relationsByName = relations.stream().filter(rel -> rel.name() != null) + .collect(Collectors.toMap(Relation::name, rel -> rel, (existing, replacement) -> existing)); + + for (Relation relation : relations) { + if (relation.name() != null && !definitionsByName.containsKey(relation.name())) { + violations.add(RELATION_NOT_DEFINED_IN_TEMPLATE, relation.name(), template.identifier()); + } else { + validateRelationTargetEntityExistence(relation, violations); + } } - /// Validates that all target entity identifiers in the relation actually exist in the database. - /// - /// @param relation the relation whose target entity identifiers are to be validated - /// @param violations the accumulator for any validation violations found during the process - private void validateRelationTargetEntityExistence(Relation relation, Violations violations) { - List targetIdentifiers = extractValidTargetIdentifiers(relation); - - if (targetIdentifiers.isEmpty()) { - return; - } - - var existingEntities = entityRepository.findByIdentifierIn(targetIdentifiers); - Set existingIdentifiers = existingEntities.stream() - .map(EntitySummary::identifier) - .collect(Collectors.toSet()); - - for (String identifier : targetIdentifiers) { - if (!existingIdentifiers.contains(identifier)) { - violations.add(RELATION_TARGET_ENTITY_NOT_FOUND, relation.name(), identifier); - } - } + for (RelationDefinition definition : definitions) { + Relation relation = relationsByName.get(definition.name()); + List validTargets = extractValidTargetIdentifiers(relation); + + if (definition.required() && validTargets.isEmpty()) { + violations.add(RELATION_REQUIRED_MISSING, definition.name(), template.identifier()); + } + + if (relation != null && !definition.toMany() && validTargets.size() > 1) { + violations.add(RELATION_TOO_MANY_TARGETS, definition.name(), template.identifier()); + } + } + } + + /// Validates that all target entity identifiers in the relation actually exist + /// in the database. + /// + /// @param relation the relation whose target entity identifiers are to be + /// validated + /// @param violations the accumulator for any validation violations found during + /// the process + private void validateRelationTargetEntityExistence(Relation relation, Violations violations) { + List targetIdentifiers = extractValidTargetIdentifiers(relation); + + if (targetIdentifiers.isEmpty()) { + return; } - /// Extracts non-null, non-blank target entity identifiers from the relation, returning an empty list if the relation or its target identifiers are null. - /// - /// @param relation the relation from which to extract target entity identifiers - /// @return a list of valid target entity identifiers; empty if none are valid or if the relation is null - private List extractValidTargetIdentifiers(Relation relation) { - if (relation == null || relation.targetEntityIdentifiers() == null) { - return List.of(); - } - return relation.targetEntityIdentifiers().stream() - .filter(id -> id != null && !id.isBlank()) - .toList(); + var existingEntities = entityRepository.findByIdentifierIn(targetIdentifiers); + Set existingIdentifiers = existingEntities.stream().map(EntitySummary::identifier) + .collect(Collectors.toSet()); + + for (String identifier : targetIdentifiers) { + if (!existingIdentifiers.contains(identifier)) { + violations.add(RELATION_TARGET_ENTITY_NOT_FOUND, relation.name(), identifier); + } + } + } + + /// Extracts non-null, non-blank target entity identifiers from the relation, + /// returning an empty list if the relation or its target identifiers are null. + /// + /// @param relation the relation from which to extract target entity identifiers + /// @return a list of valid target entity identifiers; empty if none are valid + /// or if the relation is null + private List extractValidTargetIdentifiers(Relation relation) { + if (relation == null || relation.targetEntityIdentifiers() == null) { + return List.of(); } + return relation.targetEntityIdentifiers().stream().filter(id -> id != null && !id.isBlank()) + .toList(); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java index 61097229..cfbca720 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/CorsProperties.java @@ -6,12 +6,11 @@ /// Type-safe CORS configuration properties bound from `spring.web.cors`. @ConfigurationProperties(prefix = "spring.web.cors") -public record CorsProperties( - List allowedOrigins, - List allowedOriginPatterns -) { - public CorsProperties { - allowedOrigins = allowedOrigins != null ? List.copyOf(allowedOrigins) : List.of(); - allowedOriginPatterns = allowedOriginPatterns != null ? List.copyOf(allowedOriginPatterns) : List.of(); - } +public record CorsProperties(List allowedOrigins, List allowedOriginPatterns) { + public CorsProperties { + allowedOrigins = allowedOrigins != null ? List.copyOf(allowedOrigins) : List.of(); + allowedOriginPatterns = allowedOriginPatterns != null + ? List.copyOf(allowedOriginPatterns) + : List.of(); + } } 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 ac653356..0ce81079 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 @@ -5,9 +5,10 @@ /// Centralized OpenAPI documentation constants for consistent API descriptions. /// -/// **Documentation standardization rationale:** Maintains consistency across all API -/// endpoints by centralizing descriptions, response messages, and field documentation. -/// Prevents duplication and ensures uniform language throughout the API. +/// **Documentation standardization rationale:** Maintains consistency across all +/// API endpoints by centralizing descriptions, response messages, and field +/// documentation. Prevents duplication and ensures uniform language throughout +/// the API. /// /// **Organization strategy:** /// - HTTP status codes and standard responses @@ -15,146 +16,165 @@ /// - Schema and field descriptions for comprehensive API documentation /// - Pagination parameter descriptions for consistent query interfaces /// -/// **Maintenance benefits:** Single point of truth for API documentation strings, -/// enabling easy updates and internationalization if needed in the future. +/// **Maintenance benefits:** Single point of truth for API documentation +/// strings, enabling easy updates and internationalization if needed in +/// the future. @NoArgsConstructor(access = AccessLevel.PRIVATE) public class SwaggerDescription { - /// HTTP response status codes for OpenAPI documentation - public static final String OK_CODE = "200"; - public static final String CREATED_CODE = "201"; - public static final String NO_CONTENT_CODE = "204"; - public static final String PARTIAL_CONTENT_CODE = "206"; - public static final String BAD_REQUEST_CODE = "400"; - public static final String UNAUTHORIZED_CODE = "401"; - public static final String FORBIDDEN_CODE = "403"; - public static final String NOT_FOUND_CODE = "404"; - public static final String CONFLICT_CODE = "409"; - public static final String SERVICE_UNAVAILABLE_CODE = "503"; - public static final String INTERNAL_SERVER_ERROR_CODE = "500"; - - /// Entity Template API endpoint constants - public static final String ENDPOINT_GET_TEMPLATES_SUMMARY = "Get all templates"; - public static final String ENDPOINT_GET_TEMPLATES_DESCRIPTION = "Retrieve a list of all available templates in the system"; - - public static final String ENDPOINT_GET_TEMPLATES_PAGINATED_SUMMARY = "Get paginated templates"; - public static final String ENDPOINT_GET_TEMPLATES_PAGINATED_DESCRIPTION = "Retrieve a paginated list of templates with optional sorting"; - - public static final String ENDPOINT_GET_TEMPLATE_BY_ID_SUMMARY = "Get template by ID"; - public static final String ENDPOINT_GET_TEMPLATE_BY_ID_DESCRIPTION = "Retrieve a specific template using its unique identifier"; - - public static final String ENDPOINT_GET_TEMPLATE_BY_IDENTIFIER_SUMMARY = "Get template by identifier"; - public static final String ENDPOINT_GET_TEMPLATE_BY_IDENTIFIER_DESCRIPTION = "Retrieve a specific template using its string identifier"; - - public static final String ENDPOINT_POST_TEMPLATE_SUMMARY = "Create a new template"; - public static final String ENDPOINT_POST_TEMPLATE_DESCRIPTION = "Create a new template in the system with the provided information"; - public static final String ENDPOINT_PUT_TEMPLATE_SUMMARY = "Update an existing template by template identifier"; - public static final String ENDPOINT_PUT_TEMPLATE_DESCRIPTION = "Update the details of an existing template identified by its unique string identifier"; - - public static final String ENDPOINT_DELETE_TEMPLATE_SUMMARY = "Delete template by identifier"; - public static final String ENDPOINT_DELETE_TEMPLATE_DESCRIPTION = "Remove a template from the system using its unique identifier"; - - /// Entity API endpoint constants - public static final String ENDPOINT_GET_ENTITIES_SUMMARY = "Get entities by template identifier"; - public static final String ENDPOINT_GET_ENTITIES_DESCRIPTION = "Retrieve a list of all available entities in the system"; - - public static final String ENDPOINT_GET_ENTITIES_PAGINATED_SUMMARY = "Get paginated entities"; - public static final String ENDPOINT_GET_ENTITIES_PAGINATED_DESCRIPTION = "Retrieve a paginated list of entities with optional sorting"; - - public static final String ENDPOINT_GET_ENTITY_BY_IDENTIFIER_SUMMARY = "Get entity by entity template and identifier"; - public static final String ENDPOINT_GET_ENTITY_BY_IDENTIFIER_DESCRIPTION = "Retrieve a specific entity using its string identifier and its template identifier"; - - public static final String ENDPOINT_POST_ENTITY_SUMMARY = "Create a new entity"; - public static final String ENDPOINT_POST_ENTITY_DESCRIPTION = "Create a new entity in the system with the provided information"; - public static final String ENDPOINT_PUT_ENTITY_SUMMARY = "Update an existing entity"; - public static final String ENDPOINT_PUT_ENTITY_DESCRIPTION = "Update an existing entity in the system with the provided information"; - - - /// API response description constants - public static final String RESPONSE_TEMPLATES_PAGINATED_SUCCESS = "Paginated templates retrieved successfully"; - public static final String RESPONSE_TEMPLATES_PARTIAL_CONTENT = "Partial content - paginated templates retrieved (subset of total data)"; - public static final String RESPONSE_TEMPLATE_FOUND = "Template found"; - public static final String RESPONSE_TEMPLATE_CREATED = "Template created successfully"; - public static final String RESPONSE_TEMPLATE_UPDATED = "Template update successfully"; - public static final String RESPONSE_TEMPLATE_DELETED = "Template deleted successfully"; - public static final String RESPONSE_TEMPLATE_NOT_FOUND_ID = "Template not found with the provided ID"; - public static final String RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER = "Template not found with the provided identifier"; - public static final String RESPONSE_INVALID_UUID = "Invalid UUID format"; - public static final String RESPONSE_INVALID_TEMPLATE_DATA = "Invalid template data provided"; - public static final String RESPONSE_INVALID_PAGINATION = "Invalid pagination parameters"; - public static final String RESPONSE_TEMPLATE_CONFLICT = "Template with this identifier already exists"; - public static final String RESPONSE_ENTITY_CONFLICT = "Entity already exists in this template"; - public static final String RESPONSE_ENTITIES_PAGINATED_SUCCESS = "Paginated entities retrieved successfully"; - public static final String RESPONSE_ENTITY_FOUND = "Entity found"; - public static final String RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER = "Entity not found with the provided identifier"; - public static final String RESPONSE_ENTITY_CREATED = "Entity created successfully"; - public static final String RESPONSE_ENTITY_UPDATED = "Entity updated successfully"; - public static final String RESPONSE_INVALID_ENTITY_DATA = "Invalid entity data provided"; - public static final String RESPONSE_UNEXPECTED_SERVER_ERROR = "Unexpected server-side failure"; - public static final String RESPONSE_INSUFFICIENT_RIGHTS = "Insufficient rights"; - public static final String RESPONSE_UNAUTHORIZED = "Unauthorized - Missing or invalid token"; - - - // --- Schema (class) descriptions --- - public static final String SCHEMA_ENTITY_TEMPLATE_CREATE_IN = "Input DTO for creating an entity template"; - public static final String SCHEMA_ENTITY_TEMPLATE_UPDATE_IN = "Input DTO for updating an entity template"; - public static final String SCHEMA_PROPERTY_DEFINITION_IN = "Input DTO for creating or updating a property definition"; - public static final String SCHEMA_RELATION_DEFINITION_IN = "Input DTO for creating or updating a relation definition"; - public static final String SCHEMA_PROPERTY_RULES_IN = "Input DTO for property validation rules"; - public static final String SCHEMA_ENTITY_TEMPLATE_OUT = "Output DTO for entity template"; - public static final String SCHEMA_PROPERTY_DEFINITION_OUT = "Output DTO for property definition"; - public static final String SCHEMA_RELATION_DEFINITION_OUT = "Output DTO for relation definition"; - public static final String SCHEMA_PROPERTY_RULES_OUT = "Output DTO for property validation rules"; - public static final String SCHEMA_ENTITY_IN = "Input DTO for creating or updating an entity"; - 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"; - - // --- Field descriptions (shared) --- - public static final String FIELD_TEMPLATE_ID = "Unique generated identifier of the entity template"; - public static final String FIELD_TEMPLATE_IDENTIFIER = "Unique Entity Template identifier"; - public static final String FIELD_TEMPLATE_NAME = "Unique Entity Template name"; - public static final String FIELD_TEMPLATE_DESCRIPTION = "Entity Template description"; - public static final String FIELD_TEMPLATE_PROPERTIES = "List of property definitions for this template"; - public static final String FIELD_TEMPLATE_RELATIONS = "List of relation definitions for this template"; - - public static final String FIELD_ENTITY_NAME = "Name of the entity"; - public static final String FIELD_ENTITY_IDENTIFIER = "Unique identifier of the entity within the template scope"; - public static final String FIELD_ENTITY_PROPERTIES = "Map of property name to value for this entity"; - public static final String FIELD_ENTITY_RELATIONS = "List of relations for this entity"; - public static final String FIELD_ENTITY_RELATION_NAME = "Name of the relation (must match a template relation definition)"; - public static final String FIELD_ENTITY_RELATION_TARGETS = "List of target entity identifiers for this relation"; - - public static final String FIELD_PROPERTY_ID = "Unique identifier of the property definition"; - public static final String FIELD_PROPERTY_NAME = "Property name"; - public static final String FIELD_PROPERTY_DESCRIPTION = "Property description"; - public static final String FIELD_PROPERTY_TYPE = "Property data type"; - public static final String FIELD_PROPERTY_REQUIRED = "Whether this property is required"; - public static final String FIELD_PROPERTY_RULES = "Property validation rules"; - - public static final String FIELD_PROPERTY_RULES_ID = "Unique identifier of the property rules"; - public static final String FIELD_PROPERTY_RULES_FORMAT = "Format of the property"; - public static final String FIELD_PROPERTY_RULES_ENUM_VALUES = "Allowed enum values for the property"; - public static final String FIELD_PROPERTY_RULES_REGEX = "Regular expression for property validation"; - public static final String FIELD_PROPERTY_RULES_MAX_LENGTH = "Maximum length of the property"; - public static final String FIELD_PROPERTY_RULES_MIN_LENGTH = "Minimum length of the property"; - public static final String FIELD_PROPERTY_RULES_MAX_VALUE = "Maximum value for the property"; - public static final String FIELD_PROPERTY_RULES_MIN_VALUE = "Minimum value for the property"; - public static final String FIELD_CREATED_AT = "Creation timestamp"; - public static final String FIELD_UPDATED_AT = "Last update timestamp"; - - public static final String FIELD_RELATION_ID = "Unique identifier of the relation definition"; - public static final String FIELD_RELATION_NAME = "Name of the relation"; - public static final String FIELD_RELATION_TARGET_IDENTIFIER = "Identifier of the target template"; - public static final String FIELD_RELATION_REQUIRED = "Whether this relation is required"; - public static final String FIELD_RELATION_TO_MANY = "Whether this relation can have multiple targets"; - - // --- Pagination and sorting parameter descriptions --- - public static final String PARAM_PAGE_DESCRIPTION = "Page number for pagination. Defaults to 0."; - public static final String PARAM_SIZE_DESCRIPTION = "Number of items per page. Defaults to 20."; - public static final String PARAM_SORT_DESCRIPTION = "Sorting criteria in the format: property(,asc|desc). Defaults to identifier,asc."; - public static final String PARAM_QUERY_DESCRIPTION = """ - Optional filter query using a simple expression language. See more details in the API documentation. Example: `name:idp` for entities with names containing 'idp'. - """; - public static final String RESPONSE_INVALID_QUERY = "Invalid filter query syntax"; + /// HTTP response status codes for OpenAPI documentation + public static final String OK_CODE = "200"; + public static final String CREATED_CODE = "201"; + public static final String NO_CONTENT_CODE = "204"; + public static final String PARTIAL_CONTENT_CODE = "206"; + public static final String BAD_REQUEST_CODE = "400"; + public static final String UNAUTHORIZED_CODE = "401"; + public static final String FORBIDDEN_CODE = "403"; + public static final String NOT_FOUND_CODE = "404"; + public static final String CONFLICT_CODE = "409"; + public static final String SERVICE_UNAVAILABLE_CODE = "503"; + public static final String INTERNAL_SERVER_ERROR_CODE = "500"; + + /// Entity Template API endpoint constants + public static final String ENDPOINT_GET_TEMPLATES_SUMMARY = "Get all templates"; + public static final String ENDPOINT_GET_TEMPLATES_DESCRIPTION = "Retrieve a list of all available templates in the system"; + + public static final String ENDPOINT_GET_TEMPLATES_PAGINATED_SUMMARY = "Get paginated templates"; + public static final String ENDPOINT_GET_TEMPLATES_PAGINATED_DESCRIPTION = "Retrieve a paginated list of templates with optional sorting"; + + public static final String ENDPOINT_GET_TEMPLATE_BY_ID_SUMMARY = "Get template by ID"; + public static final String ENDPOINT_GET_TEMPLATE_BY_ID_DESCRIPTION = "Retrieve a specific template using its unique identifier"; + + public static final String ENDPOINT_GET_TEMPLATE_BY_IDENTIFIER_SUMMARY = "Get template by identifier"; + public static final String ENDPOINT_GET_TEMPLATE_BY_IDENTIFIER_DESCRIPTION = "Retrieve a specific template using its string identifier"; + + public static final String ENDPOINT_POST_TEMPLATE_SUMMARY = "Create a new template"; + public static final String ENDPOINT_POST_TEMPLATE_DESCRIPTION = "Create a new template in the system with the provided information"; + public static final String ENDPOINT_PUT_TEMPLATE_SUMMARY = "Update an existing template by template identifier"; + public static final String ENDPOINT_PUT_TEMPLATE_DESCRIPTION = "Update the details of an existing template identified by its unique string identifier"; + + public static final String ENDPOINT_DELETE_TEMPLATE_SUMMARY = "Delete template by identifier"; + public static final String ENDPOINT_DELETE_TEMPLATE_DESCRIPTION = "Remove a template from the system using its unique identifier"; + + /// Entity API endpoint constants + public static final String ENDPOINT_GET_ENTITIES_SUMMARY = "Get entities by template identifier"; + public static final String ENDPOINT_GET_ENTITIES_DESCRIPTION = "Retrieve a list of all available entities in the system"; + + public static final String ENDPOINT_GET_ENTITIES_PAGINATED_SUMMARY = "Get paginated entities"; + public static final String ENDPOINT_GET_ENTITIES_PAGINATED_DESCRIPTION = "Retrieve a paginated list of entities with optional sorting"; + + public static final String ENDPOINT_GET_ENTITY_BY_IDENTIFIER_SUMMARY = "Get entity by entity template and identifier"; + public static final String ENDPOINT_GET_ENTITY_BY_IDENTIFIER_DESCRIPTION = "Retrieve a specific entity using its string identifier and its template identifier"; + + public static final String ENDPOINT_POST_ENTITY_SUMMARY = "Create a new entity"; + public static final String ENDPOINT_POST_ENTITY_DESCRIPTION = "Create a new entity in the system with the provided information"; + public static final String ENDPOINT_PUT_ENTITY_SUMMARY = "Update an existing entity"; + public static final String ENDPOINT_PUT_ENTITY_DESCRIPTION = "Update an existing entity in the system with the provided information"; + + /// API response description constants + public static final String RESPONSE_TEMPLATES_PAGINATED_SUCCESS = "Paginated templates retrieved successfully"; + public static final String RESPONSE_TEMPLATES_PARTIAL_CONTENT = "Partial content - paginated templates retrieved (subset of total data)"; + public static final String RESPONSE_TEMPLATE_FOUND = "Template found"; + public static final String RESPONSE_TEMPLATE_CREATED = "Template created successfully"; + public static final String RESPONSE_TEMPLATE_UPDATED = "Template update successfully"; + public static final String RESPONSE_TEMPLATE_DELETED = "Template deleted successfully"; + public static final String RESPONSE_TEMPLATE_NOT_FOUND_ID = "Template not found with the provided ID"; + public static final String RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER = "Template not found with the provided identifier"; + public static final String RESPONSE_INVALID_UUID = "Invalid UUID format"; + public static final String RESPONSE_INVALID_TEMPLATE_DATA = "Invalid template data provided"; + public static final String RESPONSE_INVALID_PAGINATION = "Invalid pagination parameters"; + public static final String RESPONSE_TEMPLATE_CONFLICT = "Template with this identifier already exists"; + public static final String RESPONSE_ENTITY_CONFLICT = "Entity already exists in this template"; + public static final String RESPONSE_ENTITIES_PAGINATED_SUCCESS = "Paginated entities retrieved successfully"; + public static final String RESPONSE_ENTITY_FOUND = "Entity found"; + public static final String RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER = "Entity not found with the provided identifier"; + public static final String RESPONSE_ENTITY_CREATED = "Entity created successfully"; + public static final String RESPONSE_ENTITY_UPDATED = "Entity updated successfully"; + public static final String RESPONSE_INVALID_ENTITY_DATA = "Invalid entity data provided"; + public static final String RESPONSE_UNEXPECTED_SERVER_ERROR = "Unexpected server-side failure"; + public static final String RESPONSE_INSUFFICIENT_RIGHTS = "Insufficient rights"; + public static final String RESPONSE_UNAUTHORIZED = "Unauthorized - Missing or invalid token"; + + // --- Schema (class) descriptions --- + public static final String SCHEMA_ENTITY_TEMPLATE_CREATE_IN = "Input DTO for creating an entity template"; + public static final String SCHEMA_ENTITY_TEMPLATE_UPDATE_IN = "Input DTO for updating an entity template"; + public static final String SCHEMA_PROPERTY_DEFINITION_IN = "Input DTO for creating or updating a property definition"; + public static final String SCHEMA_RELATION_DEFINITION_IN = "Input DTO for creating or updating a relation definition"; + public static final String SCHEMA_PROPERTY_RULES_IN = "Input DTO for property validation rules"; + public static final String SCHEMA_ENTITY_TEMPLATE_OUT = "Output DTO for entity template"; + public static final String SCHEMA_PROPERTY_DEFINITION_OUT = "Output DTO for property definition"; + public static final String SCHEMA_RELATION_DEFINITION_OUT = "Output DTO for relation definition"; + public static final String SCHEMA_PROPERTY_RULES_OUT = "Output DTO for property validation rules"; + public static final String SCHEMA_ENTITY_IN = "Input DTO for creating or updating an entity"; + 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"; + + // --- Field descriptions (shared) --- + public static final String FIELD_TEMPLATE_ID = "Unique generated identifier of the entity template"; + public static final String FIELD_TEMPLATE_IDENTIFIER = "Unique Entity Template identifier"; + public static final String FIELD_TEMPLATE_NAME = "Unique Entity Template name"; + public static final String FIELD_TEMPLATE_DESCRIPTION = "Entity Template description"; + public static final String FIELD_TEMPLATE_PROPERTIES = "List of property definitions for this template"; + public static final String FIELD_TEMPLATE_RELATIONS = "List of relation definitions for this template"; + + public static final String FIELD_ENTITY_NAME = "Name of the entity"; + public static final String FIELD_ENTITY_IDENTIFIER = "Unique identifier of the entity within the template scope"; + public static final String FIELD_ENTITY_PROPERTIES = "Map of property name to value for this entity"; + public static final String FIELD_ENTITY_RELATIONS = "List of relations for this entity"; + public static final String FIELD_ENTITY_RELATION_NAME = "Name of the relation (must match a template relation definition)"; + public static final String FIELD_ENTITY_RELATION_TARGETS = "List of target entity identifiers for this relation"; + + public static final String FIELD_PROPERTY_ID = "Unique identifier of the property definition"; + public static final String FIELD_PROPERTY_NAME = "Property name"; + public static final String FIELD_PROPERTY_DESCRIPTION = "Property description"; + public static final String FIELD_PROPERTY_TYPE = "Property data type"; + public static final String FIELD_PROPERTY_REQUIRED = "Whether this property is required"; + public static final String FIELD_PROPERTY_RULES = "Property validation rules"; + + public static final String FIELD_PROPERTY_RULES_ID = "Unique identifier of the property rules"; + public static final String FIELD_PROPERTY_RULES_FORMAT = "Format of the property"; + public static final String FIELD_PROPERTY_RULES_ENUM_VALUES = "Allowed enum values for the property"; + public static final String FIELD_PROPERTY_RULES_REGEX = "Regular expression for property validation"; + public static final String FIELD_PROPERTY_RULES_MAX_LENGTH = "Maximum length of the property"; + public static final String FIELD_PROPERTY_RULES_MIN_LENGTH = "Minimum length of the property"; + public static final String FIELD_PROPERTY_RULES_MAX_VALUE = "Maximum value for the property"; + public static final String FIELD_PROPERTY_RULES_MIN_VALUE = "Minimum value for the property"; + public static final String FIELD_CREATED_AT = "Creation timestamp"; + public static final String FIELD_UPDATED_AT = "Last update timestamp"; + + public static final String FIELD_RELATION_ID = "Unique identifier of the relation definition"; + public static final String FIELD_RELATION_NAME = "Name of the relation"; + public static final String FIELD_RELATION_TARGET_IDENTIFIER = "Identifier of the target template"; + public static final String FIELD_RELATION_REQUIRED = "Whether this relation is required"; + public static final String FIELD_RELATION_TO_MANY = "Whether this relation can have multiple targets"; + + // --- Pagination and sorting parameter descriptions --- + public static final String PARAM_PAGE_DESCRIPTION = "Page number for pagination. Defaults to 0."; + public static final String PARAM_SIZE_DESCRIPTION = "Number of items per page. Defaults to 20."; + public static final String PARAM_SORT_DESCRIPTION = "Sorting criteria in the format: property(,asc|desc). Defaults to identifier,asc."; + public static final String PARAM_QUERY_DESCRIPTION = """ + Optional filter query using a simple expression language. See more details in the API documentation. Example: `name:idp` for entities with names containing 'idp'. + """; + public static final String RESPONSE_INVALID_QUERY = "Invalid filter query syntax"; + + // --- Entity Graph (flat nodes & edges) descriptions --- + public static final String PARAM_DEPTH_DESCRIPTION = "Maximum traversal depth for relationship resolution. Clamped between 1 and 10."; + public static final String ENDPOINT_GET_ENTITY_GRAPH_FLAT_SUMMARY = "Get entity relationship graph as flat nodes and edges"; + public static final String ENDPOINT_GET_ENTITY_GRAPH_FLAT_DESCRIPTION = "Retrieves the entity relationship graph as a flat nodes-and-edges structure, suitable for frontend visualization tools such as React Flow, Vis.js, and Cytoscape."; + public static final String RESPONSE_ENTITY_GRAPH_FLAT_SUCCESS = "Flat entity graph successfully retrieved"; + public static final String ENTITY_GRAPH_FLAT_NODES_DESCRIPTION = "All entity nodes in the graph"; + public static final String ENTITY_GRAPH_FLAT_EDGES_DESCRIPTION = "All directed relation edges in the graph"; + public static final String ENTITY_GRAPH_FLAT_NODE_ID_DESCRIPTION = "Unique node identifier composed of templateIdentifier:identifier"; + public static final String ENTITY_GRAPH_FLAT_NODE_LABEL_DESCRIPTION = "Human-readable entity name"; + public static final String ENTITY_GRAPH_FLAT_NODE_TEMPLATE_DESCRIPTION = "Template identifier this entity belongs to"; + public static final String ENTITY_GRAPH_FLAT_NODE_IDENTIFIER_DESCRIPTION = "Business identifier of the entity within its template"; + public static final String ENTITY_GRAPH_FLAT_EDGE_ID_DESCRIPTION = "Unique edge identifier"; + public static final String ENTITY_GRAPH_FLAT_EDGE_SOURCE_DESCRIPTION = "Node id of the source entity"; + public static final String ENTITY_GRAPH_FLAT_EDGE_TARGET_DESCRIPTION = "Node id of the target entity"; + public static final String ENTITY_GRAPH_FLAT_EDGE_TYPE_DESCRIPTION = "Relation name as defined in the entity template"; + public static final String ENTITY_GRAPH_FLAT_NODE_DATA_DESCRIPTION = "Entity property values keyed by property name; present only when include_data=true is requested"; + public static final String PARAM_INCLUDE_DATA_DESCRIPTION = "When true, each graph node includes a data object containing the entity's property values. Defaults to false."; + public static final String PARAM_RELATIONS_DESCRIPTION = "When provided, only relations whose name matches one of the listed values are traversed and included. Omit to include all relations."; + public static final String PARAM_PROPERTIES_DESCRIPTION = "When provided, each node's data object is restricted to the listed property names. Requires include_data=true to have any effect. Omit to include all properties."; } 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 7d593628..f46ee69a 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 @@ -36,7 +36,9 @@ import static org.springframework.http.HttpStatus.CREATED; import static org.springframework.http.HttpStatus.OK; -import lombok.RequiredArgsConstructor; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; + import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -44,12 +46,12 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.bind.annotation.PutMapping; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntityFilter; @@ -63,6 +65,7 @@ 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 io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.enums.ParameterIn; @@ -70,8 +73,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; /// REST API adapter providing entity management endpoints. /// @@ -88,120 +90,143 @@ @RequiredArgsConstructor public class EntityController { - private final EntityService entityService; - private final EntityDtoOutMapper entityDtoOutMapper; - private final EntityDtoInMapper entityDtoInMapper; - private final EntityQueryParserService entityQueryParserService; + private final EntityService entityService; + private final EntityDtoOutMapper entityDtoOutMapper; + private final EntityDtoInMapper entityDtoInMapper; + private final EntityQueryParserService entityQueryParserService; - /// Returns paginated entities filtered by template with HTTP pagination support. - /// - /// **API contract:** Provides paginated entity listings for template-specific views. - /// Supports standard REST pagination parameters and an optional `q` filter query. - /// Template validation is handled by the domain service layer. - /// - /// @param page zero-based page index for pagination navigation - /// @param size number of entities per page for response size control - /// @param templateIdentifier template filter for entity scope limitation - /// @param q optional filter query string (e.g. `name:API;property.language=JAVA`) - /// @return paginated entity DTOs matching the template and optional filter - @Operation(summary = ENDPOINT_GET_ENTITIES_SUMMARY, description = ENDPOINT_GET_ENTITIES_PAGINATED_DESCRIPTION) - @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITIES_PAGINATED_SUCCESS, content = @Content(schema = @Schema(implementation = EntityPageResponse.class))) - @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_PAGINATION, content = { - @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) - @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_QUERY, content = { - @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) - @Parameter(name = "page", description = PARAM_PAGE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "0"))) - @Parameter(name = "size", description = PARAM_SIZE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "20"))) - @Parameter(name = "sort", description = PARAM_SORT_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string", defaultValue = "identifier,asc"))) - @Parameter(name = "q", description = PARAM_QUERY_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string"))) - @ResponseStatus(OK) - @GetMapping("/{templateIdentifier}") - public Page getEntities( - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size, - @PathVariable String templateIdentifier, - @RequestParam(required = false) String q) { - Pageable pageable = PageRequest.of(page, size); - EntityFilter filter = entityQueryParserService.parse(q); - Page entities = entityService.getEntitiesByTemplateIdentifier(pageable, templateIdentifier, filter); - return entityDtoOutMapper.fromEntitiesPageToDtoPage(entities, templateIdentifier); - } + /// Returns paginated entities filtered by template with HTTP pagination + /// support. + /// + /// **API contract:** Provides paginated entity listings for template-specific + /// views. + /// Supports standard REST pagination parameters and an optional `q` filter + /// query. + /// Template validation is handled by the domain service layer. + /// + /// @param page zero-based page index for pagination navigation + /// @param size number of entities per page for response size control + /// @param templateIdentifier template filter for entity scope limitation + /// @param q optional filter query string (e.g. + /// `name:API;property.language=JAVA`) + /// @return paginated entity DTOs matching the template and optional filter + @Operation(summary = ENDPOINT_GET_ENTITIES_SUMMARY, description = ENDPOINT_GET_ENTITIES_PAGINATED_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITIES_PAGINATED_SUCCESS, content = @Content(schema = @Schema(implementation = EntityPageResponse.class))) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_PAGINATION, content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_QUERY, content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @Parameter(name = "page", description = PARAM_PAGE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "0"))) + @Parameter(name = "size", description = PARAM_SIZE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "20"))) + @Parameter(name = "sort", description = PARAM_SORT_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string", defaultValue = "identifier,asc"))) + @Parameter(name = "q", description = PARAM_QUERY_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string"))) + @ResponseStatus(OK) + @GetMapping("/{templateIdentifier}") + public Page getEntities(@RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, @PathVariable String templateIdentifier, + @RequestParam(required = false) String q) { + Pageable pageable = PageRequest.of(page, size); + EntityFilter filter = entityQueryParserService.parse(q); + Page entities = entityService.getEntitiesByTemplateIdentifier(pageable, + templateIdentifier, filter); + return entityDtoOutMapper.fromEntitiesPageToDtoPage(entities, templateIdentifier); + } - /// Retrieves a single entity by template and entity identifiers. - /// - /// **API contract:** Provides specific entity lookup using compound identifier pattern. - /// Returns HTTP 404 if either template or entity doesn't exist, maintaining REST semantics. - /// - /// @param templateIdentifier business template identifier for entity scope - /// @param entityIdentifier unique business identifier within template context - /// @return entity DTO with full property and relationship data - @Operation(summary = ENDPOINT_GET_ENTITY_BY_IDENTIFIER_SUMMARY, description = ENDPOINT_GET_ENTITY_BY_IDENTIFIER_DESCRIPTION) - @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_FOUND, content = { - @Content(schema = @Schema(implementation = EntityDtoOut.class))}) - @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = { - @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) - @GetMapping("/{templateIdentifier}/{entityIdentifier}") - @ResponseStatus(OK) - public EntityDtoOut getEntity( - @PathVariable String templateIdentifier, - @PathVariable String entityIdentifier) { - Entity entity = entityService.getEntityByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier); - return entityDtoOutMapper.fromEntity(entity); - } + /// Retrieves a single entity by template and entity identifiers. + /// + /// **API contract:** Provides specific entity lookup using compound identifier + /// pattern. + /// Returns HTTP 404 if either template or entity doesn't exist, maintaining + /// REST semantics. + /// + /// @param templateIdentifier business template identifier for entity scope + /// @param entityIdentifier unique business identifier within template context + /// @return entity DTO with full property and relationship data + @Operation(summary = ENDPOINT_GET_ENTITY_BY_IDENTIFIER_SUMMARY, description = ENDPOINT_GET_ENTITY_BY_IDENTIFIER_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_FOUND, content = { + @Content(schema = @Schema(implementation = EntityDtoOut.class))}) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @GetMapping("/{templateIdentifier}/{entityIdentifier}") + @ResponseStatus(OK) + public EntityDtoOut getEntity(@PathVariable String templateIdentifier, + @PathVariable String entityIdentifier) { + Entity entity = entityService.getEntityByTemplateIdentifierAndIdentifier(templateIdentifier, + entityIdentifier); + return entityDtoOutMapper.fromEntity(entity); + } - /// Creates a new entity for the specified template with validation. - /// - /// **API contract:** Accepts entity creation payload and returns created entity with - /// generated identifiers. Validates entity structure against template constraints - /// and returns HTTP 201 on success, HTTP 400 for validation errors. - /// - /// @param templateIdentifier target template identifier for entity creation context - /// @param entityCreateDtoIn entity creation payload with properties and relationships - /// @return created entity DTO with server-generated identifiers - @Operation(summary = ENDPOINT_POST_ENTITY_SUMMARY, description = ENDPOINT_POST_ENTITY_DESCRIPTION) - @ApiResponse(responseCode = CREATED_CODE, description = RESPONSE_ENTITY_CREATED, content = {@Content(schema = @Schema(implementation = EntityDtoOut.class))}) - @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_ENTITY_DATA, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) - @ApiResponse(responseCode = UNAUTHORIZED_CODE, description = RESPONSE_UNAUTHORIZED, content = @Content) - @ApiResponse(responseCode = FORBIDDEN_CODE, description = RESPONSE_INSUFFICIENT_RIGHTS, content = @Content) - @ApiResponse(responseCode = CONFLICT_CODE, description = RESPONSE_ENTITY_CONFLICT, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) - @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) - @ApiResponse(responseCode = INTERNAL_SERVER_ERROR_CODE, description = RESPONSE_UNEXPECTED_SERVER_ERROR, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) - @PostMapping("/{templateIdentifier}") - @ResponseStatus(CREATED) - public EntityDtoOut createEntity( - @NotBlank @PathVariable String templateIdentifier, - @Valid @RequestBody EntityCreateDtoIn entityCreateDtoIn) { + /// Creates a new entity for the specified template with validation. + /// + /// **API contract:** Accepts entity creation payload and returns created entity + /// with + /// generated identifiers. Validates entity structure against template + /// constraints + /// and returns HTTP 201 on success, HTTP 400 for validation errors. + /// + /// @param templateIdentifier target template identifier for entity creation + /// context + /// @param entityCreateDtoIn entity creation payload with properties and + /// relationships + /// @return created entity DTO with server-generated identifiers + @Operation(summary = ENDPOINT_POST_ENTITY_SUMMARY, description = ENDPOINT_POST_ENTITY_DESCRIPTION) + @ApiResponse(responseCode = CREATED_CODE, description = RESPONSE_ENTITY_CREATED, content = { + @Content(schema = @Schema(implementation = EntityDtoOut.class))}) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_ENTITY_DATA, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = UNAUTHORIZED_CODE, description = RESPONSE_UNAUTHORIZED, content = @Content) + @ApiResponse(responseCode = FORBIDDEN_CODE, description = RESPONSE_INSUFFICIENT_RIGHTS, content = @Content) + @ApiResponse(responseCode = CONFLICT_CODE, description = RESPONSE_ENTITY_CONFLICT, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = INTERNAL_SERVER_ERROR_CODE, description = RESPONSE_UNEXPECTED_SERVER_ERROR, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @PostMapping("/{templateIdentifier}") + @ResponseStatus(CREATED) + public EntityDtoOut createEntity(@NotBlank @PathVariable String templateIdentifier, + @Valid @RequestBody EntityCreateDtoIn entityCreateDtoIn) { - Entity entity = entityDtoInMapper.fromPostEntityDtoInToEntity(entityCreateDtoIn, templateIdentifier); - Entity savedEntity = entityService.createEntity(entity); - return entityDtoOutMapper.fromEntity(savedEntity); - } + Entity entity = entityDtoInMapper.fromPostEntityDtoInToEntity(entityCreateDtoIn, + templateIdentifier); + Entity savedEntity = entityService.createEntity(entity); + return entityDtoOutMapper.fromEntity(savedEntity); + } - /// Updates an existing entity for the specified template. - /// - /// **API contract:** Accepts entity update payload and returns updated entity. Validates - /// that the entity exists and that the update payload conforms to template constraints. Returns HTTP 200 on success, HTTP 400 for validation errors, HTTP 404 if entity doesn't exist. - /// - /// @param templateIdentifier target template identifier for entity update context - /// @param entityIdentifier unique business identifier of the entity to update - /// @param entityUpdateDtoIn entity update payload with properties and relationships to apply - /// @return updated entity DTO reflecting persisted changes - @Operation(summary = ENDPOINT_PUT_ENTITY_SUMMARY, description = ENDPOINT_PUT_ENTITY_DESCRIPTION) - @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_UPDATED, content = {@Content(schema = @Schema(implementation = EntityDtoOut.class))}) - @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_ENTITY_DATA, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) - @ApiResponse(responseCode = UNAUTHORIZED_CODE, description = RESPONSE_UNAUTHORIZED, content = @Content) - @ApiResponse(responseCode = FORBIDDEN_CODE, description = RESPONSE_INSUFFICIENT_RIGHTS, content = @Content) - @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) - @ApiResponse(responseCode = INTERNAL_SERVER_ERROR_CODE, description = RESPONSE_UNEXPECTED_SERVER_ERROR, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) - @PutMapping("/{templateIdentifier}/{entityIdentifier}") - @ResponseStatus(OK) - public EntityDtoOut updateEntity( - @NotBlank @PathVariable String templateIdentifier, - @NotBlank @PathVariable String entityIdentifier, - @Valid @RequestBody EntityUpdateDtoIn entityUpdateDtoIn) { + /// Updates an existing entity for the specified template. + /// + /// **API contract:** Accepts entity update payload and returns updated entity. + /// Validates + /// that the entity exists and that the update payload conforms to template + /// constraints. Returns HTTP 200 on success, HTTP 400 for validation errors, + /// HTTP 404 if entity doesn't exist. + /// + /// @param templateIdentifier target template identifier for entity update + /// context + /// @param entityIdentifier unique business identifier of the entity to update + /// @param entityUpdateDtoIn entity update payload with properties and + /// relationships to apply + /// @return updated entity DTO reflecting persisted changes + @Operation(summary = ENDPOINT_PUT_ENTITY_SUMMARY, description = ENDPOINT_PUT_ENTITY_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_UPDATED, content = { + @Content(schema = @Schema(implementation = EntityDtoOut.class))}) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_ENTITY_DATA, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = UNAUTHORIZED_CODE, description = RESPONSE_UNAUTHORIZED, content = @Content) + @ApiResponse(responseCode = FORBIDDEN_CODE, description = RESPONSE_INSUFFICIENT_RIGHTS, content = @Content) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = INTERNAL_SERVER_ERROR_CODE, description = RESPONSE_UNEXPECTED_SERVER_ERROR, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @PutMapping("/{templateIdentifier}/{entityIdentifier}") + @ResponseStatus(OK) + public EntityDtoOut updateEntity(@NotBlank @PathVariable String templateIdentifier, + @NotBlank @PathVariable String entityIdentifier, + @Valid @RequestBody EntityUpdateDtoIn entityUpdateDtoIn) { - Entity entity = entityDtoInMapper.fromPutEntityDtoInToEntity(entityUpdateDtoIn, templateIdentifier, entityIdentifier); - Entity updatedEntity = entityService.updateEntity(templateIdentifier, entityIdentifier, entity); - return entityDtoOutMapper.fromEntity(updatedEntity); - } + Entity entity = entityDtoInMapper.fromPutEntityDtoInToEntity(entityUpdateDtoIn, + templateIdentifier, entityIdentifier); + Entity updatedEntity = entityService.updateEntity(templateIdentifier, entityIdentifier, entity); + return entityDtoOutMapper.fromEntity(updatedEntity); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityCreateDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityCreateDtoIn.java index 12f9294c..7215513f 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityCreateDtoIn.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityCreateDtoIn.java @@ -4,17 +4,18 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FIELD_ENTITY_IDENTIFIER; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.SCHEMA_ENTITY_CREATE_IN; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; import com.fasterxml.jackson.annotation.JsonUnwrapped; import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; + import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; /// Input DTO for creating a new entity within a template scope. /// @@ -29,11 +30,11 @@ @Schema(description = SCHEMA_ENTITY_CREATE_IN) public class EntityCreateDtoIn { - @NotBlank(message = ENTITY_IDENTIFIER_MANDATORY) - @Schema(description = FIELD_ENTITY_IDENTIFIER, example = "my-web-service") - private String identifier; + @NotBlank(message = ENTITY_IDENTIFIER_MANDATORY) + @Schema(description = FIELD_ENTITY_IDENTIFIER, example = "my-web-service") + private String identifier; - @Valid - @JsonUnwrapped - private EntityDtoInCommonFields entityDtoInCommonFields; + @Valid + @JsonUnwrapped + private EntityDtoInCommonFields entityDtoInCommonFields; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoInCommonFields.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoInCommonFields.java index 7d68f363..61031e3b 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoInCommonFields.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityDtoInCommonFields.java @@ -14,17 +14,18 @@ import java.util.List; import java.util.Map; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; + import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; /// Input DTO for common fields of an entity creation or update request. /// @@ -39,35 +40,35 @@ @Schema(description = SCHEMA_ENTITY_IN) public class EntityDtoInCommonFields { - @NotBlank(message = ENTITY_NAME_MANDATORY) - @Schema(description = FIELD_ENTITY_NAME, example = "my-web-service") - private String name; + @NotBlank(message = ENTITY_NAME_MANDATORY) + @Schema(description = FIELD_ENTITY_NAME, example = "my-web-service") + private String name; - @Schema(description = FIELD_ENTITY_PROPERTIES, example = "{\"port\": \"8080\", \"environment\": \"dev\"}") - private Map properties; + @Schema(description = FIELD_ENTITY_PROPERTIES, example = "{\"port\": \"8080\", \"environment\": \"dev\"}") + private Map properties; - @Valid - @Schema(description = FIELD_ENTITY_RELATIONS) - private List relations; + @Valid + @Schema(description = FIELD_ENTITY_RELATIONS) + private List relations; - /// Input DTO for an entity relation instance. - /// - /// **Infrastructure validation:** Validates relation name presence and target - /// identifiers at the API boundary before domain-level schema checks. - @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - @JsonNaming(SnakeCaseStrategy.class) - @Schema(description = SCHEMA_ENTITY_RELATION_IN) - public static class RelationDtoIn { + /// Input DTO for an entity relation instance. + /// + /// **Infrastructure validation:** Validates relation name presence and target + /// identifiers at the API boundary before domain-level schema checks. + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonNaming(SnakeCaseStrategy.class) + @Schema(description = SCHEMA_ENTITY_RELATION_IN) + public static class RelationDtoIn { - @NotBlank(message = RELATION_NAME_MANDATORY_SIMPLE) - @Schema(description = FIELD_ENTITY_RELATION_NAME, example = "depends-on") - private String name; + @NotBlank(message = RELATION_NAME_MANDATORY_SIMPLE) + @Schema(description = FIELD_ENTITY_RELATION_NAME, example = "depends-on") + private String name; - @NotNull(message = RELATION_TARGET_IDENTIFIERS_NOT_NULL) - @Schema(description = FIELD_ENTITY_RELATION_TARGETS, example = "[\"web-api-1\", \"web-api-2\"]") - private List targetEntityIdentifiers; - } + @NotNull(message = RELATION_TARGET_IDENTIFIERS_NOT_NULL) + @Schema(description = FIELD_ENTITY_RELATION_TARGETS, example = "[\"web-api-1\", \"web-api-2\"]") + private List targetEntityIdentifiers; + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityUpdateDtoIn.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityUpdateDtoIn.java index dc259e37..54b973bf 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityUpdateDtoIn.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/in/EntityUpdateDtoIn.java @@ -2,16 +2,17 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.SCHEMA_ENTITY_UPDATE_IN; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; +import jakarta.validation.Valid; import com.fasterxml.jackson.annotation.JsonUnwrapped; import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; + import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.Valid; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; /// Input DTO for updating an entity within a template scope. /// @@ -26,8 +27,8 @@ @Schema(description = SCHEMA_ENTITY_UPDATE_IN) public class EntityUpdateDtoIn { - @Valid - @JsonUnwrapped - private EntityDtoInCommonFields entityDtoInCommonFields; + @Valid + @JsonUnwrapped + private EntityDtoInCommonFields entityDtoInCommonFields; } 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 64f5a48a..75929d6f 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 @@ -1,15 +1,15 @@ package com.decathlon.idp_core.infrastructure.adapters.api.handler; +import static org.springframework.http.HttpStatus.NOT_FOUND; + import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; -import com.decathlon.idp_core.domain.exception.InvalidQueryDslException; -import com.decathlon.idp_core.domain.exception.entity_template.PropertyDefinitionRulesConflictException; -import com.decathlon.idp_core.domain.exception.entity_template.PropertyTypeChangeException; -import com.decathlon.idp_core.domain.exception.entity_template.RelationCannotTargetItselfException; -import com.decathlon.idp_core.domain.exception.entity_template.RelationTargetTemplateChangeException; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; + import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; @@ -18,6 +18,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.method.annotation.HandlerMethodValidationException; +import com.decathlon.idp_core.domain.exception.InvalidQueryDslException; import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; @@ -25,19 +26,19 @@ import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateIdentifierCannotChangeException; import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNameAlreadyExistsException; import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; +import com.decathlon.idp_core.domain.exception.entity_template.PropertyDefinitionRulesConflictException; import com.decathlon.idp_core.domain.exception.entity_template.PropertyNameAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity_template.PropertyTypeChangeException; +import com.decathlon.idp_core.domain.exception.entity_template.RelationCannotTargetItselfException; 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 jakarta.validation.ConstraintViolation; -import jakarta.validation.ConstraintViolationException; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.extern.slf4j.Slf4j; -import static org.springframework.http.HttpStatus.NOT_FOUND; - /// Global exception handler providing centralized error handling for all API endpoints. /// /// **Infrastructure error handling strategy:** Intercepts domain and validation exceptions @@ -56,331 +57,353 @@ @ControllerAdvice public class ApiExceptionHandler { - private ApiExceptionHandler() { - } - - /// Handles domain exception when entity templates are not found. - /// - /// **HTTP mapping:** Maps domain EntityTemplateNotFoundException to HTTP 404 status - /// with business-meaningful error message for API consumers. - @ExceptionHandler(EntityTemplateNotFoundException.class) - public ResponseEntity handleTemplateNotFoundException(EntityTemplateNotFoundException ex) { - log.warn("Template not found: {}", ex.getMessage()); - ErrorResponse errorResponse = new ErrorResponse(NOT_FOUND.name(), ex.getMessage()); - return ResponseEntity.status(NOT_FOUND).body(errorResponse); - } - - /// Handles domain exception for malformed filter query strings. - /// - /// **HTTP mapping:** Maps domain [InvalidQueryDslException] to HTTP 400 Bad Request - /// so API consumers receive clear feedback about invalid `q` parameter syntax. - @ExceptionHandler(InvalidQueryDslException.class) - public ResponseEntity handleInvalidQueryDslException(InvalidQueryDslException ex) { - log.warn("Invalid filter 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 409 - /// status indicating business rule conflict for duplicate identifiers. - @ExceptionHandler(EntityTemplateAlreadyExistsException.class) - public ResponseEntity handleEntityTemplateAlreadyExistsException( - EntityTemplateAlreadyExistsException ex) { - log.warn("Entity template already exists: {}", ex.getMessage()); - ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); - return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); - } - - /// Handles domain exception when entity template names already exist. - /// - /// **HTTP mapping:** Maps domain EntityTemplateNameAlreadyExistsException to HTTP 409 - /// status indicating business rule conflict for duplicate template names. - @ExceptionHandler(EntityTemplateNameAlreadyExistsException.class) - public ResponseEntity handleEntityTemplateNameAlreadyExistsException( - EntityTemplateNameAlreadyExistsException ex) { - log.warn("Entity template name already exists: {}", ex.getMessage()); - ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); - return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); - } - - /// Handles domain exception when attempting to change an entity template identifier. - /// - /// **HTTP mapping:** Maps domain EntityTemplateIdentifierCannotChangeException to HTTP 400 - /// status indicating validation error for immutable identifier field. - @ExceptionHandler(EntityTemplateIdentifierCannotChangeException.class) - public ResponseEntity handleEntityTemplateIdentifierCannotChangeException( - EntityTemplateIdentifierCannotChangeException ex) { - log.warn("Entity template identifier cannot be changed: {}", ex.getMessage()); - ErrorResponse errorResponse = new ErrorResponse(HttpStatus.BAD_REQUEST.name(), ex.getMessage()); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); - } - - /// Handles domain exception for wrong entity template property rules. - /// - /// **HTTP mapping:** Maps domain PropertyDefinitionRulesConflictException to HTTP 400 - /// status indicating validation error for wrong property rules. - @ExceptionHandler(PropertyDefinitionRulesConflictException.class) - public ResponseEntity handleWrongPropertyRulesException( - PropertyDefinitionRulesConflictException ex) { - log.warn("Wrong Entity template property rules: {}", ex.getMessage()); - ErrorResponse errorResponse = new ErrorResponse(HttpStatus.BAD_REQUEST.name(), ex.getMessage()); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); - } - - /// Handles domain exception when property names are duplicated within a template. - /// - /// **HTTP mapping:** Maps domain PropertyNameAlreadyExistsException to HTTP 400 - /// status indicating validation error for duplicate property names. - @ExceptionHandler(PropertyNameAlreadyExistsException.class) - public ResponseEntity handlePropertyNameAlreadyExistsException( - PropertyNameAlreadyExistsException ex) { - log.warn("Duplicate property name: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); - } - - /// Handles domain exception when relation names are duplicated within a template. - /// - /// **HTTP mapping:** Maps domain RelationNameAlreadyExistsException to HTTP 400 - /// status indicating validation error for duplicate relation names. - @ExceptionHandler(RelationNameAlreadyExistsException.class) - public ResponseEntity handleRelationNameAlreadyExistsException( - RelationNameAlreadyExistsException ex) { - log.warn("Duplicate relation name: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); - } - - /// Handles domain exception when a relation references a non-existent target template. - /// - /// **HTTP mapping:** Maps domain TargetTemplateNotFoundException to HTTP 400 - /// status indicating validation error for missing target template. - @ExceptionHandler(TargetTemplateNotFoundException.class) - public ResponseEntity handleTargetTemplateNotFoundException( - TargetTemplateNotFoundException ex) { - log.warn("Target template not found: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); - } - - /// Handles domain exception when type changes are attempted. - /// - /// **HTTP mapping:** Maps domain PropertyTypeChangeException to HTTP 400 - /// status indicating validation error for type changes. - @ExceptionHandler(PropertyTypeChangeException.class) - public ResponseEntity handleTypeChangeException( - PropertyTypeChangeException ex) { - log.warn("Type change error: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); - } - - /// Handles domain exception when relation target template changes are attempted. - /// - /// **HTTP mapping:** Maps domain RelationTargetTemplateChangeException to HTTP 400 - /// status indicating validation error for immutable target template field. - @ExceptionHandler(RelationTargetTemplateChangeException.class) - public ResponseEntity handleRelationTargetTemplateChangeException( - RelationTargetTemplateChangeException ex) { - log.warn("Relation target template change error: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); - } - - /// Handles domain exception when a relation's target template identifier is the template itself. - /// - /// **HTTP mapping:** Maps domain RelationCannotTargetItselfException to HTTP 400 - /// status indicating validation error for self-referential relations. - @ExceptionHandler(RelationCannotTargetItselfException.class) - public ResponseEntity handleRelationCannotTargetItselfException( - RelationCannotTargetItselfException ex) { - log.warn("Relation self-reference error: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); - } - - /// Handles validation exceptions from Spring MVC handler method parameters. - /// - /// **Error aggregation:** Combines multiple validation error messages into a single - /// user-friendly response with HTTP 400 status for client correction. - @ExceptionHandler(HandlerMethodValidationException.class) - public ResponseEntity handleHandlerMethodValidationException(HandlerMethodValidationException ex) { - log.warn("Handler method validation error: {}", ex.getMessage()); - String errorMessage = ex.getAllErrors().stream() - .map(org.springframework.context.MessageSourceResolvable::getDefaultMessage) - .collect(Collectors.joining(", ")); - return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage); - } - - /// Handles domain exception when entities already exist. - /// - /// **HTTP mapping:** Maps domain EntityAlreadyExistsException to HTTP 409 - /// status indicating business rule conflict for duplicate entities. - @ExceptionHandler(EntityAlreadyExistsException.class) - public ResponseEntity handleEntityAlreadyExistsException(EntityAlreadyExistsException ex) { - log.warn("Entity already exists: {}", ex.getMessage()); - ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); - return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); - } - - /// Handles domain exception when entity validation fails. - /// - /// **HTTP mapping:** Maps domain EntityValidationException to HTTP 400 status with aggregated - /// validation error messages for client correction. - @ExceptionHandler(EntityValidationException.class) - public ResponseEntity handleEntityValidationException(EntityValidationException ex) { - log.warn("Entity validation failed: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); - } - - /// Handles Bean Validation constraint violations from domain model validation. - /// - /// **Error aggregation:** Combines multiple constraint violation messages into - /// single user-friendly response with HTTP 400 status for client correction. - @ExceptionHandler(ConstraintViolationException.class) - public ResponseEntity handleConstraintViolationException(ConstraintViolationException ex) { - log.warn("Validation constraint violation: {}", ex.getMessage()); - - String errorMessage = ex.getConstraintViolations().stream() - .map(ConstraintViolation::getMessage) - .collect(Collectors.joining(", ")); - return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage); + private ApiExceptionHandler() { + } + + /// Handles domain exception when entity templates are not found. + /// + /// **HTTP mapping:** Maps domain EntityTemplateNotFoundException to HTTP 404 + /// status + /// with business-meaningful error message for API consumers. + @ExceptionHandler(EntityTemplateNotFoundException.class) + public ResponseEntity handleTemplateNotFoundException( + EntityTemplateNotFoundException ex) { + log.warn("Template not found: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(NOT_FOUND.name(), ex.getMessage()); + return ResponseEntity.status(NOT_FOUND).body(errorResponse); + } + + /// Handles domain exception for malformed filter query strings. + /// + /// **HTTP mapping:** Maps domain [InvalidQueryDslException] to HTTP 400 Bad + /// Request + /// so API consumers receive clear feedback about invalid `q` parameter syntax. + @ExceptionHandler(InvalidQueryDslException.class) + public ResponseEntity handleInvalidQueryDslException(InvalidQueryDslException ex) { + log.warn("Invalid filter 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 + /// 409 + /// status indicating business rule conflict for duplicate identifiers. + @ExceptionHandler(EntityTemplateAlreadyExistsException.class) + public ResponseEntity handleEntityTemplateAlreadyExistsException( + EntityTemplateAlreadyExistsException ex) { + log.warn("Entity template already exists: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + } + + /// Handles domain exception when entity template names already exist. + /// + /// **HTTP mapping:** Maps domain EntityTemplateNameAlreadyExistsException to + /// HTTP 409 + /// status indicating business rule conflict for duplicate template names. + @ExceptionHandler(EntityTemplateNameAlreadyExistsException.class) + public ResponseEntity handleEntityTemplateNameAlreadyExistsException( + EntityTemplateNameAlreadyExistsException ex) { + log.warn("Entity template name already exists: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + } + + /// Handles domain exception when attempting to change an entity template + /// identifier. + /// + /// **HTTP mapping:** Maps domain EntityTemplateIdentifierCannotChangeException + /// to HTTP 400 + /// status indicating validation error for immutable identifier field. + @ExceptionHandler(EntityTemplateIdentifierCannotChangeException.class) + public ResponseEntity handleEntityTemplateIdentifierCannotChangeException( + EntityTemplateIdentifierCannotChangeException ex) { + log.warn("Entity template identifier cannot be changed: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.BAD_REQUEST.name(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + } + + /// Handles domain exception for wrong entity template property rules. + /// + /// **HTTP mapping:** Maps domain PropertyDefinitionRulesConflictException to + /// HTTP 400 + /// status indicating validation error for wrong property rules. + @ExceptionHandler(PropertyDefinitionRulesConflictException.class) + public ResponseEntity handleWrongPropertyRulesException( + PropertyDefinitionRulesConflictException ex) { + log.warn("Wrong Entity template property rules: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.BAD_REQUEST.name(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + } + + /// Handles domain exception when property names are duplicated within a + /// template. + /// + /// **HTTP mapping:** Maps domain PropertyNameAlreadyExistsException to HTTP 400 + /// status indicating validation error for duplicate property names. + @ExceptionHandler(PropertyNameAlreadyExistsException.class) + public ResponseEntity handlePropertyNameAlreadyExistsException( + PropertyNameAlreadyExistsException ex) { + log.warn("Duplicate property name: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + /// Handles domain exception when relation names are duplicated within a + /// template. + /// + /// **HTTP mapping:** Maps domain RelationNameAlreadyExistsException to HTTP 400 + /// status indicating validation error for duplicate relation names. + @ExceptionHandler(RelationNameAlreadyExistsException.class) + public ResponseEntity handleRelationNameAlreadyExistsException( + RelationNameAlreadyExistsException ex) { + log.warn("Duplicate relation name: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + /// Handles domain exception when a relation references a non-existent target + /// template. + /// + /// **HTTP mapping:** Maps domain TargetTemplateNotFoundException to HTTP 400 + /// status indicating validation error for missing target template. + @ExceptionHandler(TargetTemplateNotFoundException.class) + public ResponseEntity handleTargetTemplateNotFoundException( + TargetTemplateNotFoundException ex) { + log.warn("Target template not found: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + /// Handles domain exception when type changes are attempted. + /// + /// **HTTP mapping:** Maps domain PropertyTypeChangeException to HTTP 400 + /// status indicating validation error for type changes. + @ExceptionHandler(PropertyTypeChangeException.class) + public ResponseEntity handleTypeChangeException(PropertyTypeChangeException ex) { + log.warn("Type change error: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + /// Handles domain exception when relation target template changes are + /// attempted. + /// + /// **HTTP mapping:** Maps domain RelationTargetTemplateChangeException to HTTP + /// 400 + /// status indicating validation error for immutable target template field. + @ExceptionHandler(RelationTargetTemplateChangeException.class) + public ResponseEntity handleRelationTargetTemplateChangeException( + RelationTargetTemplateChangeException ex) { + log.warn("Relation target template change error: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + /// Handles domain exception when a relation's target template identifier is the + /// template itself. + /// + /// **HTTP mapping:** Maps domain RelationCannotTargetItselfException to HTTP + /// 400 + /// status indicating validation error for self-referential relations. + @ExceptionHandler(RelationCannotTargetItselfException.class) + public ResponseEntity handleRelationCannotTargetItselfException( + RelationCannotTargetItselfException ex) { + log.warn("Relation self-reference error: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + /// Handles validation exceptions from Spring MVC handler method parameters. + /// + /// **Error aggregation:** Combines multiple validation error messages into a + /// single + /// user-friendly response with HTTP 400 status for client correction. + @ExceptionHandler(HandlerMethodValidationException.class) + public ResponseEntity handleHandlerMethodValidationException( + HandlerMethodValidationException ex) { + log.warn("Handler method validation error: {}", ex.getMessage()); + String errorMessage = ex.getAllErrors().stream() + .map(org.springframework.context.MessageSourceResolvable::getDefaultMessage) + .collect(Collectors.joining(", ")); + return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage); + } + + /// Handles domain exception when entities already exist. + /// + /// **HTTP mapping:** Maps domain EntityAlreadyExistsException to HTTP 409 + /// status indicating business rule conflict for duplicate entities. + @ExceptionHandler(EntityAlreadyExistsException.class) + public ResponseEntity handleEntityAlreadyExistsException( + EntityAlreadyExistsException ex) { + log.warn("Entity already exists: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + } + + /// Handles domain exception when entity validation fails. + /// + /// **HTTP mapping:** Maps domain EntityValidationException to HTTP 400 status + /// with aggregated + /// validation error messages for client correction. + @ExceptionHandler(EntityValidationException.class) + public ResponseEntity handleEntityValidationException( + EntityValidationException ex) { + log.warn("Entity validation failed: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + /// Handles Bean Validation constraint violations from domain model validation. + /// + /// **Error aggregation:** Combines multiple constraint violation messages into + /// single user-friendly response with HTTP 400 status for client correction. + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleConstraintViolationException( + ConstraintViolationException ex) { + log.warn("Validation constraint violation: {}", ex.getMessage()); + + String errorMessage = ex.getConstraintViolations().stream().map(ConstraintViolation::getMessage) + .collect(Collectors.joining(", ")); + return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage); + } + + /// Handles Spring MVC request body validation failures. + /// + /// **Field-level errors:** Extracts and aggregates field validation errors from + /// request body binding into comprehensive HTTP 400 error response. + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException( + MethodArgumentNotValidException ex) { + log.warn("Method argument validation error: {}", ex.getMessage()); + + String errorMessage = ex.getBindingResult().getFieldErrors().stream() + .map(org.springframework.context.MessageSourceResolvable::getDefaultMessage) + .collect(Collectors.joining(", ")); + + return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage); + } + + /// Handles JSON parsing and deserialization errors from request bodies. + /// + /// **User-friendly parsing:** Converts technical JSON parsing errors into + /// readable messages, especially for enum validation and format issues. + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleHttpMessageNotReadableException( + HttpMessageNotReadableException ex) { + log.warn("HTTP message not readable: {}", ex.getMessage()); + + String errorMessage = parseHttpMessageNotReadableError(ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage); + } + + /// Handles domain exception when entities are not found. + /// + /// **HTTP mapping:** Maps domain EntityNotFoundException to HTTP 404 status + /// with specific entity context for API consumers. + @ExceptionHandler(EntityNotFoundException.class) + public ResponseEntity handleEntityNotFoundException(EntityNotFoundException ex) { + ErrorResponse errorResponse = new ErrorResponse(NOT_FOUND.name(), ex.getMessage()); + return ResponseEntity.status(NOT_FOUND).body(errorResponse); + } + private String parseHttpMessageNotReadableError(String originalMessage) { + if (originalMessage == null) { + return "Invalid request body format"; } - /// Handles Spring MVC request body validation failures. - /// - /// **Field-level errors:** Extracts and aggregates field validation errors from - /// request body binding into comprehensive HTTP 400 error response. - @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) { - log.warn("Method argument validation error: {}", ex.getMessage()); - - String errorMessage = ex.getBindingResult().getFieldErrors().stream() - .map(org.springframework.context.MessageSourceResolvable::getDefaultMessage) - .collect(Collectors.joining(", ")); - - return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage); + if (originalMessage.contains("Cannot deserialize value")) { + return parseDeserializationError(originalMessage); + } else if (originalMessage.contains("Required request body is missing")) { + return "Request body is required"; + } else if (originalMessage.contains("JSON parse error")) { + return "Invalid JSON format in request body"; } - /// Handles JSON parsing and deserialization errors from request bodies. - /// - /// **User-friendly parsing:** Converts technical JSON parsing errors into - /// readable messages, especially for enum validation and format issues. - @ExceptionHandler(HttpMessageNotReadableException.class) - public ResponseEntity handleHttpMessageNotReadableException(HttpMessageNotReadableException ex) { - log.warn("HTTP message not readable: {}", ex.getMessage()); + return "Invalid request body format"; + } - String errorMessage = parseHttpMessageNotReadableError(ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage); + private String parseDeserializationError(String originalMessage) { + if (originalMessage.contains("not one of the values accepted for Enum class")) { + return parseEnumDeserializationError(originalMessage); } + return parseTypeDeserializationError(originalMessage); + } + private String parseTypeDeserializationError(String originalMessage) { + String targetType = extractTargetType(originalMessage); + String invalidValue = extractInvalidValueFromString(originalMessage); - /// Handles domain exception when entities are not found. - /// - /// **HTTP mapping:** Maps domain EntityNotFoundException to HTTP 404 status - /// with specific entity context for API consumers. - @ExceptionHandler(EntityNotFoundException.class) - public ResponseEntity handleEntityNotFoundException(EntityNotFoundException ex) { - ErrorResponse errorResponse = new ErrorResponse(NOT_FOUND.name(), ex.getMessage()); - return ResponseEntity.status(NOT_FOUND).body(errorResponse); + if (!targetType.isEmpty() && !invalidValue.isEmpty()) { + return "Invalid value '" + invalidValue + "' for property, expected " + targetType; + } else if (!targetType.isEmpty()) { + return "Invalid type: expected " + targetType; } - private String parseHttpMessageNotReadableError(String originalMessage) { - if (originalMessage == null) { - return "Invalid request body format"; - } - - if (originalMessage.contains("Cannot deserialize value")) { - return parseDeserializationError(originalMessage); - } else if (originalMessage.contains("Required request body is missing")) { - return "Request body is required"; - } else if (originalMessage.contains("JSON parse error")) { - return "Invalid JSON format in request body"; - } - - return "Invalid request body format"; + return "Cannot deserialize request body property"; + } + + private String extractTargetType(String message) { + Pattern typePattern = Pattern.compile("Cannot deserialize value of type `([^`]+)`"); + Matcher matcher = typePattern.matcher(message); + if (matcher.find()) { + String fullType = matcher.group(1); + return fullType.substring(fullType.lastIndexOf('.') + 1); } - - private String parseDeserializationError(String originalMessage) { - if (originalMessage.contains("not one of the values accepted for Enum class")) { - return parseEnumDeserializationError(originalMessage); - } - return parseTypeDeserializationError(originalMessage); + return ""; + } + + private String extractInvalidValueFromString(String message) { + Pattern valuePattern = Pattern.compile("from String \"([^\"]+)\""); + Matcher matcher = valuePattern.matcher(message); + if (matcher.find()) { + return matcher.group(1); } + return ""; + } - private String parseTypeDeserializationError(String originalMessage) { - String targetType = extractTargetType(originalMessage); - String invalidValue = extractInvalidValueFromString(originalMessage); - - if (!targetType.isEmpty() && !invalidValue.isEmpty()) { - return "Invalid value '" + invalidValue + "' for property, expected " + targetType; - } else if (!targetType.isEmpty()) { - return "Invalid type: expected " + targetType; - } - return "Cannot deserialize request body property"; - } + private String parseEnumDeserializationError(String originalMessage) { + String enumTypeName = getPropertyNameFromEnumType(originalMessage); + String invalidValue = extractInvalidValueFromString(originalMessage); - private String extractTargetType(String message) { - Pattern typePattern = Pattern.compile("Cannot deserialize value of type `([^`]+)`"); - Matcher matcher = typePattern.matcher(message); - if (matcher.find()) { - String fullType = matcher.group(1); - return fullType.substring(fullType.lastIndexOf('.') + 1); - } - return ""; + if (!enumTypeName.isEmpty() && !invalidValue.isEmpty()) { + return "Invalid value '" + invalidValue + "' for property '" + enumTypeName + "'"; + } else if (!enumTypeName.isEmpty()) { + return "Invalid value for property '" + enumTypeName + "'"; } + return "Invalid enum value in request body"; + } - private String extractInvalidValueFromString(String message) { - Pattern valuePattern = Pattern.compile("from String \"([^\"]+)\""); - Matcher matcher = valuePattern.matcher(message); - if (matcher.find()) { - return matcher.group(1); - } - return ""; - } - - private String parseEnumDeserializationError(String originalMessage) { - String enumTypeName = getPropertyNameFromEnumType(originalMessage); - String invalidValue = extractInvalidValueFromString(originalMessage); - - if (!enumTypeName.isEmpty() && !invalidValue.isEmpty()) { - return "Invalid value '" + invalidValue + "' for property '" + enumTypeName + "'"; - } else if (!enumTypeName.isEmpty()) { - return "Invalid value for property '" + enumTypeName + "'"; - } - return "Invalid enum value in request body"; - } + private static final Map ENUM_TYPE_TO_PROPERTY = Map.of("PropertyType", "type", + "PropertyFormat", "format"); - private static final Map ENUM_TYPE_TO_PROPERTY = Map.of( - "PropertyType", "type", - "PropertyFormat", "format"); - - private static final Pattern ENUM_CLASS_PATTERN = Pattern.compile("Cannot deserialize value of type `(?:[\\w.]+\\.)?(\\w+)`"); - - private String getPropertyNameFromEnumType(String message) { - Matcher matcher = ENUM_CLASS_PATTERN.matcher(message); - if (matcher.find()) { - String enumType = matcher.group(1); - return ENUM_TYPE_TO_PROPERTY.getOrDefault(enumType, ""); - } - return ""; - } - - /// Handles all unexpected exceptions as safety fallback. - /// - /// **Security consideration:** Returns generic error message to prevent information - /// leakage while logging full exception details for internal debugging. - @ExceptionHandler(Exception.class) - public ResponseEntity handleGenericException(Exception ex) { - log.error("Unexpected error occurred: {}", ex.getMessage(), ex); - - String errorMessage = "An unexpected error occurred. Please try again later."; - return createErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR, errorMessage); - } - - private static ResponseEntity createErrorResponse(HttpStatus httpStatus, String errorMessage) { - return new ResponseEntity<>(new ErrorResponse(httpStatus.name(), errorMessage), httpStatus); - } + private static final Pattern ENUM_CLASS_PATTERN = Pattern + .compile("Cannot deserialize value of type `(?:[\\w.]+\\.)?(\\w+)`"); - @Getter - @AllArgsConstructor - @NoArgsConstructor(force = true) - public static class ErrorResponse { - private String error; - private String errorDescription; + private String getPropertyNameFromEnumType(String message) { + Matcher matcher = ENUM_CLASS_PATTERN.matcher(message); + if (matcher.find()) { + String enumType = matcher.group(1); + return ENUM_TYPE_TO_PROPERTY.getOrDefault(enumType, ""); } + return ""; + } + + /// Handles all unexpected exceptions as safety fallback. + /// + /// **Security consideration:** Returns generic error message to prevent + /// information + /// leakage while logging full exception details for internal debugging. + @ExceptionHandler(Exception.class) + public ResponseEntity handleGenericException(Exception ex) { + log.error("Unexpected error occurred: {}", ex.getMessage(), ex); + + String errorMessage = "An unexpected error occurred. Please try again later."; + return createErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR, errorMessage); + } + + private static ResponseEntity createErrorResponse(HttpStatus httpStatus, + String errorMessage) { + return new ResponseEntity<>(new ErrorResponse(httpStatus.name(), errorMessage), httpStatus); + } + + @Getter + @AllArgsConstructor + @NoArgsConstructor(force = true) + public static class ErrorResponse { + private String error; + private String errorDescription; + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java index 814a8df5..08e60592 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapper.java @@ -30,56 +30,43 @@ @Component public class EntityDtoInMapper { - /// Converts an entity creation request DTO to a domain entity. - /// - /// @param entityCreateDtoIn the entity creation request payload - /// @param entityTemplateIdentifier the target template identifier - /// @return the mapped domain entity with audit fields populated - public Entity fromPostEntityDtoInToEntity(EntityCreateDtoIn entityCreateDtoIn, String entityTemplateIdentifier) { - return buildEntity( - entityCreateDtoIn.getEntityDtoInCommonFields(), - entityTemplateIdentifier, - entityCreateDtoIn.getIdentifier() - ); - } + /// Converts an entity creation request DTO to a domain entity. + /// + /// @param entityCreateDtoIn the entity creation request payload + /// @param entityTemplateIdentifier the target template identifier + /// @return the mapped domain entity with audit fields populated + public Entity fromPostEntityDtoInToEntity(EntityCreateDtoIn entityCreateDtoIn, + String entityTemplateIdentifier) { + return buildEntity(entityCreateDtoIn.getEntityDtoInCommonFields(), entityTemplateIdentifier, + entityCreateDtoIn.getIdentifier()); + } - /// Converts an entity update request DTO to a domain entity. - /// - /// @param entityUpdateDtoIn the entity update request payload - /// @param entityTemplateIdentifier the target template identifier - /// @param entityIdentifier the target entity identifier from request path - /// @return the mapped domain entity with audit fields populated - public Entity fromPutEntityDtoInToEntity(EntityUpdateDtoIn entityUpdateDtoIn, - String entityTemplateIdentifier, - String entityIdentifier) { - return buildEntity( - entityUpdateDtoIn.getEntityDtoInCommonFields(), - entityTemplateIdentifier, - entityIdentifier - ); - } + /// Converts an entity update request DTO to a domain entity. + /// + /// @param entityUpdateDtoIn the entity update request payload + /// @param entityTemplateIdentifier the target template identifier + /// @param entityIdentifier the target entity identifier from request path + /// @return the mapped domain entity with audit fields populated + public Entity fromPutEntityDtoInToEntity(EntityUpdateDtoIn entityUpdateDtoIn, + String entityTemplateIdentifier, String entityIdentifier) { + return buildEntity(entityUpdateDtoIn.getEntityDtoInCommonFields(), entityTemplateIdentifier, + entityIdentifier); + } - /// Shared helper method to build the domain entity from common fields. - private Entity buildEntity(EntityDtoInCommonFields commonFields, String entityTemplateIdentifier, String identifier) { - List properties = commonFields.getProperties() == null - ? Collections.emptyList() - : commonFields.getProperties().entrySet().stream() - .map(entry -> new Property(null, entry.getKey(), entry.getValue())) - .toList(); + /// Shared helper method to build the domain entity from common fields. + private Entity buildEntity(EntityDtoInCommonFields commonFields, String entityTemplateIdentifier, + String identifier) { + List properties = commonFields.getProperties() == null + ? Collections.emptyList() + : commonFields.getProperties().entrySet().stream() + .map(entry -> new Property(null, entry.getKey(), entry.getValue())).toList(); - List relations = commonFields.getRelations() == null - ? Collections.emptyList() - : commonFields.getRelations().stream() - .map(relDto -> new Relation(null, relDto.getName(), null, relDto.getTargetEntityIdentifiers())) - .toList(); + List relations = commonFields.getRelations() == null + ? Collections.emptyList() + : commonFields.getRelations().stream().map(relDto -> new Relation(null, relDto.getName(), + null, relDto.getTargetEntityIdentifiers())).toList(); - return new Entity( - null, - entityTemplateIdentifier, - commonFields.getName(), - identifier, - properties, - relations - ); - } + return new Entity(null, entityTemplateIdentifier, commonFields.getName(), identifier, + properties, relations); + } } 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 5edf89b5..80957ce7 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 @@ -49,262 +49,259 @@ @RequiredArgsConstructor public class EntityDtoOutMapper { - private final EntityTemplateService entityTemplateService; - private final EntityService entityService; - private final RelationService relationService; + private final EntityTemplateService entityTemplateService; + private final EntityService entityService; + private final RelationService relationService; - /// Maps a single domain entity to API DTO using template-based conversion. - /// - /// **Infrastructure mapping:** Resolves entity template dynamically and performs - /// complete domain-to-DTO transformation including properties and relationships. - /// - /// @param entity domain entity to convert for API response - /// @return fully mapped entity DTO with resolved template metadata - public EntityDtoOut fromEntity(Entity entity) { - EntityTemplate entityTemplate = entityTemplateService - .getEntityTemplateByIdentifier(entity.templateIdentifier()); - return fromEntityUsingEntityTemplate(entity, entityTemplate); - } + /// Maps a single domain entity to API DTO using template-based conversion. + /// + /// **Infrastructure mapping:** Resolves entity template dynamically and + /// performs + /// complete domain-to-DTO transformation including properties and + /// relationships. + /// + /// @param entity domain entity to convert for API response + /// @return fully mapped entity DTO with resolved template metadata + public EntityDtoOut fromEntity(Entity entity) { + EntityTemplate entityTemplate = entityTemplateService + .getEntityTemplateByIdentifier(entity.templateIdentifier()); + return fromEntityUsingEntityTemplate(entity, entityTemplate); + } - /// Maps paginated domain entities to API DTOs with optimized bulk operations. - /// - /// **Performance optimization:** Batches template resolution and relationship lookups - /// to minimize database queries. Builds summary maps for efficient relationship - /// resolution across the entire page. - /// - /// @param entities paginated domain entities from repository layer - /// @param entityTemplateIdentifier template identifier for batch template resolution - /// @return paginated API DTOs with complete relationship data - public Page fromEntitiesPageToDtoPage(Page entities, - String entityTemplateIdentifier) { + /// Maps paginated domain entities to API DTOs with optimized bulk operations. + /// + /// **Performance optimization:** Batches template resolution and relationship + /// lookups + /// to minimize database queries. Builds summary maps for efficient relationship + /// resolution across the entire page. + /// + /// @param entities paginated domain entities from repository layer + /// @param entityTemplateIdentifier template identifier for batch template + /// resolution + /// @return paginated API DTOs with complete relationship data + public Page fromEntitiesPageToDtoPage(Page entities, + String entityTemplateIdentifier) { - Map pageEntitiesSummaries = buildRelatedEntitiesSummaryMapByPage(entities); - Map> relationTargetOwnershipsMap = buildRelationsAsTargetSummaryMapByPage( - entities); + Map pageEntitiesSummaries = buildRelatedEntitiesSummaryMapByPage( + entities); + Map> relationTargetOwnershipsMap = buildRelationsAsTargetSummaryMapByPage( + entities); - EntityTemplate pageEntityTemplate = entityTemplateService - .getEntityTemplateByIdentifier(entityTemplateIdentifier); - return entities.map(entity -> fromEntityUsingEntityTemplateAndSummaryMap(entity, pageEntityTemplate, - pageEntitiesSummaries, relationTargetOwnershipsMap)); - } + EntityTemplate pageEntityTemplate = entityTemplateService + .getEntityTemplateByIdentifier(entityTemplateIdentifier); + return entities.map(entity -> fromEntityUsingEntityTemplateAndSummaryMap(entity, + pageEntityTemplate, pageEntitiesSummaries, relationTargetOwnershipsMap)); + } - /// Maps a single entity to its DTO using the provided entity template. - /// - /// @param entity the entity to map - /// @param entityTemplate the template for property type mapping - /// @return the mapped DTO - private EntityDtoOut fromEntityUsingEntityTemplate(Entity entity, EntityTemplate entityTemplate) { - Map props = mapPropertiesDto(entity, entityTemplate); + /// Maps a single entity to its DTO using the provided entity template. + /// + /// @param entity the entity to map + /// @param entityTemplate the template for property type mapping + /// @return the mapped DTO + private EntityDtoOut fromEntityUsingEntityTemplate(Entity entity, EntityTemplate entityTemplate) { + Map props = mapPropertiesDto(entity, entityTemplate); - List allTargetIdentifiers = getAllTargetIdentifiersFromEntityRelations(entity); - Map relatedEntitiesSummaryMap = buildEntitiesSummariesMap(allTargetIdentifiers); - Map> relationMap = mapRelationsDto(entity, relatedEntitiesSummaryMap); - Map> relatedEntitiesByTargetSummaryMap = buildRelationsAsTargetSummaryMapByEntity( - entity); - Map> relationAsTargetMap = mapRelationsAsTargetDto(entity, - relatedEntitiesByTargetSummaryMap); + List allTargetIdentifiers = getAllTargetIdentifiersFromEntityRelations(entity); + Map relatedEntitiesSummaryMap = buildEntitiesSummariesMap( + allTargetIdentifiers); + Map> relationMap = mapRelationsDto(entity, + relatedEntitiesSummaryMap); + Map> relatedEntitiesByTargetSummaryMap = buildRelationsAsTargetSummaryMapByEntity( + entity); + Map> relationAsTargetMap = mapRelationsAsTargetDto(entity, + relatedEntitiesByTargetSummaryMap); - return EntityDtoOut.builder() - .templateIdentifier(entity.templateIdentifier()) - .name(entity.name()) - .identifier(entity.identifier()) - .properties(props) - .relations(relationMap) - .relationsAsTarget(relationAsTargetMap) - .build(); - } + return EntityDtoOut.builder().templateIdentifier(entity.templateIdentifier()) + .name(entity.name()).identifier(entity.identifier()).properties(props) + .relations(relationMap).relationsAsTarget(relationAsTargetMap).build(); + } - /// Maps a single entity to its DTO using pre-built summary and - /// relation-as-target maps. - /// - /// @param entity the entity to map - /// @param entityTemplate the template for property type mapping - /// @param relatedEntitiesSummaries map of entity summaries for relation targets - /// @param relationTargetOwnershipsMap map of relations-as-target for the entity - /// @return the mapped DTO - private EntityDtoOut fromEntityUsingEntityTemplateAndSummaryMap(Entity entity, EntityTemplate entityTemplate, - Map relatedEntitiesSummaries, - Map> relationTargetOwnershipsMap) { + /// Maps a single entity to its DTO using pre-built summary and + /// relation-as-target maps. + /// + /// @param entity the entity to map + /// @param entityTemplate the template for property type mapping + /// @param relatedEntitiesSummaries map of entity summaries for relation targets + /// @param relationTargetOwnershipsMap map of relations-as-target for the entity + /// @return the mapped DTO + private EntityDtoOut fromEntityUsingEntityTemplateAndSummaryMap(Entity entity, + EntityTemplate entityTemplate, Map relatedEntitiesSummaries, + Map> relationTargetOwnershipsMap) { - Map props = mapPropertiesDto(entity, entityTemplate); - Map> relationMap = mapRelationsDto(entity, relatedEntitiesSummaries); - Map> relationAsTargetMap = mapRelationsAsTargetDto(entity, - relationTargetOwnershipsMap); + Map props = mapPropertiesDto(entity, entityTemplate); + Map> relationMap = mapRelationsDto(entity, + relatedEntitiesSummaries); + Map> relationAsTargetMap = mapRelationsAsTargetDto(entity, + relationTargetOwnershipsMap); - return EntityDtoOut.builder() - .templateIdentifier(entity.templateIdentifier()) - .name(entity.name()) - .identifier(entity.identifier()) - .properties(props) - .relations(relationMap) - .relationsAsTarget(relationAsTargetMap) - .build(); + return EntityDtoOut.builder().templateIdentifier(entity.templateIdentifier()) + .name(entity.name()).identifier(entity.identifier()).properties(props) + .relations(relationMap).relationsAsTarget(relationAsTargetMap).build(); + } + + /// Maps the properties of an entity to a map of property names to typed values, + /// using the entity template for type conversion. + /// Properties with a null value are excluded from the output. + /// + /// @param entity the entity whose properties to map + /// @param entityTemplate the template for property type mapping + /// @return a map of property names to typed values + private Map mapPropertiesDto(Entity entity, EntityTemplate entityTemplate) { + if (entity.properties() == null) { + return Collections.emptyMap(); } - /// Maps the properties of an entity to a map of property names to typed values, - /// using the entity template for type conversion. - /// Properties with a null value are excluded from the output. - /// - /// @param entity the entity whose properties to map - /// @param entityTemplate the template for property type mapping - /// @return a map of property names to typed values - private Map mapPropertiesDto(Entity entity, EntityTemplate entityTemplate) { - if (entity.properties() == null) { - return Collections.emptyMap(); - } + Map propertiesDefinitions = entityTemplate.propertiesDefinitions() + .stream().collect(Collectors.toMap(PropertyDefinition::name, Function.identity())); - Map propertiesDefinitions = entityTemplate.propertiesDefinitions().stream() - .collect(Collectors.toMap(PropertyDefinition::name, Function.identity())); + return entity.properties().stream().filter(prop -> prop.value() != null) + .collect(Collectors.toMap(Property::name, + prop -> convertPropertyValue(prop, propertiesDefinitions.get(prop.name())))); + } - return entity.properties().stream() - .filter(prop -> prop.value() != null) - .collect(Collectors.toMap( - Property::name, - prop -> convertPropertyValue(prop, propertiesDefinitions.get(prop.name())))); + /// Converts a property value to its typed representation based on the property + /// definition. + /// + /// @param property the property to convert + /// @param definition the property definition for type information, may be null + /// @return the typed value, falling back to the raw string value + private Object convertPropertyValue(Property property, PropertyDefinition definition) { + String value = property.value(); + if (definition == null) { + return value; } - - /// Converts a property value to its typed representation based on the property definition. - /// - /// @param property the property to convert - /// @param definition the property definition for type information, may be null - /// @return the typed value, falling back to the raw string value - private Object convertPropertyValue(Property property, PropertyDefinition definition) { - String value = property.value(); - if (definition == null) { - return value; - } - PropertyType type = definition.type(); - if (PropertyType.NUMBER.equals(type)) { - try { - return Double.valueOf(value); - } catch (NumberFormatException _) { - return value; - } - } else if (PropertyType.BOOLEAN.equals(type)) { - return Boolean.valueOf(value); - } + PropertyType type = definition.type(); + if (PropertyType.NUMBER.equals(type)) { + try { + return Double.valueOf(value); + } catch (NumberFormatException _) { return value; + } + } else if (PropertyType.BOOLEAN.equals(type)) { + return Boolean.valueOf(value); } + return value; + } - /// Maps the relations of an entity to a map of relation names to lists of target - /// entity summaries. - /// - /// @param entity the entity whose relations to map - /// @param relatedEntitiesSummaries map of entity summaries for relation targets - /// @return a map of relation names to lists of target entity summaries - private Map> mapRelationsDto(Entity entity, - Map relatedEntitiesSummaries) { - return entity.relations() == null - ? Collections.emptyMap() - : entity.relations().stream() - .collect(Collectors.groupingBy( - Relation::name, - Collectors.flatMapping(rel -> rel.targetEntityIdentifiers().stream() - .map(relatedEntitiesSummaries::get) - .filter(Objects::nonNull), - Collectors.toList()))); - } - - /// Maps the relations-as-target for an entity to a map of relation names to - /// lists of source entity summaries. - /// - /// @param entity the entity whose relations-as-target to map - /// @param relationTargetOwnershipsMap map of relations-as-target for the entity - /// @return a map of relation names to lists of source entity summaries - private Map> mapRelationsAsTargetDto(Entity entity, - Map> relationTargetOwnershipsMap) { - List relationAsTargetSummaries = relationTargetOwnershipsMap.get(entity.identifier()); - if (relationAsTargetSummaries == null) { - return Collections.emptyMap(); - } + /// Maps the relations of an entity to a map of relation names to lists of + /// target + /// entity summaries. + /// + /// @param entity the entity whose relations to map + /// @param relatedEntitiesSummaries map of entity summaries for relation targets + /// @return a map of relation names to lists of target entity summaries + private Map> mapRelationsDto(Entity entity, + Map relatedEntitiesSummaries) { + return entity.relations() == null + ? Collections.emptyMap() + : entity.relations().stream().collect(Collectors.groupingBy(Relation::name, + Collectors.flatMapping(rel -> rel.targetEntityIdentifiers().stream() + .map(relatedEntitiesSummaries::get).filter(Objects::nonNull), + Collectors.toList()))); + } - return relationAsTargetSummaries.stream() - .collect(Collectors.groupingBy( - RelationAsTargetSummary::relationName, - Collectors.mapping( - r -> new EntitySummaryDto(r.sourceEntityIdentifier(), r.sourceEntityName()), - Collectors.toList()))); + /// Maps the relations-as-target for an entity to a map of relation names to + /// lists of source entity summaries. + /// + /// @param entity the entity whose relations-as-target to map + /// @param relationTargetOwnershipsMap map of relations-as-target for the entity + /// @return a map of relation names to lists of source entity summaries + private Map> mapRelationsAsTargetDto(Entity entity, + Map> relationTargetOwnershipsMap) { + List relationAsTargetSummaries = relationTargetOwnershipsMap + .get(entity.identifier()); + if (relationAsTargetSummaries == null) { + return Collections.emptyMap(); } - /// Builds a map of relation target ownerships for a page of entities, grouping - /// by target entity identifier. - /// - /// @param entitiesPage the page of entities to analyze - /// @return a map from target entity identifier to list of relation-as-target summaries - private Map> buildRelationsAsTargetSummaryMapByPage( - Page entitiesPage) { - if (entitiesPage == null || entitiesPage.getContent().isEmpty()) { - return Collections.emptyMap(); - } - List entitiesIdentifiers = entitiesPage.getContent().stream().map(Entity::identifier) - .filter(Objects::nonNull).toList(); - List relationTargetOwnerships = relationService - .findRelationsSummariesByTargetEntityIdentifiers(entitiesIdentifiers); - return relationTargetOwnerships.stream() - .collect(Collectors.groupingBy(RelationAsTargetSummary::targetEntityIdentifier)); - } + return relationAsTargetSummaries.stream() + .collect(Collectors.groupingBy(RelationAsTargetSummary::relationName, + Collectors.mapping( + r -> new EntitySummaryDto(r.sourceEntityIdentifier(), r.sourceEntityName()), + Collectors.toList()))); + } - /// Builds a map of relation target ownerships for a single entity, grouping by - /// target entity identifier. - /// - /// @param entity the entity to analyze - /// @return a map from target entity identifier to list of relation-as-target summaries - private Map> buildRelationsAsTargetSummaryMapByEntity(Entity entity) { - if (entity == null || entity.identifier() == null) { - return Collections.emptyMap(); - } - List relationTargetOwnerships = relationService - .findRelationsSummariesByTargetEntityIdentifiers(List.of(entity.identifier())); - return relationTargetOwnerships.stream() - .collect(Collectors.groupingBy(RelationAsTargetSummary::targetEntityIdentifier)); + /// Builds a map of relation target ownerships for a page of entities, grouping + /// by target entity identifier. + /// + /// @param entitiesPage the page of entities to analyze + /// @return a map from target entity identifier to list of relation-as-target + /// summaries + private Map> buildRelationsAsTargetSummaryMapByPage( + Page entitiesPage) { + if (entitiesPage == null || entitiesPage.getContent().isEmpty()) { + return Collections.emptyMap(); } + List entitiesIdentifiers = entitiesPage.getContent().stream().map(Entity::identifier) + .filter(Objects::nonNull).toList(); + List relationTargetOwnerships = relationService + .findRelationsSummariesByTargetEntityIdentifiers(entitiesIdentifiers); + return relationTargetOwnerships.stream() + .collect(Collectors.groupingBy(RelationAsTargetSummary::targetEntityIdentifier)); + } - /// Gets all unique target entity identifiers from the relations of a single entity. - /// - /// @param entity the entity to analyze - /// @return a list of unique target entity identifiers - private List getAllTargetIdentifiersFromEntityRelations(Entity entity) { - return entity.relations() == null - ? Collections.emptyList() - : new ArrayList<>(entity.relations().stream() - .flatMap(rel -> rel.targetEntityIdentifiers().stream()) - .collect(Collectors.toSet())); + /// Builds a map of relation target ownerships for a single entity, grouping by + /// target entity identifier. + /// + /// @param entity the entity to analyze + /// @return a map from target entity identifier to list of relation-as-target + /// summaries + private Map> buildRelationsAsTargetSummaryMapByEntity( + Entity entity) { + if (entity == null || entity.identifier() == null) { + return Collections.emptyMap(); } + List relationTargetOwnerships = relationService + .findRelationsSummariesByTargetEntityIdentifiers(List.of(entity.identifier())); + return relationTargetOwnerships.stream() + .collect(Collectors.groupingBy(RelationAsTargetSummary::targetEntityIdentifier)); + } - /// Gets all unique target entity identifiers from the relations of all entities in a page. - /// - /// @param entities the page of entities to analyze - /// @return a list of unique target entity identifiers - private List getUniqueTargetIdentifiersInPage(Page entities) { - return new ArrayList<>(entities.stream() - .flatMap(entity -> entity.relations() == null - ? Stream.empty() - : entity.relations().stream() - .flatMap(rel -> rel.targetEntityIdentifiers().stream())) - .collect(Collectors.toSet())); - } + /// Gets all unique target entity identifiers from the relations of a single + /// entity. + /// + /// @param entity the entity to analyze + /// @return a list of unique target entity identifiers + private List getAllTargetIdentifiersFromEntityRelations(Entity entity) { + return entity.relations() == null + ? Collections.emptyList() + : new ArrayList<>(entity.relations().stream() + .flatMap(rel -> rel.targetEntityIdentifiers().stream()).collect(Collectors.toSet())); + } - /// Builds a map of entity summaries for all unique target identifiers in a page of entities. - /// - /// @param entities the page of entities - /// @return a map from entity identifier to summary DTO - private Map buildRelatedEntitiesSummaryMapByPage(Page entities) { - return buildEntitiesSummariesMap( - getUniqueTargetIdentifiersInPage(entities)); - } + /// Gets all unique target entity identifiers from the relations of all entities + /// in a page. + /// + /// @param entities the page of entities to analyze + /// @return a list of unique target entity identifiers + private List getUniqueTargetIdentifiersInPage(Page entities) { + return new ArrayList<>(entities.stream() + .flatMap(entity -> entity.relations() == null + ? Stream.empty() + : entity.relations().stream().flatMap(rel -> rel.targetEntityIdentifiers().stream())) + .collect(Collectors.toSet())); + } - /// Builds a map of entity summaries for a list of target identifiers. - /// - /// @param targetIdentifiers the list of target entity identifiers - /// @return a map from entity identifier to summary DTO - private Map buildEntitiesSummariesMap(List targetIdentifiers) { - return targetIdentifiers.isEmpty() - ? Collections.emptyMap() - : entityService.getEntitiesSummariesByIdentifiers(targetIdentifiers) - .stream() - .collect(Collectors.toMap( - EntitySummary::identifier, - es -> new EntitySummaryDto(es.identifier(), es.name()))); - } + /// Builds a map of entity summaries for all unique target identifiers in a page + /// of entities. + /// + /// @param entities the page of entities + /// @return a map from entity identifier to summary DTO + private Map buildRelatedEntitiesSummaryMapByPage( + Page entities) { + return buildEntitiesSummariesMap(getUniqueTargetIdentifiersInPage(entities)); + } + + /// Builds a map of entity summaries for a list of target identifiers. + /// + /// @param targetIdentifiers the list of target entity identifiers + /// @return a map from entity identifier to summary DTO + private Map buildEntitiesSummariesMap(List targetIdentifiers) { + return targetIdentifiers.isEmpty() + ? Collections.emptyMap() + : entityService.getEntitiesSummariesByIdentifiers(targetIdentifiers).stream() + .collect(Collectors.toMap(EntitySummary::identifier, + es -> new EntitySummaryDto(es.identifier(), es.name()))); + } } 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 61e07b1b..4cc14a22 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 @@ -5,7 +5,6 @@ import java.util.Optional; import java.util.UUID; -import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.EntityJpaEntity; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.domain.Specification; @@ -16,6 +15,7 @@ import com.decathlon.idp_core.domain.model.entity.EntitySummary; 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; @@ -25,60 +25,67 @@ @RequiredArgsConstructor public class PostgresEntityAdapter implements EntityRepositoryPort { - private final JpaEntityRepository jpaEntityRepository; - private final EntityPersistenceMapper mapper; - - @Override - public Entity save(Entity entity) { - return mapper.toDomain(jpaEntityRepository.save(mapper.toJpa(entity))); - } - - @Override - public Optional findById(UUID id) { - return jpaEntityRepository.findById(id).map(mapper::toDomain); - } - - @Override - public Optional findByTemplateIdentifierAndIdentifier(String templateIdentifier, String identifier) { - return jpaEntityRepository.findByTemplateIdentifierAndIdentifier(templateIdentifier, identifier) - .map(mapper::toDomain); - } - - @Override - public Optional findByTemplateIdentifierAndName(String templateIdentifier, String entityName) { - return jpaEntityRepository.findByTemplateIdentifierAndName(templateIdentifier, entityName) - .map(mapper::toDomain); - } - - @Override - public Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable) { - var pageableEntity = jpaEntityRepository.findByTemplateIdentifier(templateIdentifier, pageable); - return pageableEntity.map(mapper::toDomain); - } - - @Override - public Page findByTemplateIdentifierWithFilter(String templateIdentifier, EntityFilter filter, Pageable pageable) { - Specification spec = EntitySpecification.of(templateIdentifier, filter); - return jpaEntityRepository.findAll(spec, pageable).map(mapper::toDomain); - } - - @Override - public List findByIdentifierIn(List identifiers) { - return jpaEntityRepository.findByIdentifierIn(identifiers); - } - - @Override - public List findByRelationIdIn(List relationIds) { - return jpaEntityRepository.findByRelationIdIn(relationIds); - } - - @Override - public void deletePropertiesByTemplateIdentifierAndPropertyName(String templateIdentifier, Collection propertyNames) { - jpaEntityRepository.deletePropertiesByTemplateIdentifierAndPropertyName(templateIdentifier, propertyNames); - } - - @Override - public void deleteRelationsByTemplateIdentifierAndRelationName(String templateIdentifier, Collection relationNames) { - jpaEntityRepository.deleteRelationsByTemplateIdentifierAndRelationName(templateIdentifier, relationNames); - } + private final JpaEntityRepository jpaEntityRepository; + private final EntityPersistenceMapper mapper; + + @Override + public Entity save(Entity entity) { + return mapper.toDomain(jpaEntityRepository.save(mapper.toJpa(entity))); + } + + @Override + public Optional findById(UUID id) { + return jpaEntityRepository.findById(id).map(mapper::toDomain); + } + + @Override + public Optional findByTemplateIdentifierAndIdentifier(String templateIdentifier, + String identifier) { + return jpaEntityRepository.findByTemplateIdentifierAndIdentifier(templateIdentifier, identifier) + .map(mapper::toDomain); + } + + @Override + public Optional findByTemplateIdentifierAndName(String templateIdentifier, + String entityName) { + return jpaEntityRepository.findByTemplateIdentifierAndName(templateIdentifier, entityName) + .map(mapper::toDomain); + } + + @Override + public Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable) { + var pageableEntity = jpaEntityRepository.findByTemplateIdentifier(templateIdentifier, pageable); + return pageableEntity.map(mapper::toDomain); + } + + @Override + public Page findByTemplateIdentifierWithFilter(String templateIdentifier, + EntityFilter filter, Pageable pageable) { + Specification spec = EntitySpecification.of(templateIdentifier, filter); + return jpaEntityRepository.findAll(spec, pageable).map(mapper::toDomain); + } + + @Override + public List findByIdentifierIn(List identifiers) { + return jpaEntityRepository.findByIdentifierIn(identifiers); + } + + @Override + public List findByRelationIdIn(List relationIds) { + return jpaEntityRepository.findByRelationIdIn(relationIds); + } + + @Override + public void deletePropertiesByTemplateIdentifierAndPropertyName(String templateIdentifier, + Collection propertyNames) { + jpaEntityRepository.deletePropertiesByTemplateIdentifierAndPropertyName(templateIdentifier, + propertyNames); + } + + @Override + public void deleteRelationsByTemplateIdentifierAndRelationName(String templateIdentifier, + Collection relationNames) { + jpaEntityRepository.deleteRelationsByTemplateIdentifierAndRelationName(templateIdentifier, + relationNames); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/EntityJpaEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/EntityJpaEntity.java index 848693df..72aea570 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/EntityJpaEntity.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/EntityJpaEntity.java @@ -14,6 +14,7 @@ import jakarta.persistence.OneToMany; import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -22,37 +23,30 @@ @jakarta.persistence.Entity @Data @Table(name = "entity", uniqueConstraints = { - @UniqueConstraint(columnNames = {"identifier", "template_identifier"}) -}) + @UniqueConstraint(columnNames = {"identifier", "template_identifier"})}) @Builder @NoArgsConstructor @AllArgsConstructor public class EntityJpaEntity { - @Id - @GeneratedValue(strategy = GenerationType.UUID) - private UUID id; + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; - @Column(name = "template_identifier") - private String templateIdentifier; + @Column(name = "template_identifier") + private String templateIdentifier; - private String name; + private String name; - private String identifier; + private String identifier; - @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) - @JoinTable(name = "entity_properties", - joinColumns = @JoinColumn(name = "entity_id"), - inverseJoinColumns = @JoinColumn(name = "property_id"), - uniqueConstraints = @UniqueConstraint(columnNames = {"entity_id", "property_id"}), - indexes = @Index(columnList = "entity_id")) - private List properties; + @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) + @JoinTable(name = "entity_properties", joinColumns = @JoinColumn(name = "entity_id"), inverseJoinColumns = @JoinColumn(name = "property_id"), uniqueConstraints = @UniqueConstraint(columnNames = { + "entity_id", "property_id"}), indexes = @Index(columnList = "entity_id")) + private List properties; - @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) - @JoinTable(name = "entity_relations", - joinColumns = @JoinColumn(name = "entity_id"), - inverseJoinColumns = @JoinColumn(name = "relation_id"), - uniqueConstraints = @UniqueConstraint(columnNames = {"entity_id", "relation_id"}), - indexes = @Index(columnList = "entity_id")) - private List relations; + @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) + @JoinTable(name = "entity_relations", joinColumns = @JoinColumn(name = "entity_id"), inverseJoinColumns = @JoinColumn(name = "relation_id"), uniqueConstraints = @UniqueConstraint(columnNames = { + "entity_id", "relation_id"}), indexes = @Index(columnList = "entity_id")) + private List relations; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java index 500a3255..1ddc3bba 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java @@ -18,43 +18,159 @@ import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.EntityJpaEntity; @Repository -public interface JpaEntityRepository extends JpaRepository, JpaSpecificationExecutor { - - @Query("SELECT e.identifier AS identifier, e.name AS name, e.templateIdentifier AS templateIdentifier FROM EntityJpaEntity e WHERE e.identifier IN :identifiers") - List findByIdentifierIn(List identifiers); - - @Query("SELECT e.identifier AS identifier, e.name AS name, e.templateIdentifier AS templateIdentifier FROM EntityJpaEntity e JOIN e.relations r WHERE r.id IN :relationIds") - List findByRelationIdIn(List relationIds); - - Optional findByTemplateIdentifierAndIdentifier(String templateIdentifier, String identifier); - - Optional findByTemplateIdentifierAndName(String templateIdentifier, String name); - - Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable); - - @Modifying(clearAutomatically = true, flushAutomatically = true) - @Query(""" - DELETE FROM PropertyJpaEntity p - WHERE p IN ( - SELECT p2 FROM EntityJpaEntity e JOIN e.properties p2 - WHERE e.templateIdentifier = :templateIdentifier - AND p2.name IN :propertyNames - ) - """) - void deletePropertiesByTemplateIdentifierAndPropertyName( - @Param("templateIdentifier") String templateIdentifier, - @Param("propertyNames") Collection propertyNames); - - @Modifying(clearAutomatically = true, flushAutomatically = true) - @Query(""" - DELETE FROM RelationJpaEntity r - WHERE r IN ( - SELECT r2 FROM EntityJpaEntity e JOIN e.relations r2 - WHERE e.templateIdentifier = :templateIdentifier - AND r2.name IN :relationNames - ) - """) - void deleteRelationsByTemplateIdentifierAndRelationName( - @Param("templateIdentifier") String templateIdentifier, - @Param("relationNames") Collection relationNames); +public interface JpaEntityRepository + extends + JpaRepository, + JpaSpecificationExecutor { + + @Query("SELECT e.identifier AS identifier, e.name AS name, e.templateIdentifier AS templateIdentifier FROM EntityJpaEntity e WHERE e.identifier IN :identifiers") + List findByIdentifierIn(List identifiers); + + @Query("SELECT e.identifier AS identifier, e.name AS name, e.templateIdentifier AS templateIdentifier FROM EntityJpaEntity e JOIN e.relations r WHERE r.id IN :relationIds") + List findByRelationIdIn(List relationIds); + + Optional findByTemplateIdentifierAndIdentifier(String templateIdentifier, + String identifier); + + Optional findByTemplateIdentifierAndName(String templateIdentifier, String name); + + Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable); + + /// Batch fetch entities by identifiers with eager loading of relations and + /// properties. Uses two separate queries to avoid Hibernate's + /// MultipleBagFetchException. First fetches entities with relations, then + /// fetches properties separately. + @Query("SELECT DISTINCT e FROM EntityJpaEntity e LEFT JOIN FETCH e.relations WHERE e.identifier IN :identifiers") + List findAllByIdentifierInWithRelations( + @Param("identifiers") Collection identifiers); + + /// Fetch properties for entities that were already loaded. This is called after + /// findAllByIdentifierInWithRelations to complete the entity graph. + @Query("SELECT DISTINCT e FROM EntityJpaEntity e LEFT JOIN FETCH e.properties WHERE e.identifier IN :identifiers") + List findAllByIdentifierInWithProperties( + @Param("identifiers") Collection identifiers); + + @Query(value = """ + WITH RECURSIVE + -- Traverse outbound relations (this entity -> targets) + outbound_graph(identifier, template_identifier, depth) AS ( + SELECT e.identifier, e.template_identifier, 0 + FROM entity e + WHERE e.identifier = :entityIdentifier + AND e.template_identifier = :templateIdentifier + + UNION ALL + + SELECT e2.identifier, e2.template_identifier, og.depth + 1 + FROM outbound_graph og + JOIN entity e ON e.identifier = og.identifier AND e.template_identifier = og.template_identifier + JOIN entity_relations er ON er.entity_id = e.id + JOIN relation r ON r.id = er.relation_id + JOIN relation_target_entities rte ON rte.relation_id = r.id + JOIN entity e2 ON e2.identifier = rte.target_entity_identifier + WHERE og.depth < :depth + ), + -- Traverse inbound relations (sources -> this entity as target) + inbound_graph(identifier, template_identifier, depth) AS ( + SELECT e.identifier, e.template_identifier, 0 + FROM entity e + WHERE e.identifier = :entityIdentifier + AND e.template_identifier = :templateIdentifier + + UNION ALL + + SELECT e2.identifier, e2.template_identifier, ig.depth + 1 + FROM inbound_graph ig + JOIN entity e ON e.identifier = ig.identifier AND e.template_identifier = ig.template_identifier + JOIN relation_target_entities rte ON rte.target_entity_identifier = e.identifier + JOIN relation r ON r.id = rte.relation_id + JOIN entity_relations er ON er.relation_id = r.id + JOIN entity e2 ON e2.id = er.entity_id + WHERE ig.depth < :depth + ) + SELECT DISTINCT identifier, template_identifier FROM outbound_graph + UNION + SELECT DISTINCT identifier, template_identifier FROM inbound_graph + """, nativeQuery = true) + List findEntityGraphIdentifiers(@Param("templateIdentifier") String templateIdentifier, + @Param("entityIdentifier") String entityIdentifier, @Param("depth") int depth); + + /// Variant of [findEntityGraphIdentifiers] that restricts traversal to the + /// given relation names. When the list is empty, all relation names are + /// followed + /// (no filter). The filter is applied inside both the outbound and inbound + /// recursive CTE steps so that only entities reachable through the specified + /// relations are returned, keeping the result set lean. + @Query(value = """ + WITH RECURSIVE + outbound_graph(identifier, template_identifier, depth) AS ( + SELECT e.identifier, e.template_identifier, 0 + FROM entity e + WHERE e.identifier = :entityIdentifier + AND e.template_identifier = :templateIdentifier + + UNION ALL + + SELECT e2.identifier, e2.template_identifier, og.depth + 1 + FROM outbound_graph og + JOIN entity e ON e.identifier = og.identifier AND e.template_identifier = og.template_identifier + JOIN entity_relations er ON er.entity_id = e.id + JOIN relation r ON r.id = er.relation_id + JOIN relation_target_entities rte ON rte.relation_id = r.id + JOIN entity e2 ON e2.identifier = rte.target_entity_identifier + WHERE og.depth < :depth + AND r.name IN :relationNames + ), + inbound_graph(identifier, template_identifier, depth) AS ( + SELECT e.identifier, e.template_identifier, 0 + FROM entity e + WHERE e.identifier = :entityIdentifier + AND e.template_identifier = :templateIdentifier + + UNION ALL + + SELECT e2.identifier, e2.template_identifier, ig.depth + 1 + FROM inbound_graph ig + JOIN entity e ON e.identifier = ig.identifier AND e.template_identifier = ig.template_identifier + JOIN relation_target_entities rte ON rte.target_entity_identifier = e.identifier + JOIN relation r ON r.id = rte.relation_id + JOIN entity_relations er ON er.relation_id = r.id + JOIN entity e2 ON e2.id = er.entity_id + WHERE ig.depth < :depth + AND r.name IN :relationNames + ) + SELECT DISTINCT identifier, template_identifier FROM outbound_graph + UNION + SELECT DISTINCT identifier, template_identifier FROM inbound_graph + """, nativeQuery = true) + List findEntityGraphIdentifiersFilteredByRelations( + @Param("templateIdentifier") String templateIdentifier, + @Param("entityIdentifier") String entityIdentifier, @Param("depth") int depth, + @Param("relationNames") Collection relationNames); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + DELETE FROM PropertyJpaEntity p + WHERE p IN ( + SELECT p2 FROM EntityJpaEntity e JOIN e.properties p2 + WHERE e.templateIdentifier = :templateIdentifier + AND p2.name IN :propertyNames + ) + """) + void deletePropertiesByTemplateIdentifierAndPropertyName( + @Param("templateIdentifier") String templateIdentifier, + @Param("propertyNames") Collection propertyNames); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + DELETE FROM RelationJpaEntity r + WHERE r IN ( + SELECT r2 FROM EntityJpaEntity e JOIN e.relations r2 + WHERE e.templateIdentifier = :templateIdentifier + AND r2.name IN :relationNames + ) + """) + void deleteRelationsByTemplateIdentifierAndRelationName( + @Param("templateIdentifier") String templateIdentifier, + @Param("relationNames") Collection relationNames); } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySpecification.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySpecification.java index 0839c6e9..8423e9e2 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/EntitySpecification.java @@ -2,6 +2,13 @@ import java.util.stream.Stream; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.Expression; +import jakarta.persistence.criteria.Join; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; +import jakarta.persistence.criteria.Subquery; + import org.springframework.data.jpa.domain.Specification; import com.decathlon.idp_core.domain.model.entity.EntityFilter; @@ -11,12 +18,6 @@ import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.PropertyJpaEntity; import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.RelationJpaEntity; -import jakarta.persistence.criteria.CriteriaBuilder; -import jakarta.persistence.criteria.Expression; -import jakarta.persistence.criteria.Join; -import jakarta.persistence.criteria.Predicate; -import jakarta.persistence.criteria.Root; -import jakarta.persistence.criteria.Subquery; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -40,184 +41,177 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public final class EntitySpecification { - private static final char LIKE_ESCAPE_CHAR = '\\'; - private static final String NAME = "name"; - private static final String IDENTIFIER = "identifier"; - private static final String RELATIONS = "relations"; - private static final String TARGET_ENTITY_IDENTIFIERS = "targetEntityIdentifiers"; - - /// Builds a [Specification] that matches entities belonging to the given template identifier - /// and satisfying all criteria in the given filter. - /// - /// @param templateIdentifier the template to scope the query to - /// @param filter the filter to apply; may be empty (no additional predicates) - /// @return a composed [Specification] combining template scope and all filter criteria - public static Specification of(String templateIdentifier, EntityFilter filter) { - var criteriaSpecs = filter.criteria().stream() - .map(EntitySpecification::fromCriterion); - - return Stream.concat( - Stream.of(hasTemplateIdentifier(templateIdentifier)), - criteriaSpecs - ).reduce(Specification::and).orElse(hasTemplateIdentifier(templateIdentifier)); - } - - private static Specification hasTemplateIdentifier(String templateIdentifier) { - return (root, query, cb) -> cb.equal(root.get("templateIdentifier"), templateIdentifier); - } - - private static Specification fromCriterion(FilterCriterion criterion) { - return switch (criterion.keyType()) { - case ATTRIBUTE -> attributeSpec(criterion); - case PROPERTY -> propertySpec(criterion); - case RELATION_NAME -> relationNameSpec(criterion); - case RELATION_ENTITY -> relationEntitySpec(criterion); - case RELATION_PROPERTY -> relationPropertySpec(criterion); - case RELATIONS_AS_TARGET_NAME -> relationsAsTargetNameSpec(criterion); - case RELATIONS_AS_TARGET_PROPERTY -> relationsAsTargetPropertySpec(criterion); - }; - } - - private static Specification attributeSpec(FilterCriterion criterion) { - return (root, query, cb) -> - buildPredicate(cb, root.get(criterion.key()), criterion.operator(), criterion.value()); - } - - private static Specification propertySpec(FilterCriterion criterion) { - return (root, query, cb) -> { - query.distinct(true); - Join propJoin = root.join("properties"); - return cb.and( - cb.equal(propJoin.get(NAME), criterion.key()), - buildPredicate(cb, propJoin.get("value"), criterion.operator(), criterion.value()) - ); - }; - } - - private static Specification relationEntitySpec(FilterCriterion criterion) { - return (root, query, cb) -> { - query.distinct(true); - Join relJoin = root.join(RELATIONS); - Join targetJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); - return cb.and( - cb.equal(relJoin.get(NAME), criterion.key()), - buildPredicate(cb, targetJoin, criterion.operator(), criterion.value()) - ); - }; - } - - private static Specification relationPropertySpec(FilterCriterion criterion) { - return (root, query, cb) -> { - query.distinct(true); - Join relJoin = root.join(RELATIONS); - - String compositeKey = criterion.key(); - int dotIndex = compositeKey.indexOf('.'); - if (dotIndex < 0) { - throw new IllegalArgumentException("Invalid composite key format: " + compositeKey); - } - String relationName = compositeKey.substring(0, dotIndex); - String propertyName = compositeKey.substring(dotIndex + 1); - - // Check if the property is a target entity property (identifier, name) - if (IDENTIFIER.equals(propertyName) || NAME.equals(propertyName)) { - // Join to target entity identifiers first - Join targetIdJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); - // Create a subquery to find the actual target entities and filter by their properties - var subquery = query.subquery(String.class); - var subRoot = subquery.from(EntityJpaEntity.class); - subquery.select(subRoot.get(IDENTIFIER)) - .where(buildPredicate(cb, subRoot.get(propertyName), criterion.operator(), criterion.value())); - - return cb.and( - cb.equal(relJoin.get(NAME), relationName), - cb.in(targetIdJoin).value(subquery) - ); - } else { - // Direct relation property (shouldn't happen normally as RelationJpaEntity has limited properties) - return cb.and( - cb.equal(relJoin.get(NAME), relationName), - buildPredicate(cb, relJoin.get(propertyName), criterion.operator(), criterion.value()) - ); - } - }; - } - - private static Predicate buildPredicate( - CriteriaBuilder cb, - Expression field, - FilterOperator operator, - String value) { - Expression stringField = field.as(String.class); - return switch (operator) { - case EQUALS -> cb.equal(cb.lower(stringField), value.toLowerCase()); - case CONTAINS -> { - String escaped = escapeLikeWildcards(value.toLowerCase()); - yield cb.like(cb.lower(stringField), "%" + escaped + "%", LIKE_ESCAPE_CHAR); - } - case LESS_THAN -> cb.lessThan(stringField, value); - case GREATER_THAN -> cb.greaterThan(stringField, value); - }; - } - - private static Specification relationNameSpec(FilterCriterion criterion) { - return (root, query, cb) -> { - query.distinct(true); - Join relJoin = root.join(RELATIONS); - return buildPredicate(cb, relJoin.get(NAME), criterion.operator(), criterion.value()); - }; - } - - private static Specification relationsAsTargetNameSpec(FilterCriterion criterion) { - return (root, query, cb) -> { - // Find entities whose identifier appears as a target in any relation whose name matches. - // Uses a correlated subquery to avoid joining through the entity's own outgoing relations. - Subquery subquery = query.subquery(String.class); - Root relRoot = subquery.from(RelationJpaEntity.class); - Join targetJoin = relRoot.join(TARGET_ENTITY_IDENTIFIERS); - subquery.select(targetJoin) - .where(buildPredicate(cb, relRoot.get(NAME), criterion.operator(), criterion.value())); - return cb.in(root.get(IDENTIFIER)).value(subquery); - }; - } - - /// Finds entities whose `identifier` appears as a `targetEntityIdentifier` in any - /// relation whose **source entity** property matches the criterion. - /// - /// Example: `relations_as_target.api-link.name:microservice` returns entities that - /// are targeted by a `api-link` relation originating from an entity whose name - /// contains "microservice". - private static Specification relationsAsTargetPropertySpec(FilterCriterion criterion) { - return (root, query, cb) -> { - String compositeKey = criterion.key(); - int dotIndex = compositeKey.indexOf('.'); - if (dotIndex < 0) { - throw new IllegalArgumentException("Invalid composite key format: " + compositeKey); - } - String relationName = compositeKey.substring(0, dotIndex); - String propertyName = compositeKey.substring(dotIndex + 1); // "identifier" or "name" - - // Subquery: collect all target identifiers from relations named - // that originate from source entities whose matches. - Subquery subquery = query.subquery(String.class); - Root sourceRoot = subquery.from(EntityJpaEntity.class); - Join relJoin = sourceRoot.join(RELATIONS); - Join targetJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); - subquery.select(targetJoin) - .where( - cb.equal(relJoin.get(NAME), relationName), - buildPredicate(cb, sourceRoot.get(propertyName), criterion.operator(), criterion.value()) - ); - return cb.in(root.get(IDENTIFIER)).value(subquery); - }; - } - - /// Escapes SQL LIKE wildcards (`%` and `_`) in the given value so they are - /// treated as literal characters rather than pattern metacharacters. - static String escapeLikeWildcards(String value) { - return value - .replace(String.valueOf(LIKE_ESCAPE_CHAR), LIKE_ESCAPE_CHAR + String.valueOf(LIKE_ESCAPE_CHAR)) - .replace("%", LIKE_ESCAPE_CHAR + "%") - .replace("_", LIKE_ESCAPE_CHAR + "_"); - } + private static final char LIKE_ESCAPE_CHAR = '\\'; + private static final String NAME = "name"; + private static final String IDENTIFIER = "identifier"; + private static final String RELATIONS = "relations"; + private static final String TARGET_ENTITY_IDENTIFIERS = "targetEntityIdentifiers"; + + /// Builds a [Specification] that matches entities belonging to the given + /// template identifier + /// and satisfying all criteria in the given filter. + /// + /// @param templateIdentifier the template to scope the query to + /// @param filter the filter to apply; may be empty (no additional predicates) + /// @return a composed [Specification] combining template scope and all filter + /// criteria + public static Specification of(String templateIdentifier, EntityFilter filter) { + var criteriaSpecs = filter.criteria().stream().map(EntitySpecification::fromCriterion); + + return Stream.concat(Stream.of(hasTemplateIdentifier(templateIdentifier)), criteriaSpecs) + .reduce(Specification::and).orElse(hasTemplateIdentifier(templateIdentifier)); + } + + private static Specification hasTemplateIdentifier(String templateIdentifier) { + return (root, query, cb) -> cb.equal(root.get("templateIdentifier"), templateIdentifier); + } + + private static Specification fromCriterion(FilterCriterion criterion) { + return switch (criterion.keyType()) { + case ATTRIBUTE -> attributeSpec(criterion); + case PROPERTY -> propertySpec(criterion); + case RELATION_NAME -> relationNameSpec(criterion); + case RELATION_ENTITY -> relationEntitySpec(criterion); + case RELATION_PROPERTY -> relationPropertySpec(criterion); + case RELATIONS_AS_TARGET_NAME -> relationsAsTargetNameSpec(criterion); + case RELATIONS_AS_TARGET_PROPERTY -> relationsAsTargetPropertySpec(criterion); + }; + } + + private static Specification attributeSpec(FilterCriterion criterion) { + return (root, query, cb) -> buildPredicate(cb, root.get(criterion.key()), criterion.operator(), + criterion.value()); + } + + private static Specification propertySpec(FilterCriterion criterion) { + return (root, query, cb) -> { + query.distinct(true); + Join propJoin = root.join("properties"); + return cb.and(cb.equal(propJoin.get(NAME), criterion.key()), + buildPredicate(cb, propJoin.get("value"), criterion.operator(), criterion.value())); + }; + } + + private static Specification relationEntitySpec(FilterCriterion criterion) { + return (root, query, cb) -> { + query.distinct(true); + Join relJoin = root.join(RELATIONS); + Join targetJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); + return cb.and(cb.equal(relJoin.get(NAME), criterion.key()), + buildPredicate(cb, targetJoin, criterion.operator(), criterion.value())); + }; + } + + private static Specification relationPropertySpec(FilterCriterion criterion) { + return (root, query, cb) -> { + query.distinct(true); + Join relJoin = root.join(RELATIONS); + + String compositeKey = criterion.key(); + int dotIndex = compositeKey.indexOf('.'); + if (dotIndex < 0) { + throw new IllegalArgumentException("Invalid composite key format: " + compositeKey); + } + String relationName = compositeKey.substring(0, dotIndex); + String propertyName = compositeKey.substring(dotIndex + 1); + + // Check if the property is a target entity property (identifier, name) + if (IDENTIFIER.equals(propertyName) || NAME.equals(propertyName)) { + // Join to target entity identifiers first + Join targetIdJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); + // Create a subquery to find the actual target entities and filter by their + // properties + var subquery = query.subquery(String.class); + var subRoot = subquery.from(EntityJpaEntity.class); + subquery.select(subRoot.get(IDENTIFIER)).where( + buildPredicate(cb, subRoot.get(propertyName), criterion.operator(), criterion.value())); + + return cb.and(cb.equal(relJoin.get(NAME), relationName), + cb.in(targetIdJoin).value(subquery)); + } else { + // Direct relation property (shouldn't happen normally as RelationJpaEntity has + // limited properties) + return cb.and(cb.equal(relJoin.get(NAME), relationName), + buildPredicate(cb, relJoin.get(propertyName), criterion.operator(), criterion.value())); + } + }; + } + + private static Predicate buildPredicate(CriteriaBuilder cb, Expression field, + FilterOperator operator, String value) { + Expression stringField = field.as(String.class); + return switch (operator) { + case EQUALS -> cb.equal(cb.lower(stringField), value.toLowerCase()); + case CONTAINS -> { + String escaped = escapeLikeWildcards(value.toLowerCase()); + yield cb.like(cb.lower(stringField), "%" + escaped + "%", LIKE_ESCAPE_CHAR); + } + case LESS_THAN -> cb.lessThan(stringField, value); + case GREATER_THAN -> cb.greaterThan(stringField, value); + }; + } + + private static Specification relationNameSpec(FilterCriterion criterion) { + return (root, query, cb) -> { + query.distinct(true); + Join relJoin = root.join(RELATIONS); + return buildPredicate(cb, relJoin.get(NAME), criterion.operator(), criterion.value()); + }; + } + + private static Specification relationsAsTargetNameSpec( + FilterCriterion criterion) { + return (root, query, cb) -> { + // Find entities whose identifier appears as a target in any relation whose name + // matches. + // Uses a correlated subquery to avoid joining through the entity's own outgoing + // relations. + Subquery subquery = query.subquery(String.class); + Root relRoot = subquery.from(RelationJpaEntity.class); + Join targetJoin = relRoot.join(TARGET_ENTITY_IDENTIFIERS); + subquery.select(targetJoin) + .where(buildPredicate(cb, relRoot.get(NAME), criterion.operator(), criterion.value())); + return cb.in(root.get(IDENTIFIER)).value(subquery); + }; + } + + /// Finds entities whose `identifier` appears as a `targetEntityIdentifier` in + /// any + /// relation whose **source entity** property matches the criterion. + /// + /// Example: `relations_as_target.api-link.name:microservice` returns entities + /// that + /// are targeted by a `api-link` relation originating from an entity whose name + /// contains "microservice". + private static Specification relationsAsTargetPropertySpec( + FilterCriterion criterion) { + return (root, query, cb) -> { + String compositeKey = criterion.key(); + int dotIndex = compositeKey.indexOf('.'); + if (dotIndex < 0) { + throw new IllegalArgumentException("Invalid composite key format: " + compositeKey); + } + String relationName = compositeKey.substring(0, dotIndex); + String propertyName = compositeKey.substring(dotIndex + 1); // "identifier" or "name" + + // Subquery: collect all target identifiers from relations named + // that originate from source entities whose matches. + Subquery subquery = query.subquery(String.class); + Root sourceRoot = subquery.from(EntityJpaEntity.class); + Join relJoin = sourceRoot.join(RELATIONS); + Join targetJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); + subquery.select(targetJoin).where(cb.equal(relJoin.get(NAME), relationName), buildPredicate( + cb, sourceRoot.get(propertyName), criterion.operator(), criterion.value())); + return cb.in(root.get(IDENTIFIER)).value(subquery); + }; + } + + /// Escapes SQL LIKE wildcards (`%` and `_`) in the given value so they are + /// treated as literal characters rather than pattern metacharacters. + static String escapeLikeWildcards(String value) { + return value + .replace(String.valueOf(LIKE_ESCAPE_CHAR), + LIKE_ESCAPE_CHAR + String.valueOf(LIKE_ESCAPE_CHAR)) + .replace("%", LIKE_ESCAPE_CHAR + "%").replace("_", LIKE_ESCAPE_CHAR + "_"); + } } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/EntityQueryParserServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/EntityQueryParserServiceTest.java index d3502cc4..24477373 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/EntityQueryParserServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/EntityQueryParserServiceTest.java @@ -24,504 +24,499 @@ @SuppressWarnings("java:S2187") class EntityQueryParserServiceTest { - private final EntityQueryParserService parser = new EntityQueryParserService(); - - private void assertSingleCriterion( - EntityFilter result, - FilterKeyType expectedKeyType, - String expectedKeyName, - FilterOperator expectedOperator, - String expectedValue) { - assertThat(result.criteria()).hasSize(1); - assertCriterion(result.criteria().getFirst(), expectedKeyType, expectedKeyName, expectedOperator, expectedValue); - } - - private void assertCriterion( - FilterCriterion criterion, - FilterKeyType expectedKeyType, - String expectedKeyName, - FilterOperator expectedOperator, - String expectedValue) { - assertThat(criterion.keyType()).isEqualTo(expectedKeyType); - assertThat(criterion.key()).isEqualTo(expectedKeyName); - assertThat(criterion.operator()).isEqualTo(expectedOperator); - assertThat(criterion.value()).isEqualTo(expectedValue); - } - - @Nested - @DisplayName("Attribute filters") - class AttributeFilterTests { - - @Test - @DisplayName("identifier equals") - void parse_attributeIdentifierEquals() { - var result = parser.parse("identifier=web-api-1"); - assertSingleCriterion(result, FilterKeyType.ATTRIBUTE, "identifier", FilterOperator.EQUALS, "web-api-1"); - } - - @Test - @DisplayName("name contains") - void parse_attributeNameContains() { - var result = parser.parse("name:API"); - assertSingleCriterion(result, FilterKeyType.ATTRIBUTE, "name", FilterOperator.CONTAINS, "API"); - } - } - - @Nested - @DisplayName("Property filters") - class PropertyFilterTests { - - @Test - @DisplayName("property equals") - void parse_propertyEquals() { - var result = parser.parse("property.language=JAVA"); - assertSingleCriterion(result, FilterKeyType.PROPERTY, "language", FilterOperator.EQUALS, "JAVA"); - } - - @Test - @DisplayName("property contains") - void parse_propertyContains() { - var result = parser.parse("property.version:1.0"); - assertSingleCriterion(result, FilterKeyType.PROPERTY, "version", FilterOperator.CONTAINS, "1.0"); - } - - @Test - @DisplayName("property less than") - void parse_propertyLessThan() { - var result = parser.parse("property.port<9000"); - assertSingleCriterion(result, FilterKeyType.PROPERTY, "port", FilterOperator.LESS_THAN, "9000"); - } - - @Test - @DisplayName("property greater than") - void parse_propertyGreaterThan() { - var result = parser.parse("property.port>1000"); - assertSingleCriterion(result, FilterKeyType.PROPERTY, "port", FilterOperator.GREATER_THAN, "1000"); - } - } - - @Nested - @DisplayName("Relation name filters") - class RelationNameFilterTests { - - @Test - @DisplayName("relation name equals") - void parse_relationNameEquals() { - var result = parser.parse("relation=api-link"); - assertSingleCriterion(result, FilterKeyType.RELATION_NAME, "", FilterOperator.EQUALS, "api-link"); - } - - @Test - @DisplayName("relation name contains") - void parse_relationNameContains() { - var result = parser.parse("relation:rover"); - assertSingleCriterion(result, FilterKeyType.RELATION_NAME, "", FilterOperator.CONTAINS, "rover"); - } - } - - @Nested - @DisplayName("Relation entity filters") - class RelationEntityFilterTests { - - @Test - @DisplayName("relation entity equals") - void parse_relationEntityEquals() { - var result = parser.parse("relation.database=my-db"); - assertSingleCriterion(result, FilterKeyType.RELATION_ENTITY, "database", FilterOperator.EQUALS, "my-db"); - } - - @Test - @DisplayName("relation entity contains") - void parse_relationEntityContains() { - var result = parser.parse("relation.database:my"); - assertSingleCriterion(result, FilterKeyType.RELATION_ENTITY, "database", FilterOperator.CONTAINS, "my"); - } - } - - @Nested - @DisplayName("Relation property filters") - class RelationPropertyFilterTests { - - @Test - @DisplayName("relation property equals") - void parse_relationPropertyEquals() { - var result = parser.parse("relation.api-link.identifier=microservice-1"); - assertSingleCriterion(result, FilterKeyType.RELATION_PROPERTY, "api-link.identifier", FilterOperator.EQUALS, "microservice-1"); - } - - @Test - @DisplayName("relation property contains") - void parse_relationPropertyContains() { - var result = parser.parse("relation.api-link.name:microservice"); - assertSingleCriterion(result, FilterKeyType.RELATION_PROPERTY, "api-link.name", FilterOperator.CONTAINS, "microservice"); - } - - @Test - @DisplayName("throws InvalidQueryDslException for unsupported property in relation (custom-prop is not identifier or name)") - void parse_relationPropertyUnsupported_throwsException() { - assertThatThrownBy(() -> parser.parse("relation.my-link.custom-prop=value")) - .isInstanceOf(InvalidQueryDslException.class) - .hasMessageContaining("custom-prop") - .hasMessageContaining("identifier") - .hasMessageContaining("name"); - } - } - - @Nested - @DisplayName("Relations as target filters") - class RelationsAsTargetFilterTests { - - @Test - @DisplayName("relations_as_target name equals") - void parse_relationsAsTargetNameEquals() { - var result = parser.parse("relations_as_target=api-link"); - assertSingleCriterion(result, FilterKeyType.RELATIONS_AS_TARGET_NAME, "", FilterOperator.EQUALS, "api-link"); - } - - @Test - @DisplayName("relations_as_target name contains") - void parse_relationsAsTargetNameContains() { - var result = parser.parse("relations_as_target:rover"); - assertSingleCriterion(result, FilterKeyType.RELATIONS_AS_TARGET_NAME, "", FilterOperator.CONTAINS, "rover"); - } - - @Test - @DisplayName("relations_as_target property identifier equals") - void parse_relationsAsTargetPropertyIdentifierEquals() { - var result = parser.parse("relations_as_target.api-link.identifier=web-api-1"); - assertSingleCriterion(result, FilterKeyType.RELATIONS_AS_TARGET_PROPERTY, "api-link.identifier", FilterOperator.EQUALS, "web-api-1"); - } - - @Test - @DisplayName("relations_as_target property name contains") - void parse_relationsAsTargetPropertyNameContains() { - var result = parser.parse("relations_as_target.api-link.name:microservice"); - assertSingleCriterion(result, FilterKeyType.RELATIONS_AS_TARGET_PROPERTY, "api-link.name", FilterOperator.CONTAINS, "microservice"); - } - - @Test - @DisplayName("throws exception for unsupported property in relations_as_target") - void parse_relationsAsTargetInvalidProperty_throwsException() { - assertThatThrownBy(() -> parser.parse("relations_as_target.api-link.language=JAVA")) - .isInstanceOf(InvalidQueryDslException.class) - .hasMessageContaining("only 'identifier' and 'name' are supported"); - } - - @Test - @DisplayName("throws exception for relations_as_target without property") - void parse_relationsAsTargetWithoutProperty_throwsException() { - assertThatThrownBy(() -> parser.parse("relations_as_target.api-link=web-api-1")) - .isInstanceOf(InvalidQueryDslException.class) - .hasMessageContaining("relations_as_target requires the form"); - } - } - - @Nested - @DisplayName("Combined AND criteria") - class CombinedCriteriaTests { - - @Test - @DisplayName("two criteria separated by semicolon") - void parse_twoCriteriaWithSemicolon() { - var result = parser.parse("name:API;property.language=JAVA"); - assertThat(result.criteria()).hasSize(2); - assertCriterion(result.criteria().get(0), FilterKeyType.ATTRIBUTE, "name", FilterOperator.CONTAINS, "API"); - assertCriterion(result.criteria().get(1), FilterKeyType.PROPERTY, "language", FilterOperator.EQUALS, "JAVA"); - } - - @Test - @DisplayName("four criteria of different key types") - void parse_fourCriteria() { - var result = parser.parse("name:API;property.language=JAVA;relation.database=my-db;relation.api-link.identifier=service-1"); - assertThat(result.criteria()).hasSize(4); - assertCriterion(result.criteria().get(0), FilterKeyType.ATTRIBUTE, "name", FilterOperator.CONTAINS, "API"); - assertCriterion(result.criteria().get(1), FilterKeyType.PROPERTY, "language", FilterOperator.EQUALS, "JAVA"); - assertCriterion(result.criteria().get(2), FilterKeyType.RELATION_ENTITY, "database", FilterOperator.EQUALS, "my-db"); - assertCriterion(result.criteria().get(3), FilterKeyType.RELATION_PROPERTY, "api-link.identifier", FilterOperator.EQUALS, "service-1"); - } - - @Test - @DisplayName("five criteria including relation property and reverse relation") - void parse_fiveCriteriaWithRelationProperty() { - var result = parser.parse("name:API;property.language=JAVA;relation.database=my-db;relation.api-link.identifier=service-1;relations_as_target.owned_by.name:platform"); - assertThat(result.criteria()).hasSize(5); - assertCriterion(result.criteria().get(0), FilterKeyType.ATTRIBUTE, "name", FilterOperator.CONTAINS, "API"); - assertCriterion(result.criteria().get(1), FilterKeyType.PROPERTY, "language", FilterOperator.EQUALS, "JAVA"); - assertCriterion(result.criteria().get(2), FilterKeyType.RELATION_ENTITY, "database", FilterOperator.EQUALS, "my-db"); - assertCriterion(result.criteria().get(3), FilterKeyType.RELATION_PROPERTY, "api-link.identifier", FilterOperator.EQUALS, "service-1"); - assertCriterion(result.criteria().get(4), FilterKeyType.RELATIONS_AS_TARGET_PROPERTY, "owned_by.name", FilterOperator.CONTAINS, "platform"); - } - } - - @Nested - @DisplayName("Invalid query syntax") - class InvalidQueryTests { - - @ParameterizedTest(name = "missing operator in: ''{0}''") - @ValueSource(strings = {"noOperatorHere", "property.lang", "relation.db"}) - @DisplayName("throws InvalidQueryDslException when operator is missing") - void parse_missingOperator_throwsException(String query) { - assertThatThrownBy(() -> parser.parse(query)) - .isInstanceOf(InvalidQueryDslException.class) - .hasMessage(ValidationMessages.FILTER_INVALID_FORMAT); - } - - @Test - @DisplayName("throws InvalidQueryDslException for unknown attribute") - void parse_unknownAttribute_throwsException() { - assertThatThrownBy(() -> parser.parse("unknownField=value")) - .isInstanceOf(InvalidQueryDslException.class) - .hasMessageContaining("Unknown attribute"); - } - - @Test - @DisplayName("throws InvalidQueryDslException for blank value") - void parse_blankValue_throwsException() { - assertThatThrownBy(() -> parser.parse("name=")) - .isInstanceOf(InvalidQueryDslException.class) - .hasMessageContaining("value must not be blank"); - } - - @Test - @DisplayName("throws InvalidQueryDslException for blank key") - void parse_blankKey_throwsException() { - assertThatThrownBy(() -> parser.parse("=value")) - .isInstanceOf(InvalidQueryDslException.class) - .hasMessageContaining("key must not be blank"); - } - - @Test - @DisplayName("throws InvalidQueryDslException for blank property name after prefix") - void parse_blankPropertyName_throwsException() { - assertThatThrownBy(() -> parser.parse("property.=JAVA")) - .isInstanceOf(InvalidQueryDslException.class) - .hasMessageContaining("key name must not be blank"); - } - } - - @Nested - @DisplayName("Security constraints") - class SecurityConstraintTests { - - @Test - @DisplayName("throws InvalidQueryDslException when criteria count exceeds limit") - void parse_tooManyCriteria_throwsException() { - var query = "property.a=1;property.b=2;property.c=3;property.d=4;property.e=5;" - + "property.f=6;property.g=7;property.h=8;property.i=9;property.j=10;" - + "property.k=11"; - assertThatThrownBy(() -> parser.parse(query)) - .isInstanceOf(InvalidQueryDslException.class) - .hasMessageContaining("maximum of %d".formatted(EntityQueryParserService.MAX_CRITERIA_COUNT)); - } - - @Test - @DisplayName("accepts exactly the maximum number of criteria") - void parse_exactlyMaxCriteria_succeeds() { - var query = "property.a=1;property.b=2;property.c=3;property.d=4;property.e=5;" - + "property.f=6;property.g=7;property.h=8;property.i=9;property.j=10"; - var result = parser.parse(query); - assertThat(result.criteria()).hasSize(EntityQueryParserService.MAX_CRITERIA_COUNT); - } - - @Test - @DisplayName("throws InvalidQueryDslException when value exceeds max length") - void parse_valueTooLong_throwsException() { - var longValue = "a".repeat(EntityQueryParserService.MAX_KEY_VALUE_LENGTH + 1); - assertThatThrownBy(() -> parser.parse("name=" + longValue)) - .isInstanceOf(InvalidQueryDslException.class) - .hasMessageContaining("must not exceed %d".formatted(EntityQueryParserService.MAX_KEY_VALUE_LENGTH)); - } - - @Test - @DisplayName("throws InvalidQueryDslException when key exceeds max length") - void parse_keyTooLong_throwsException() { - var longKey = "property." + "a".repeat(EntityQueryParserService.MAX_KEY_VALUE_LENGTH); - assertThatThrownBy(() -> parser.parse(longKey + "=value")) - .isInstanceOf(InvalidQueryDslException.class) - .hasMessageContaining("must not exceed %d".formatted(EntityQueryParserService.MAX_KEY_VALUE_LENGTH)); - } - - @ParameterizedTest(name = "valid key name: ''{0}''") - @ValueSource(strings = { - "property.language=JAVA", - "property.my-key=value", - "property.my_key=value", - "property.key123=value", - "property.lang@ge=JAVA", - "property.my key=JAVA", - "property.lang/age=JAVA", - "relation.database=my-db", - "relation.db$name=my-db", - "relation.my-cache.identifier=redis-1" - }) - @DisplayName("accepts valid key name characters") - void parse_validKeyNameChars_succeeds(String query) { - var result = parser.parse(query); - assertThat(result.criteria()).hasSize(1); - } - } - - @Nested - @DisplayName("Duplicate criterion detection") - class DuplicateCriterionTests { - - @ParameterizedTest(name = "duplicate criterion in: ''{0}''") - @ValueSource(strings = { - "name=A;name=B", - "property.language=JAVA;property.language=PYTHON", - "relation=api-link;relation=database" - }) - @DisplayName("throws InvalidQueryDslException for duplicate criteria") - void parse_duplicateCriterion_throwsException(String query) { - assertThatThrownBy(() -> parser.parse(query)) - .isInstanceOf(InvalidQueryDslException.class) - .hasMessage(ValidationMessages.FILTER_DUPLICATE_CRITERION); - } - - @Test - @DisplayName("accepts distinct attribute criteria") - void parse_distinctAttributeCriteria_succeeds() { - var result = parser.parse("identifier=web-api-1;name=Web API 1"); - assertThat(result.criteria()).hasSize(2); - } - - @Test - @DisplayName("accepts distinct property criteria") - void parse_distinctPropertyCriteria_succeeds() { - var result = parser.parse("property.language=JAVA;property.environment=PROD"); - assertThat(result.criteria()).hasSize(2); - } - } - - @Nested - @DisplayName("Type mismatch validation") - class TypeMismatchTests { - - @ParameterizedTest(name = "comparison operator on: ''{0}''") - @ValueSource(strings = {"relationapi-link"}) - @DisplayName("throws InvalidQueryDslException for less/greater than on relation name") - void parse_comparisonOnRelationName_throwsException(String query) { - assertThatThrownBy(() -> parser.parse(query)) - .isInstanceOf(InvalidQueryDslException.class) - .hasMessageContaining("is not applicable for field"); - } - - @ParameterizedTest(name = "comparison operator on: ''{0}''") - @ValueSource(strings = {"relation.databasemy-db"}) - @DisplayName("throws InvalidQueryDslException for less/greater than on relation entity") - void parse_comparisonOnRelationEntity_throwsException(String query) { - assertThatThrownBy(() -> parser.parse(query)) - .isInstanceOf(InvalidQueryDslException.class) - .hasMessageContaining("is not applicable for field"); - } - - @ParameterizedTest(name = "comparison operator on: ''{0}''") - @ValueSource(strings = {"relation.database.templatepostgresql"}) - @DisplayName("throws InvalidQueryDslException for unsupported property on relation (template is not a valid relation property)") - void parse_comparisonOnRelationTemplate_throwsException(String query) { - assertThatThrownBy(() -> parser.parse(query)) - .isInstanceOf(InvalidQueryDslException.class) - .hasMessageContaining("template"); - } - - @Test - @DisplayName("throws InvalidQueryDslException for unsupported property on relation with equals operator") - void parse_equalsOnRelationTemplate_throwsException() { - assertThatThrownBy(() -> parser.parse("relation.database.template=postgresql")) - .isInstanceOf(InvalidQueryDslException.class) - .hasMessageContaining("template") - .hasMessageContaining("identifier") - .hasMessageContaining("name"); - } - - @ParameterizedTest(name = "comparison operator on: ''{0}''") - @ValueSource(strings = {"relation.api-link.identifiermicroservice-1"}) - @DisplayName("throws InvalidQueryDslException for less/greater than on relation property") - void parse_comparisonOnRelationProperty_throwsException(String query) { - assertThatThrownBy(() -> parser.parse(query)) - .isInstanceOf(InvalidQueryDslException.class) - .hasMessageContaining("is not applicable for field"); - } - - @ParameterizedTest(name = "comparison operator on: ''{0}''") - @ValueSource(strings = {"relations_as_target.api-link.namemicroservice"}) - @DisplayName("throws InvalidQueryDslException for less/greater than on relations_as_target property") - void parse_comparisonOnRelationsAsTargetProperty_throwsException(String query) { - assertThatThrownBy(() -> parser.parse(query)) - .isInstanceOf(InvalidQueryDslException.class) - .hasMessageContaining("is not applicable for field"); - } - - @ParameterizedTest(name = "comparison operator on: ''{0}''") - @ValueSource(strings = {"nameA", "identifier parser.parse(query)) - .isInstanceOf(InvalidQueryDslException.class) - .hasMessageContaining("is not applicable for field"); - } - - @ParameterizedTest(name = "comparison operator on: ''{0}''") - @ValueSource(strings = {"property.port<9000", "property.port>1000"}) - @DisplayName("accepts less/greater than on NUMBER properties (type check is deferred to EntityService)") - void parse_comparisonOnProperty_succeeds(String query) { - var result = parser.parse(query); - assertThat(result.criteria()).hasSize(1); - } - } - - @Nested - @DisplayName("Edge cases") - class EdgeCaseTests { - - @Test - @DisplayName("consecutive semicolons produce empty filter") - void parse_consecutiveSemicolons_ignoresEmptyTokens() { - var result = parser.parse("name=API;;property.lang=JAVA"); - assertThat(result.criteria()).hasSize(2); - } - - @Test - @DisplayName("trailing semicolon is ignored") - void parse_trailingSemicolon_ignored() { - var result = parser.parse("name=API;"); - assertThat(result.criteria()).hasSize(1); - } - - @Test - @DisplayName("leading semicolon is ignored") - void parse_leadingSemicolon_ignored() { - var result = parser.parse(";name=API"); - assertThat(result.criteria()).hasSize(1); - } - - @Test - @DisplayName("values containing SQL LIKE wildcards are accepted") - void parse_valuesWithLikeWildcards_accepted() { - var result = parser.parse("name:100%_success"); - assertSingleCriterion(result, FilterKeyType.ATTRIBUTE, "name", FilterOperator.CONTAINS, "100%_success"); - } - } - - @Nested - @DisplayName("Null or blank query") - class NullOrBlankQueryTests { - - @ParameterizedTest(name = "returns empty filter for: {0}") - @MethodSource("provideNullOrBlankQueries") - @DisplayName("parse(null/empty/blank) returns empty filter with no criteria") - void parse_nullOrBlankQuery_returnsEmptyFilter(String query) { - var result = parser.parse(query); - assertThat(result.criteria()).isEmpty(); - } - - private static Stream provideNullOrBlankQueries() { - return Stream.of( - Arguments.of((String) null), - Arguments.of(""), - Arguments.of(" ") - ); - } + private final EntityQueryParserService parser = new EntityQueryParserService(); + + private void assertSingleCriterion(EntityFilter result, FilterKeyType expectedKeyType, + String expectedKeyName, FilterOperator expectedOperator, String expectedValue) { + assertThat(result.criteria()).hasSize(1); + assertCriterion(result.criteria().getFirst(), expectedKeyType, expectedKeyName, + expectedOperator, expectedValue); + } + + private void assertCriterion(FilterCriterion criterion, FilterKeyType expectedKeyType, + String expectedKeyName, FilterOperator expectedOperator, String expectedValue) { + assertThat(criterion.keyType()).isEqualTo(expectedKeyType); + assertThat(criterion.key()).isEqualTo(expectedKeyName); + assertThat(criterion.operator()).isEqualTo(expectedOperator); + assertThat(criterion.value()).isEqualTo(expectedValue); + } + + @Nested + @DisplayName("Attribute filters") + class AttributeFilterTests { + + @Test + @DisplayName("identifier equals") + void parse_attributeIdentifierEquals() { + var result = parser.parse("identifier=web-api-1"); + assertSingleCriterion(result, FilterKeyType.ATTRIBUTE, "identifier", FilterOperator.EQUALS, + "web-api-1"); } + @Test + @DisplayName("name contains") + void parse_attributeNameContains() { + var result = parser.parse("name:API"); + assertSingleCriterion(result, FilterKeyType.ATTRIBUTE, "name", FilterOperator.CONTAINS, + "API"); + } + } + + @Nested + @DisplayName("Property filters") + class PropertyFilterTests { + + @Test + @DisplayName("property equals") + void parse_propertyEquals() { + var result = parser.parse("property.language=JAVA"); + assertSingleCriterion(result, FilterKeyType.PROPERTY, "language", FilterOperator.EQUALS, + "JAVA"); + } + + @Test + @DisplayName("property contains") + void parse_propertyContains() { + var result = parser.parse("property.version:1.0"); + assertSingleCriterion(result, FilterKeyType.PROPERTY, "version", FilterOperator.CONTAINS, + "1.0"); + } + + @Test + @DisplayName("property less than") + void parse_propertyLessThan() { + var result = parser.parse("property.port<9000"); + assertSingleCriterion(result, FilterKeyType.PROPERTY, "port", FilterOperator.LESS_THAN, + "9000"); + } + + @Test + @DisplayName("property greater than") + void parse_propertyGreaterThan() { + var result = parser.parse("property.port>1000"); + assertSingleCriterion(result, FilterKeyType.PROPERTY, "port", FilterOperator.GREATER_THAN, + "1000"); + } + } + + @Nested + @DisplayName("Relation name filters") + class RelationNameFilterTests { + + @Test + @DisplayName("relation name equals") + void parse_relationNameEquals() { + var result = parser.parse("relation=api-link"); + assertSingleCriterion(result, FilterKeyType.RELATION_NAME, "", FilterOperator.EQUALS, + "api-link"); + } + + @Test + @DisplayName("relation name contains") + void parse_relationNameContains() { + var result = parser.parse("relation:rover"); + assertSingleCriterion(result, FilterKeyType.RELATION_NAME, "", FilterOperator.CONTAINS, + "rover"); + } + } + + @Nested + @DisplayName("Relation entity filters") + class RelationEntityFilterTests { + + @Test + @DisplayName("relation entity equals") + void parse_relationEntityEquals() { + var result = parser.parse("relation.database=my-db"); + assertSingleCriterion(result, FilterKeyType.RELATION_ENTITY, "database", + FilterOperator.EQUALS, "my-db"); + } + + @Test + @DisplayName("relation entity contains") + void parse_relationEntityContains() { + var result = parser.parse("relation.database:my"); + assertSingleCriterion(result, FilterKeyType.RELATION_ENTITY, "database", + FilterOperator.CONTAINS, "my"); + } + } + + @Nested + @DisplayName("Relation property filters") + class RelationPropertyFilterTests { + + @Test + @DisplayName("relation property equals") + void parse_relationPropertyEquals() { + var result = parser.parse("relation.api-link.identifier=microservice-1"); + assertSingleCriterion(result, FilterKeyType.RELATION_PROPERTY, "api-link.identifier", + FilterOperator.EQUALS, "microservice-1"); + } + + @Test + @DisplayName("relation property contains") + void parse_relationPropertyContains() { + var result = parser.parse("relation.api-link.name:microservice"); + assertSingleCriterion(result, FilterKeyType.RELATION_PROPERTY, "api-link.name", + FilterOperator.CONTAINS, "microservice"); + } + + @Test + @DisplayName("throws InvalidQueryDslException for unsupported property in relation (custom-prop is not identifier or name)") + void parse_relationPropertyUnsupported_throwsException() { + assertThatThrownBy(() -> parser.parse("relation.my-link.custom-prop=value")) + .isInstanceOf(InvalidQueryDslException.class).hasMessageContaining("custom-prop") + .hasMessageContaining("identifier").hasMessageContaining("name"); + } + } + + @Nested + @DisplayName("Relations as target filters") + class RelationsAsTargetFilterTests { + + @Test + @DisplayName("relations_as_target name equals") + void parse_relationsAsTargetNameEquals() { + var result = parser.parse("relations_as_target=api-link"); + assertSingleCriterion(result, FilterKeyType.RELATIONS_AS_TARGET_NAME, "", + FilterOperator.EQUALS, "api-link"); + } + + @Test + @DisplayName("relations_as_target name contains") + void parse_relationsAsTargetNameContains() { + var result = parser.parse("relations_as_target:rover"); + assertSingleCriterion(result, FilterKeyType.RELATIONS_AS_TARGET_NAME, "", + FilterOperator.CONTAINS, "rover"); + } + + @Test + @DisplayName("relations_as_target property identifier equals") + void parse_relationsAsTargetPropertyIdentifierEquals() { + var result = parser.parse("relations_as_target.api-link.identifier=web-api-1"); + assertSingleCriterion(result, FilterKeyType.RELATIONS_AS_TARGET_PROPERTY, + "api-link.identifier", FilterOperator.EQUALS, "web-api-1"); + } + + @Test + @DisplayName("relations_as_target property name contains") + void parse_relationsAsTargetPropertyNameContains() { + var result = parser.parse("relations_as_target.api-link.name:microservice"); + assertSingleCriterion(result, FilterKeyType.RELATIONS_AS_TARGET_PROPERTY, "api-link.name", + FilterOperator.CONTAINS, "microservice"); + } + + @Test + @DisplayName("throws exception for unsupported property in relations_as_target") + void parse_relationsAsTargetInvalidProperty_throwsException() { + assertThatThrownBy(() -> parser.parse("relations_as_target.api-link.language=JAVA")) + .isInstanceOf(InvalidQueryDslException.class) + .hasMessageContaining("only 'identifier' and 'name' are supported"); + } + + @Test + @DisplayName("throws exception for relations_as_target without property") + void parse_relationsAsTargetWithoutProperty_throwsException() { + assertThatThrownBy(() -> parser.parse("relations_as_target.api-link=web-api-1")) + .isInstanceOf(InvalidQueryDslException.class) + .hasMessageContaining("relations_as_target requires the form"); + } + } + + @Nested + @DisplayName("Combined AND criteria") + class CombinedCriteriaTests { + + @Test + @DisplayName("two criteria separated by semicolon") + void parse_twoCriteriaWithSemicolon() { + var result = parser.parse("name:API;property.language=JAVA"); + assertThat(result.criteria()).hasSize(2); + assertCriterion(result.criteria().get(0), FilterKeyType.ATTRIBUTE, "name", + FilterOperator.CONTAINS, "API"); + assertCriterion(result.criteria().get(1), FilterKeyType.PROPERTY, "language", + FilterOperator.EQUALS, "JAVA"); + } + + @Test + @DisplayName("four criteria of different key types") + void parse_fourCriteria() { + var result = parser.parse( + "name:API;property.language=JAVA;relation.database=my-db;relation.api-link.identifier=service-1"); + assertThat(result.criteria()).hasSize(4); + assertCriterion(result.criteria().get(0), FilterKeyType.ATTRIBUTE, "name", + FilterOperator.CONTAINS, "API"); + assertCriterion(result.criteria().get(1), FilterKeyType.PROPERTY, "language", + FilterOperator.EQUALS, "JAVA"); + assertCriterion(result.criteria().get(2), FilterKeyType.RELATION_ENTITY, "database", + FilterOperator.EQUALS, "my-db"); + assertCriterion(result.criteria().get(3), FilterKeyType.RELATION_PROPERTY, + "api-link.identifier", FilterOperator.EQUALS, "service-1"); + } + + @Test + @DisplayName("five criteria including relation property and reverse relation") + void parse_fiveCriteriaWithRelationProperty() { + var result = parser.parse( + "name:API;property.language=JAVA;relation.database=my-db;relation.api-link.identifier=service-1;relations_as_target.owned_by.name:platform"); + assertThat(result.criteria()).hasSize(5); + assertCriterion(result.criteria().get(0), FilterKeyType.ATTRIBUTE, "name", + FilterOperator.CONTAINS, "API"); + assertCriterion(result.criteria().get(1), FilterKeyType.PROPERTY, "language", + FilterOperator.EQUALS, "JAVA"); + assertCriterion(result.criteria().get(2), FilterKeyType.RELATION_ENTITY, "database", + FilterOperator.EQUALS, "my-db"); + assertCriterion(result.criteria().get(3), FilterKeyType.RELATION_PROPERTY, + "api-link.identifier", FilterOperator.EQUALS, "service-1"); + assertCriterion(result.criteria().get(4), FilterKeyType.RELATIONS_AS_TARGET_PROPERTY, + "owned_by.name", FilterOperator.CONTAINS, "platform"); + } + } + + @Nested + @DisplayName("Invalid query syntax") + class InvalidQueryTests { + + @ParameterizedTest(name = "missing operator in: ''{0}''") + @ValueSource(strings = {"noOperatorHere", "property.lang", "relation.db"}) + @DisplayName("throws InvalidQueryDslException when operator is missing") + void parse_missingOperator_throwsException(String query) { + assertThatThrownBy(() -> parser.parse(query)).isInstanceOf(InvalidQueryDslException.class) + .hasMessage(ValidationMessages.FILTER_INVALID_FORMAT); + } + + @Test + @DisplayName("throws InvalidQueryDslException for unknown attribute") + void parse_unknownAttribute_throwsException() { + assertThatThrownBy(() -> parser.parse("unknownField=value")) + .isInstanceOf(InvalidQueryDslException.class).hasMessageContaining("Unknown attribute"); + } + + @Test + @DisplayName("throws InvalidQueryDslException for blank value") + void parse_blankValue_throwsException() { + assertThatThrownBy(() -> parser.parse("name=")).isInstanceOf(InvalidQueryDslException.class) + .hasMessageContaining("value must not be blank"); + } + + @Test + @DisplayName("throws InvalidQueryDslException for blank key") + void parse_blankKey_throwsException() { + assertThatThrownBy(() -> parser.parse("=value")).isInstanceOf(InvalidQueryDslException.class) + .hasMessageContaining("key must not be blank"); + } + + @Test + @DisplayName("throws InvalidQueryDslException for blank property name after prefix") + void parse_blankPropertyName_throwsException() { + assertThatThrownBy(() -> parser.parse("property.=JAVA")) + .isInstanceOf(InvalidQueryDslException.class) + .hasMessageContaining("key name must not be blank"); + } + } + + @Nested + @DisplayName("Security constraints") + class SecurityConstraintTests { + + @Test + @DisplayName("throws InvalidQueryDslException when criteria count exceeds limit") + void parse_tooManyCriteria_throwsException() { + var query = "property.a=1;property.b=2;property.c=3;property.d=4;property.e=5;" + + "property.f=6;property.g=7;property.h=8;property.i=9;property.j=10;" + "property.k=11"; + assertThatThrownBy(() -> parser.parse(query)).isInstanceOf(InvalidQueryDslException.class) + .hasMessageContaining( + "maximum of %d".formatted(EntityQueryParserService.MAX_CRITERIA_COUNT)); + } + + @Test + @DisplayName("accepts exactly the maximum number of criteria") + void parse_exactlyMaxCriteria_succeeds() { + var query = "property.a=1;property.b=2;property.c=3;property.d=4;property.e=5;" + + "property.f=6;property.g=7;property.h=8;property.i=9;property.j=10"; + var result = parser.parse(query); + assertThat(result.criteria()).hasSize(EntityQueryParserService.MAX_CRITERIA_COUNT); + } + + @Test + @DisplayName("throws InvalidQueryDslException when value exceeds max length") + void parse_valueTooLong_throwsException() { + var longValue = "a".repeat(EntityQueryParserService.MAX_KEY_VALUE_LENGTH + 1); + assertThatThrownBy(() -> parser.parse("name=" + longValue)) + .isInstanceOf(InvalidQueryDslException.class).hasMessageContaining( + "must not exceed %d".formatted(EntityQueryParserService.MAX_KEY_VALUE_LENGTH)); + } + + @Test + @DisplayName("throws InvalidQueryDslException when key exceeds max length") + void parse_keyTooLong_throwsException() { + var longKey = "property." + "a".repeat(EntityQueryParserService.MAX_KEY_VALUE_LENGTH); + assertThatThrownBy(() -> parser.parse(longKey + "=value")) + .isInstanceOf(InvalidQueryDslException.class).hasMessageContaining( + "must not exceed %d".formatted(EntityQueryParserService.MAX_KEY_VALUE_LENGTH)); + } + + @ParameterizedTest(name = "valid key name: ''{0}''") + @ValueSource(strings = {"property.language=JAVA", "property.my-key=value", + "property.my_key=value", "property.key123=value", "property.lang@ge=JAVA", + "property.my key=JAVA", "property.lang/age=JAVA", "relation.database=my-db", + "relation.db$name=my-db", "relation.my-cache.identifier=redis-1"}) + @DisplayName("accepts valid key name characters") + void parse_validKeyNameChars_succeeds(String query) { + var result = parser.parse(query); + assertThat(result.criteria()).hasSize(1); + } + } + + @Nested + @DisplayName("Duplicate criterion detection") + class DuplicateCriterionTests { + + @ParameterizedTest(name = "duplicate criterion in: ''{0}''") + @ValueSource(strings = {"name=A;name=B", "property.language=JAVA;property.language=PYTHON", + "relation=api-link;relation=database"}) + @DisplayName("throws InvalidQueryDslException for duplicate criteria") + void parse_duplicateCriterion_throwsException(String query) { + assertThatThrownBy(() -> parser.parse(query)).isInstanceOf(InvalidQueryDslException.class) + .hasMessage(ValidationMessages.FILTER_DUPLICATE_CRITERION); + } + + @Test + @DisplayName("accepts distinct attribute criteria") + void parse_distinctAttributeCriteria_succeeds() { + var result = parser.parse("identifier=web-api-1;name=Web API 1"); + assertThat(result.criteria()).hasSize(2); + } + + @Test + @DisplayName("accepts distinct property criteria") + void parse_distinctPropertyCriteria_succeeds() { + var result = parser.parse("property.language=JAVA;property.environment=PROD"); + assertThat(result.criteria()).hasSize(2); + } + } + + @Nested + @DisplayName("Type mismatch validation") + class TypeMismatchTests { + + @ParameterizedTest(name = "comparison operator on: ''{0}''") + @ValueSource(strings = {"relationapi-link"}) + @DisplayName("throws InvalidQueryDslException for less/greater than on relation name") + void parse_comparisonOnRelationName_throwsException(String query) { + assertThatThrownBy(() -> parser.parse(query)).isInstanceOf(InvalidQueryDslException.class) + .hasMessageContaining("is not applicable for field"); + } + + @ParameterizedTest(name = "comparison operator on: ''{0}''") + @ValueSource(strings = {"relation.databasemy-db"}) + @DisplayName("throws InvalidQueryDslException for less/greater than on relation entity") + void parse_comparisonOnRelationEntity_throwsException(String query) { + assertThatThrownBy(() -> parser.parse(query)).isInstanceOf(InvalidQueryDslException.class) + .hasMessageContaining("is not applicable for field"); + } + + @ParameterizedTest(name = "comparison operator on: ''{0}''") + @ValueSource(strings = {"relation.database.templatepostgresql"}) + @DisplayName("throws InvalidQueryDslException for unsupported property on relation (template is not a valid relation property)") + void parse_comparisonOnRelationTemplate_throwsException(String query) { + assertThatThrownBy(() -> parser.parse(query)).isInstanceOf(InvalidQueryDslException.class) + .hasMessageContaining("template"); + } + + @Test + @DisplayName("throws InvalidQueryDslException for unsupported property on relation with equals operator") + void parse_equalsOnRelationTemplate_throwsException() { + assertThatThrownBy(() -> parser.parse("relation.database.template=postgresql")) + .isInstanceOf(InvalidQueryDslException.class).hasMessageContaining("template") + .hasMessageContaining("identifier").hasMessageContaining("name"); + } + + @ParameterizedTest(name = "comparison operator on: ''{0}''") + @ValueSource(strings = {"relation.api-link.identifiermicroservice-1"}) + @DisplayName("throws InvalidQueryDslException for less/greater than on relation property") + void parse_comparisonOnRelationProperty_throwsException(String query) { + assertThatThrownBy(() -> parser.parse(query)).isInstanceOf(InvalidQueryDslException.class) + .hasMessageContaining("is not applicable for field"); + } + + @ParameterizedTest(name = "comparison operator on: ''{0}''") + @ValueSource(strings = {"relations_as_target.api-link.namemicroservice"}) + @DisplayName("throws InvalidQueryDslException for less/greater than on relations_as_target property") + void parse_comparisonOnRelationsAsTargetProperty_throwsException(String query) { + assertThatThrownBy(() -> parser.parse(query)).isInstanceOf(InvalidQueryDslException.class) + .hasMessageContaining("is not applicable for field"); + } + + @ParameterizedTest(name = "comparison operator on: ''{0}''") + @ValueSource(strings = {"nameA", "identifier parser.parse(query)).isInstanceOf(InvalidQueryDslException.class) + .hasMessageContaining("is not applicable for field"); + } + + @ParameterizedTest(name = "comparison operator on: ''{0}''") + @ValueSource(strings = {"property.port<9000", "property.port>1000"}) + @DisplayName("accepts less/greater than on NUMBER properties (type check is deferred to EntityService)") + void parse_comparisonOnProperty_succeeds(String query) { + var result = parser.parse(query); + assertThat(result.criteria()).hasSize(1); + } + } + + @Nested + @DisplayName("Edge cases") + class EdgeCaseTests { + + @Test + @DisplayName("consecutive semicolons produce empty filter") + void parse_consecutiveSemicolons_ignoresEmptyTokens() { + var result = parser.parse("name=API;;property.lang=JAVA"); + assertThat(result.criteria()).hasSize(2); + } + + @Test + @DisplayName("trailing semicolon is ignored") + void parse_trailingSemicolon_ignored() { + var result = parser.parse("name=API;"); + assertThat(result.criteria()).hasSize(1); + } + + @Test + @DisplayName("leading semicolon is ignored") + void parse_leadingSemicolon_ignored() { + var result = parser.parse(";name=API"); + assertThat(result.criteria()).hasSize(1); + } + + @Test + @DisplayName("values containing SQL LIKE wildcards are accepted") + void parse_valuesWithLikeWildcards_accepted() { + var result = parser.parse("name:100%_success"); + assertSingleCriterion(result, FilterKeyType.ATTRIBUTE, "name", FilterOperator.CONTAINS, + "100%_success"); + } + } + + @Nested + @DisplayName("Null or blank query") + class NullOrBlankQueryTests { + + @ParameterizedTest(name = "returns empty filter for: {0}") + @MethodSource("provideNullOrBlankQueries") + @DisplayName("parse(null/empty/blank) returns empty filter with no criteria") + void parse_nullOrBlankQuery_returnsEmptyFilter(String query) { + var result = parser.parse(query); + assertThat(result.criteria()).isEmpty(); + } + + private static Stream provideNullOrBlankQueries() { + return Stream.of(Arguments.of((String) null), Arguments.of(""), Arguments.of(" ")); + } + } + } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java index 001e221c..747c7605 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java @@ -24,11 +24,11 @@ import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; +import com.decathlon.idp_core.domain.constant.ValidationMessages; import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; -import com.decathlon.idp_core.domain.constant.ValidationMessages; 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; @@ -42,201 +42,218 @@ @DisplayName("EntityService Tests") class EntityServiceTest { - @Mock - private EntityRepositoryPort entityRepository; - - - @Mock - private EntityValidationService entityValidationService; - - @Mock - private EntityTemplateValidationService entityTemplateValidationService; - - @Mock - private EntityTemplateService entityTemplateService; - - @Mock - private EntityQueryParserService entityQueryParserService; - - @InjectMocks - private EntityService entityService; - - @Test - @DisplayName("Should return entities page by template identifier") - void shouldReturnEntitiesByTemplateIdentifier() { - var pageable = Pageable.ofSize(10); - var entity = entity("template-a", "entity-a", "Entity A"); - var page = new PageImpl<>(List.of(entity)); - var template = new EntityTemplate(UUID.randomUUID(), "template-a", "Template A", "desc", List.of(), - List.of()); - - when(entityTemplateService.getEntityTemplateByIdentifier("template-a")).thenReturn(template); - when(entityRepository.findByTemplateIdentifierWithFilter("template-a", EntityFilter.empty(), pageable)) - .thenReturn(page); - - var result = entityService.getEntitiesByTemplateIdentifier(pageable, "template-a", null); - - assertSame(page, result); - verify(entityTemplateService).getEntityTemplateByIdentifier("template-a"); - verify(entityQueryParserService).validateFilterPropertyTypes(EntityFilter.empty(), template); - verify(entityRepository).findByTemplateIdentifierWithFilter("template-a", EntityFilter.empty(), pageable); - } - - @Test - @DisplayName("Should return entity summaries by identifiers") - void shouldReturnEntitySummariesByIdentifiers() { - var summaries = List.of(new EntitySummary("service-a", "Service A", "web-service")); - when(entityRepository.findByIdentifierIn(List.of("service-a"))).thenReturn(summaries); - - var result = entityService.getEntitiesSummariesByIdentifiers(List.of("service-a")); - - assertEquals(summaries, result); - verify(entityRepository).findByIdentifierIn(List.of("service-a")); - } - - @Test - @DisplayName("Should return entity by template and identifier") - void shouldReturnEntityByTemplateAndIdentifier() { - var entity = entity("web-service", "catalog-api", "Catalog API"); - when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) - .thenReturn(Optional.of(entity)); - - var result = entityService.getEntityByTemplateIdentifierAndIdentifier("web-service", "catalog-api"); - - assertSame(entity, result); - verify(entityTemplateValidationService).validateTemplateExists("web-service"); - verify(entityRepository).findByTemplateIdentifierAndIdentifier("web-service", "catalog-api"); - } - - @Test - @DisplayName("Should throw when entity is not found for template") - void shouldThrowWhenEntityNotFoundByTemplateAndIdentifier() { - when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "missing-entity")) - .thenReturn(Optional.empty()); - - assertThrows(EntityNotFoundException.class, - () -> entityService.getEntityByTemplateIdentifierAndIdentifier("web-service", "missing-entity")); - } - - @Test - @DisplayName("Should create entity when validations pass") - void shouldCreateEntityWhenValidationsPass() { - var entity = entity("web-service", "catalog-api", "Catalog API"); - var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", List.of(), - List.of()); - when(entityTemplateService.getEntityTemplateByIdentifier("web-service")).thenReturn(template); - when(entityRepository.save(entity)).thenReturn(entity); - - var result = entityService.createEntity(entity); - - assertSame(entity, result); - - InOrder inOrder = inOrder(entityTemplateService, entityValidationService, entityRepository); - inOrder.verify(entityTemplateService).getEntityTemplateByIdentifier("web-service"); - inOrder.verify(entityValidationService).validateForCreation(entity, template); - inOrder.verify(entityRepository).save(entity); - verifyNoInteractions(entityTemplateValidationService); - } - - @Test - @DisplayName("Should not save when entity already exists") - void shouldNotSaveWhenEntityAlreadyExists() { - var entity = entity("web-service", "catalog-api", "Catalog API"); - var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", List.of(), - List.of()); - var alreadyExists = new EntityAlreadyExistsException("web-service", "catalog-api"); - - when(entityTemplateService.getEntityTemplateByIdentifier("web-service")).thenReturn(template); - doThrow(alreadyExists).when(entityValidationService).validateForCreation(entity, template); - - assertThrows(EntityAlreadyExistsException.class, () -> entityService.createEntity(entity)); - - verify(entityTemplateService).getEntityTemplateByIdentifier("web-service"); - verify(entityValidationService).validateForCreation(entity, template); - verifyNoMoreInteractions(entityRepository); - } - - @Test - @DisplayName("Should stop immediately when template does not exist") - void shouldStopWhenTemplateDoesNotExistOnCreate() { - var entity = entity("missing-template", "catalog-api", "Catalog API"); - - when(entityTemplateService.getEntityTemplateByIdentifier("missing-template")) - .thenThrow(new EntityTemplateNotFoundException("identifier", "missing-template")); - - assertThrows(EntityTemplateNotFoundException.class, () -> entityService.createEntity(entity)); - - verify(entityTemplateService).getEntityTemplateByIdentifier("missing-template"); - verifyNoInteractions(entityValidationService); - verifyNoMoreInteractions(entityRepository); - } - - @Test - @DisplayName("Should update entity when validations pass") - void shouldUpdateEntityWhenValidationsPass() { - var existing = new Entity(UUID.randomUUID(), "web-service", "Web API 2", "web-api-2", List.of(), List.of()); - var payload = new Entity(null, "web-service", "Web API 2 Updated", "web-api-2", List.of(), List.of()); - var expectedSaved = new Entity(existing.id(), "web-service", "Web API 2 Updated", "web-api-2", List.of(), List.of()); - var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", List.of(), List.of()); - - when(entityTemplateService.getEntityTemplateByIdentifier("web-service")).thenReturn(template); - when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "web-api-2")).thenReturn(Optional.of(existing)); - when(entityRepository.save(expectedSaved)).thenReturn(expectedSaved); - - var result = entityService.updateEntity("web-service", "web-api-2", payload); - - assertSame(expectedSaved, result); - InOrder inOrder = inOrder(entityTemplateService, entityRepository, entityValidationService, entityRepository); - inOrder.verify(entityTemplateService).getEntityTemplateByIdentifier("web-service"); - inOrder.verify(entityRepository).findByTemplateIdentifierAndIdentifier("web-service", "web-api-2"); - inOrder.verify(entityValidationService).validateForUpdate(expectedSaved, template); - inOrder.verify(entityRepository).save(expectedSaved); - } - - @Test - @DisplayName("Should throw when updating non-existing entity") - void shouldThrowWhenUpdatingNonExistingEntity() { - var payload = new Entity(null, "web-service", "Web API 2 Updated", "web-api-2", List.of(), List.of()); - var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", List.of(), List.of()); - - when(entityTemplateService.getEntityTemplateByIdentifier("web-service")).thenReturn(template); - when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "web-api-2")).thenReturn(Optional.empty()); - - assertThrows(EntityNotFoundException.class, - () -> entityService.updateEntity("web-service", "web-api-2", payload)); - - verify(entityTemplateService).getEntityTemplateByIdentifier("web-service"); - verify(entityRepository).findByTemplateIdentifierAndIdentifier("web-service", "web-api-2"); - verifyNoMoreInteractions(entityRepository); - } - - @Test - @DisplayName("Should propagate two validation errors when update payload violates template constraints") - void shouldPropagateTwoValidationErrorsWhenUpdatingInvalidEntity() { - var existing = new Entity(UUID.randomUUID(), "web-service", "Web API 2", "web-api-2", List.of(), List.of()); - var payload = new Entity(null, "web-service", "Web API 2 Updated", "web-api-2", List.of(), List.of()); - var expectedToValidate = new Entity(existing.id(), "web-service", "Web API 2 Updated", "web-api-2", List.of(), List.of()); - var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", List.of(), List.of()); - var validationErrors = List.of( - ValidationMessages.PROPERTY_NOT_DEFINED_IN_TEMPLATE.formatted("status", "web-service"), - ValidationMessages.RELATION_TARGET_ENTITY_NOT_FOUND.formatted("child_of", "missing-platform") - ); - - when(entityTemplateService.getEntityTemplateByIdentifier("web-service")).thenReturn(template); - when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "web-api-2")).thenReturn(Optional.of(existing)); - doThrow(new EntityValidationException(validationErrors)) - .when(entityValidationService).validateForUpdate(expectedToValidate, template); - - var thrown = assertThrows(EntityValidationException.class, - () -> entityService.updateEntity("web-service", "web-api-2", payload)); - - assertEquals(validationErrors, thrown.getViolations()); - verify(entityValidationService).validateForUpdate(expectedToValidate, template); - verify(entityRepository).findByTemplateIdentifierAndIdentifier("web-service", "web-api-2"); - verifyNoMoreInteractions(entityRepository); - } - - private Entity entity(String templateIdentifier, String identifier, String name) { - return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, List.of(), List.of()); - } + @Mock + private EntityRepositoryPort entityRepository; + + @Mock + private EntityValidationService entityValidationService; + + @Mock + private EntityTemplateValidationService entityTemplateValidationService; + + @Mock + private EntityTemplateService entityTemplateService; + + @Mock + private EntityQueryParserService entityQueryParserService; + + @InjectMocks + private EntityService entityService; + + @Test + @DisplayName("Should return entities page by template identifier") + void shouldReturnEntitiesByTemplateIdentifier() { + var pageable = Pageable.ofSize(10); + var entity = entity("template-a", "entity-a", "Entity A"); + var page = new PageImpl<>(List.of(entity)); + var template = new EntityTemplate(UUID.randomUUID(), "template-a", "Template A", "desc", + List.of(), List.of()); + + when(entityTemplateService.getEntityTemplateByIdentifier("template-a")).thenReturn(template); + when(entityRepository.findByTemplateIdentifierWithFilter("template-a", EntityFilter.empty(), + pageable)).thenReturn(page); + + var result = entityService.getEntitiesByTemplateIdentifier(pageable, "template-a", null); + + assertSame(page, result); + verify(entityTemplateService).getEntityTemplateByIdentifier("template-a"); + verify(entityQueryParserService).validateFilterPropertyTypes(EntityFilter.empty(), template); + verify(entityRepository).findByTemplateIdentifierWithFilter("template-a", EntityFilter.empty(), + pageable); + } + + @Test + @DisplayName("Should return entity summaries by identifiers") + void shouldReturnEntitySummariesByIdentifiers() { + var summaries = List.of(new EntitySummary("service-a", "Service A", "web-service")); + when(entityRepository.findByIdentifierIn(List.of("service-a"))).thenReturn(summaries); + + var result = entityService.getEntitiesSummariesByIdentifiers(List.of("service-a")); + + assertEquals(summaries, result); + verify(entityRepository).findByIdentifierIn(List.of("service-a")); + } + + @Test + @DisplayName("Should return entity by template and identifier") + void shouldReturnEntityByTemplateAndIdentifier() { + var entity = entity("web-service", "catalog-api", "Catalog API"); + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) + .thenReturn(Optional.of(entity)); + + var result = entityService.getEntityByTemplateIdentifierAndIdentifier("web-service", + "catalog-api"); + + assertSame(entity, result); + verify(entityTemplateValidationService).validateTemplateExists("web-service"); + verify(entityRepository).findByTemplateIdentifierAndIdentifier("web-service", "catalog-api"); + } + + @Test + @DisplayName("Should throw when entity is not found for template") + void shouldThrowWhenEntityNotFoundByTemplateAndIdentifier() { + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "missing-entity")) + .thenReturn(Optional.empty()); + + assertThrows(EntityNotFoundException.class, () -> entityService + .getEntityByTemplateIdentifierAndIdentifier("web-service", "missing-entity")); + } + + @Test + @DisplayName("Should create entity when validations pass") + void shouldCreateEntityWhenValidationsPass() { + var entity = entity("web-service", "catalog-api", "Catalog API"); + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", + List.of(), List.of()); + when(entityTemplateService.getEntityTemplateByIdentifier("web-service")).thenReturn(template); + when(entityRepository.save(entity)).thenReturn(entity); + + var result = entityService.createEntity(entity); + + assertSame(entity, result); + + InOrder inOrder = inOrder(entityTemplateService, entityValidationService, entityRepository); + inOrder.verify(entityTemplateService).getEntityTemplateByIdentifier("web-service"); + inOrder.verify(entityValidationService).validateForCreation(entity, template); + inOrder.verify(entityRepository).save(entity); + verifyNoInteractions(entityTemplateValidationService); + } + + @Test + @DisplayName("Should not save when entity already exists") + void shouldNotSaveWhenEntityAlreadyExists() { + var entity = entity("web-service", "catalog-api", "Catalog API"); + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", + List.of(), List.of()); + var alreadyExists = new EntityAlreadyExistsException("web-service", "catalog-api"); + + when(entityTemplateService.getEntityTemplateByIdentifier("web-service")).thenReturn(template); + doThrow(alreadyExists).when(entityValidationService).validateForCreation(entity, template); + + assertThrows(EntityAlreadyExistsException.class, () -> entityService.createEntity(entity)); + + verify(entityTemplateService).getEntityTemplateByIdentifier("web-service"); + verify(entityValidationService).validateForCreation(entity, template); + verifyNoMoreInteractions(entityRepository); + } + + @Test + @DisplayName("Should stop immediately when template does not exist") + void shouldStopWhenTemplateDoesNotExistOnCreate() { + var entity = entity("missing-template", "catalog-api", "Catalog API"); + + when(entityTemplateService.getEntityTemplateByIdentifier("missing-template")) + .thenThrow(new EntityTemplateNotFoundException("identifier", "missing-template")); + + assertThrows(EntityTemplateNotFoundException.class, () -> entityService.createEntity(entity)); + + verify(entityTemplateService).getEntityTemplateByIdentifier("missing-template"); + verifyNoInteractions(entityValidationService); + verifyNoMoreInteractions(entityRepository); + } + + @Test + @DisplayName("Should update entity when validations pass") + void shouldUpdateEntityWhenValidationsPass() { + var existing = new Entity(UUID.randomUUID(), "web-service", "Web API 2", "web-api-2", List.of(), + List.of()); + var payload = new Entity(null, "web-service", "Web API 2 Updated", "web-api-2", List.of(), + List.of()); + var expectedSaved = new Entity(existing.id(), "web-service", "Web API 2 Updated", "web-api-2", + List.of(), List.of()); + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", + List.of(), List.of()); + + when(entityTemplateService.getEntityTemplateByIdentifier("web-service")).thenReturn(template); + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "web-api-2")) + .thenReturn(Optional.of(existing)); + when(entityRepository.save(expectedSaved)).thenReturn(expectedSaved); + + var result = entityService.updateEntity("web-service", "web-api-2", payload); + + assertSame(expectedSaved, result); + InOrder inOrder = inOrder(entityTemplateService, entityRepository, entityValidationService, + entityRepository); + inOrder.verify(entityTemplateService).getEntityTemplateByIdentifier("web-service"); + inOrder.verify(entityRepository).findByTemplateIdentifierAndIdentifier("web-service", + "web-api-2"); + inOrder.verify(entityValidationService).validateForUpdate(expectedSaved, template); + inOrder.verify(entityRepository).save(expectedSaved); + } + + @Test + @DisplayName("Should throw when updating non-existing entity") + void shouldThrowWhenUpdatingNonExistingEntity() { + var payload = new Entity(null, "web-service", "Web API 2 Updated", "web-api-2", List.of(), + List.of()); + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", + List.of(), List.of()); + + when(entityTemplateService.getEntityTemplateByIdentifier("web-service")).thenReturn(template); + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "web-api-2")) + .thenReturn(Optional.empty()); + + assertThrows(EntityNotFoundException.class, + () -> entityService.updateEntity("web-service", "web-api-2", payload)); + + verify(entityTemplateService).getEntityTemplateByIdentifier("web-service"); + verify(entityRepository).findByTemplateIdentifierAndIdentifier("web-service", "web-api-2"); + verifyNoMoreInteractions(entityRepository); + } + + @Test + @DisplayName("Should propagate two validation errors when update payload violates template constraints") + void shouldPropagateTwoValidationErrorsWhenUpdatingInvalidEntity() { + var existing = new Entity(UUID.randomUUID(), "web-service", "Web API 2", "web-api-2", List.of(), + List.of()); + var payload = new Entity(null, "web-service", "Web API 2 Updated", "web-api-2", List.of(), + List.of()); + var expectedToValidate = new Entity(existing.id(), "web-service", "Web API 2 Updated", + "web-api-2", List.of(), List.of()); + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", + List.of(), List.of()); + var validationErrors = List.of( + ValidationMessages.PROPERTY_NOT_DEFINED_IN_TEMPLATE.formatted("status", "web-service"), + ValidationMessages.RELATION_TARGET_ENTITY_NOT_FOUND.formatted("child_of", + "missing-platform")); + + when(entityTemplateService.getEntityTemplateByIdentifier("web-service")).thenReturn(template); + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "web-api-2")) + .thenReturn(Optional.of(existing)); + doThrow(new EntityValidationException(validationErrors)).when(entityValidationService) + .validateForUpdate(expectedToValidate, template); + + var thrown = assertThrows(EntityValidationException.class, + () -> entityService.updateEntity("web-service", "web-api-2", payload)); + + assertEquals(validationErrors, thrown.getViolations()); + verify(entityValidationService).validateForUpdate(expectedToValidate, template); + verify(entityRepository).findByTemplateIdentifierAndIdentifier("web-service", "web-api-2"); + verifyNoMoreInteractions(entityRepository); + } + + private Entity entity(String templateIdentifier, String identifier, String name) { + return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, List.of(), + List.of()); + } } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java index 2f795a9a..0ea96534 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityValidationServiceTest.java @@ -38,125 +38,95 @@ @DisplayName("EntityValidationService Tests") class EntityValidationServiceTest { - @Mock - private EntityRepositoryPort entityRepository; - - @Mock - private RelationValidationService relationValidationService; - - @Mock - private PropertyValidationService propertyValidationService; - - @InjectMocks - private EntityValidationService entityValidationService; - - @Test - @DisplayName("Should throw when entity with same identifier already exists") - void shouldThrowWhenEntityAlreadyExists() { - var template = new EntityTemplate( - UUID.randomUUID(), - "web-service", - "Web Service", - "desc", - Collections.emptyList(), - List.of()); - var entity = entity("web-service", "catalog-api", "Catalog API", List.of(), List.of()); - when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) - .thenReturn(Optional.of(entity)); - - assertThrows(EntityAlreadyExistsException.class, () -> entityValidationService.validateForCreation(entity, template)); - } + @Mock + private EntityRepositoryPort entityRepository; - @Test - @DisplayName("Should not query repository when identifier is null") - void shouldNotQueryRepositoryWhenIdentifierIsNull() { - var template = new EntityTemplate( - UUID.randomUUID(), - "web-service", - "Web Service", - "desc", - Collections.emptyList(), - List.of()); + @Mock + private RelationValidationService relationValidationService; - var entity = entity("web-service", null, "Catalog API", List.of(), List.of()); + @Mock + private PropertyValidationService propertyValidationService; - assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); + @InjectMocks + private EntityValidationService entityValidationService; - verify(entityRepository, never()).findByTemplateIdentifierAndIdentifier(any(), any()); - } + @Test + @DisplayName("Should throw when entity with same identifier already exists") + void shouldThrowWhenEntityAlreadyExists() { + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", + Collections.emptyList(), List.of()); + var entity = entity("web-service", "catalog-api", "Catalog API", List.of(), List.of()); + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) + .thenReturn(Optional.of(entity)); - @Test - @DisplayName("Should validate entity successfully by delegating to property and relation validation services") - void shouldValidateForCreationSuccessfullyWhenNoViolations() { - var template = new EntityTemplate( - UUID.randomUUID(), - "web-service", - "Web Service", - "desc", - List.of(), - List.of()); - - var property = new Property(UUID.randomUUID(), "version", "1.0.0"); - var relation = new Relation(UUID.randomUUID(), "owned-by", "team", List.of("team-a")); - var entity = entity( - "web-service", - "catalog-api", - "Catalog API", - List.of(property), - List.of(relation)); - - assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); - - verify(propertyValidationService).validatePropertiesAgainstTemplate( - eq(template), - eq(template.propertiesDefinitions()), - eq(Map.of("version", property)), - any(Violations.class) - ); - - verify(relationValidationService).validateRelationsAgainstTemplate( - eq(template), - eq(entity.relations()), - any(Violations.class) - ); - } + assertThrows(EntityAlreadyExistsException.class, + () -> entityValidationService.validateForCreation(entity, template)); + } - @Test - @DisplayName("Should throw EntityValidationException when delegated validations populate the Violations aggregate") - void shouldThrowEntityValidationExceptionWhenViolationsExist() { - var template = new EntityTemplate( - UUID.randomUUID(), - "web-service", - "Web Service", - "desc", - List.of(), - List.of()); - - var entity = entity("web-service", "catalog-api", "Catalog API", List.of(), List.of()); - - try (var _ = mockConstruction(Violations.class, - (mock, context) -> { - when(mock.isEmpty()).thenReturn(false); - when(mock.asList()).thenReturn(List.of("Delegated property error", "Delegated relation error")); - })) { - - var exception = assertThrows(EntityValidationException.class, - () -> entityValidationService.validateForCreation(entity, template)); - - assertEquals(2, exception.getViolations().size()); - assertEquals("Delegated property error", exception.getViolations().get(0)); - - verify(propertyValidationService).validatePropertiesAgainstTemplate(eq(template), any(), any(), any()); - verify(relationValidationService).validateRelationsAgainstTemplate(eq(template), any(), any()); - } - } + @Test + @DisplayName("Should not query repository when identifier is null") + void shouldNotQueryRepositoryWhenIdentifierIsNull() { + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", + Collections.emptyList(), List.of()); + + var entity = entity("web-service", null, "Catalog API", List.of(), List.of()); + + assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); + + verify(entityRepository, never()).findByTemplateIdentifierAndIdentifier(any(), any()); + } + + @Test + @DisplayName("Should validate entity successfully by delegating to property and relation validation services") + void shouldValidateForCreationSuccessfullyWhenNoViolations() { + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", + List.of(), List.of()); + + var property = new Property(UUID.randomUUID(), "version", "1.0.0"); + var relation = new Relation(UUID.randomUUID(), "owned-by", "team", List.of("team-a")); + var entity = entity("web-service", "catalog-api", "Catalog API", List.of(property), + List.of(relation)); - private Entity entity( - String templateIdentifier, - String identifier, - String name, - List properties, - List relations) { - return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, properties, relations); + assertDoesNotThrow(() -> entityValidationService.validateForCreation(entity, template)); + + verify(propertyValidationService).validatePropertiesAgainstTemplate(eq(template), + eq(template.propertiesDefinitions()), eq(Map.of("version", property)), + any(Violations.class)); + + verify(relationValidationService).validateRelationsAgainstTemplate(eq(template), + eq(entity.relations()), any(Violations.class)); + } + + @Test + @DisplayName("Should throw EntityValidationException when delegated validations populate the Violations aggregate") + void shouldThrowEntityValidationExceptionWhenViolationsExist() { + var template = new EntityTemplate(UUID.randomUUID(), "web-service", "Web Service", "desc", + List.of(), List.of()); + + var entity = entity("web-service", "catalog-api", "Catalog API", List.of(), List.of()); + + try (var _ = mockConstruction(Violations.class, (mock, context) -> { + when(mock.isEmpty()).thenReturn(false); + when(mock.asList()) + .thenReturn(List.of("Delegated property error", "Delegated relation error")); + })) { + + var exception = assertThrows(EntityValidationException.class, + () -> entityValidationService.validateForCreation(entity, template)); + + assertEquals(2, exception.getViolations().size()); + assertEquals("Delegated property error", exception.getViolations().get(0)); + + verify(propertyValidationService).validatePropertiesAgainstTemplate(eq(template), any(), + any(), any()); + verify(relationValidationService).validateRelationsAgainstTemplate(eq(template), any(), + any()); } + } + + private Entity entity(String templateIdentifier, String identifier, String name, + List properties, List relations) { + return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, properties, + relations); + } } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java index 5eee5bc1..520debc8 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/property/PropertyValidationServiceTest.java @@ -7,8 +7,8 @@ import java.util.List; import java.util.Map; -import java.util.stream.Stream; import java.util.UUID; +import java.util.stream.Stream; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -17,7 +17,6 @@ import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; - import com.decathlon.idp_core.domain.constant.ValidationMessages; import com.decathlon.idp_core.domain.model.entity.Property; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; @@ -30,426 +29,480 @@ @DisplayName("PropertyValidationService Tests") class PropertyValidationServiceTest { - private final PropertyValidationService service = new PropertyValidationService(); - - @Nested - @DisplayName("validatePropertiesAgainstTemplate Orchestration Tests") - class AgainstTemplateValidationTests { - - @Test - @DisplayName("Should report violation when required property is completely missing") - void shouldReportViolationWhenRequiredPropertyIsMissing() { - var template = new EntityTemplate(UUID.randomUUID(), "system-template", "System", "desc", List.of(), List.of()); - var definition = new PropertyDefinition(UUID.randomUUID(), "owner", "Owner", PropertyType.STRING, true, null); - var violations = mock(Violations.class); - - service.validatePropertiesAgainstTemplate(template, List.of(definition), Map.of(), violations); - - verify(violations).add(ValidationMessages.PROPERTY_REQUIRED_MISSING, "owner", "system-template"); - } - - @Test - @DisplayName("Should report violation when required property is present but blank") - void shouldReportViolationWhenRequiredPropertyIsBlank() { - var template = new EntityTemplate(UUID.randomUUID(), "system-template", "System", "desc", List.of(), List.of()); - var definition = new PropertyDefinition(UUID.randomUUID(), "owner", "Owner", PropertyType.STRING, true, null); - var property = new Property(UUID.randomUUID(), "owner", " "); - var violations = mock(Violations.class); - - service.validatePropertiesAgainstTemplate(template, List.of(definition), Map.of("owner", property), violations); - - verify(violations).add(ValidationMessages.PROPERTY_REQUIRED_MISSING, "owner", "system-template"); - } - - @Test - @DisplayName("Should not report violation when optional property is missing") - void shouldNotReportViolationWhenOptionalPropertyIsMissing() { - var template = new EntityTemplate(UUID.randomUUID(), "system-template", "System", "desc", List.of(), List.of()); - var definition = new PropertyDefinition(UUID.randomUUID(), "description", "Desc", PropertyType.STRING, false, null); - var violations = mock(Violations.class); - - service.validatePropertiesAgainstTemplate(template, List.of(definition), Map.of(), violations); + private final PropertyValidationService service = new PropertyValidationService(); - verifyNoInteractions(violations); - } + @Nested + @DisplayName("validatePropertiesAgainstTemplate Orchestration Tests") + class AgainstTemplateValidationTests { - @Test - @DisplayName("Should report violation when provided property is not defined in template") - void shouldReportViolationWhenPropertyNotDefinedInTemplate() { - var template = new EntityTemplate(UUID.randomUUID(), "system-template", "System", "desc", List.of(), List.of()); - var definition = new PropertyDefinition(UUID.randomUUID(), "owner", "Owner", PropertyType.STRING, true, null); - var extraProperty = new Property(UUID.randomUUID(), "status", "deprecated"); - var violations = mock(Violations.class); + @Test + @DisplayName("Should report violation when required property is completely missing") + void shouldReportViolationWhenRequiredPropertyIsMissing() { + var template = new EntityTemplate(UUID.randomUUID(), "system-template", "System", "desc", + List.of(), List.of()); + var definition = new PropertyDefinition(UUID.randomUUID(), "owner", "Owner", + PropertyType.STRING, true, null); + var violations = mock(Violations.class); - service.validatePropertiesAgainstTemplate(template, List.of(definition), Map.of("status", extraProperty), violations); + service.validatePropertiesAgainstTemplate(template, List.of(definition), Map.of(), + violations); - verify(violations).add(ValidationMessages.PROPERTY_NOT_DEFINED_IN_TEMPLATE, "status", "system-template"); - verify(violations).add(ValidationMessages.PROPERTY_REQUIRED_MISSING, "owner", "system-template"); - } + verify(violations).add(ValidationMessages.PROPERTY_REQUIRED_MISSING, "owner", + "system-template"); + } - @Test - @DisplayName("Should delegate to validatePropertyValue and accumulate rule violations") - void shouldDelegateAndAccumulateRuleViolations() { - var template = new EntityTemplate(UUID.randomUUID(), "system-template", "System", "desc", List.of(), List.of()); - var definition = new PropertyDefinition(UUID.randomUUID(), "port", "Port", PropertyType.NUMBER, true, null); - var property = new Property(UUID.randomUUID(), "port", "not-a-number"); - var violations = mock(Violations.class); + @Test + @DisplayName("Should report violation when required property is present but blank") + void shouldReportViolationWhenRequiredPropertyIsBlank() { + var template = new EntityTemplate(UUID.randomUUID(), "system-template", "System", "desc", + List.of(), List.of()); + var definition = new PropertyDefinition(UUID.randomUUID(), "owner", "Owner", + PropertyType.STRING, true, null); + var property = new Property(UUID.randomUUID(), "owner", " "); + var violations = mock(Violations.class); + + service.validatePropertiesAgainstTemplate(template, List.of(definition), + Map.of("owner", property), violations); + + verify(violations).add(ValidationMessages.PROPERTY_REQUIRED_MISSING, "owner", + "system-template"); + } - service.validatePropertiesAgainstTemplate(template, List.of(definition), Map.of("port", property), violations); + @Test + @DisplayName("Should not report violation when optional property is missing") + void shouldNotReportViolationWhenOptionalPropertyIsMissing() { + var template = new EntityTemplate(UUID.randomUUID(), "system-template", "System", "desc", + List.of(), List.of()); + var definition = new PropertyDefinition(UUID.randomUUID(), "description", "Desc", + PropertyType.STRING, false, null); + var violations = mock(Violations.class); - verify(violations).add(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("port", PropertyType.NUMBER)); - } + service.validatePropertiesAgainstTemplate(template, List.of(definition), Map.of(), + violations); - @Test - @DisplayName("Should add no violations when required property is present and valid") - void shouldAddNoViolationsWhenValid() { - var template = new EntityTemplate(UUID.randomUUID(), "system-template", "System", "desc", List.of(), List.of()); - var definition = new PropertyDefinition(UUID.randomUUID(), "port", "Port", PropertyType.NUMBER, true, null); - var property = new Property(UUID.randomUUID(), "port", "8080"); - var violations = mock(Violations.class); + verifyNoInteractions(violations); + } - service.validatePropertiesAgainstTemplate(template, List.of(definition), Map.of("port", property), violations); + @Test + @DisplayName("Should report violation when provided property is not defined in template") + void shouldReportViolationWhenPropertyNotDefinedInTemplate() { + var template = new EntityTemplate(UUID.randomUUID(), "system-template", "System", "desc", + List.of(), List.of()); + var definition = new PropertyDefinition(UUID.randomUUID(), "owner", "Owner", + PropertyType.STRING, true, null); + var extraProperty = new Property(UUID.randomUUID(), "status", "deprecated"); + var violations = mock(Violations.class); + + service.validatePropertiesAgainstTemplate(template, List.of(definition), + Map.of("status", extraProperty), violations); + + verify(violations).add(ValidationMessages.PROPERTY_NOT_DEFINED_IN_TEMPLATE, "status", + "system-template"); + verify(violations).add(ValidationMessages.PROPERTY_REQUIRED_MISSING, "owner", + "system-template"); + } - verifyNoInteractions(violations); - } + @Test + @DisplayName("Should delegate to validatePropertyValue and accumulate rule violations") + void shouldDelegateAndAccumulateRuleViolations() { + var template = new EntityTemplate(UUID.randomUUID(), "system-template", "System", "desc", + List.of(), List.of()); + var definition = new PropertyDefinition(UUID.randomUUID(), "port", "Port", + PropertyType.NUMBER, true, null); + var property = new Property(UUID.randomUUID(), "port", "not-a-number"); + var violations = mock(Violations.class); + + service.validatePropertiesAgainstTemplate(template, List.of(definition), + Map.of("port", property), violations); + + verify(violations) + .add(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("port", PropertyType.NUMBER)); } - @Nested - @DisplayName("STRING validation") - class StringValidationTests { + @Test + @DisplayName("Should add no violations when required property is present and valid") + void shouldAddNoViolationsWhenValid() { + var template = new EntityTemplate(UUID.randomUUID(), "system-template", "System", "desc", + List.of(), List.of()); + var definition = new PropertyDefinition(UUID.randomUUID(), "port", "Port", + PropertyType.NUMBER, true, null); + var property = new Property(UUID.randomUUID(), "port", "8080"); + var violations = mock(Violations.class); - @Test - @DisplayName("Should report type mismatch when STRING value is null") - void shouldReportTypeMismatchWhenStringValueIsNull() { - var definition = propertyDefinition("label", PropertyType.STRING, null); + service.validatePropertiesAgainstTemplate(template, List.of(definition), + Map.of("port", property), violations); - var violations = service.validatePropertyValue(definition, null); + verifyNoInteractions(violations); + } + } - assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("label", PropertyType.STRING)), violations); - } + @Nested + @DisplayName("STRING validation") + class StringValidationTests { - @Test - @DisplayName("Should return no violations when STRING has no rules") - void shouldReturnNoViolationsWhenStringHasNoRules() { - var definition = propertyDefinition("label", PropertyType.STRING, null); + @Test + @DisplayName("Should report type mismatch when STRING value is null") + void shouldReportTypeMismatchWhenStringValueIsNull() { + var definition = propertyDefinition("label", PropertyType.STRING, null); - var violations = service.validatePropertyValue(definition, "hello"); + var violations = service.validatePropertyValue(definition, null); - assertEquals(List.of(), violations); - } + assertEquals( + List.of( + ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("label", PropertyType.STRING)), + violations); + } - @Test - @DisplayName("Should return no violations when STRING value satisfies all rules") - void shouldReturnNoViolationsWhenStringPassesAllRules() { - var rules = new PropertyRules(null, null, List.of("dev", "prod"), "^[a-z]+$", 10, 2, null, null); - var definition = propertyDefinition("env", PropertyType.STRING, rules); + @Test + @DisplayName("Should return no violations when STRING has no rules") + void shouldReturnNoViolationsWhenStringHasNoRules() { + var definition = propertyDefinition("label", PropertyType.STRING, null); - var violations = service.validatePropertyValue(definition, "dev"); + var violations = service.validatePropertyValue(definition, "hello"); - assertEquals(List.of(), violations); - } + assertEquals(List.of(), violations); + } - @Test - @DisplayName("Should report minLength violation") - void shouldReportMinLengthViolation() { - var rules = new PropertyRules(null, null, null, null, null, 5, null, null); - var definition = propertyDefinition("name", PropertyType.STRING, rules); + @Test + @DisplayName("Should return no violations when STRING value satisfies all rules") + void shouldReturnNoViolationsWhenStringPassesAllRules() { + var rules = new PropertyRules(null, null, List.of("dev", "prod"), "^[a-z]+$", 10, 2, null, + null); + var definition = propertyDefinition("env", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "ab"); + var violations = service.validatePropertyValue(definition, "dev"); - assertEquals(List.of(ValidationMessages.PROPERTY_MIN_LENGTH_VIOLATION.formatted("name", 5)), violations); - } + assertEquals(List.of(), violations); + } - @Test - @DisplayName("Should report maxLength violation") - void shouldReportMaxLengthViolation() { - var rules = new PropertyRules(null, null, null, null, 5, null, null, null); - var definition = propertyDefinition("name", PropertyType.STRING, rules); + @Test + @DisplayName("Should report minLength violation") + void shouldReportMinLengthViolation() { + var rules = new PropertyRules(null, null, null, null, null, 5, null, null); + var definition = propertyDefinition("name", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "too-long-value"); + var violations = service.validatePropertyValue(definition, "ab"); - assertEquals(List.of(ValidationMessages.PROPERTY_MAX_LENGTH_VIOLATION.formatted("name", 5)), violations); - } + assertEquals(List.of(ValidationMessages.PROPERTY_MIN_LENGTH_VIOLATION.formatted("name", 5)), + violations); + } - @Test - @DisplayName("Should report regex violation") - void shouldReportRegexViolation() { - var rules = new PropertyRules(null, null, null, "^[0-9]+$", null, null, null, null); - var definition = propertyDefinition("code", PropertyType.STRING, rules); + @Test + @DisplayName("Should report maxLength violation") + void shouldReportMaxLengthViolation() { + var rules = new PropertyRules(null, null, null, null, 5, null, null, null); + var definition = propertyDefinition("name", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "abc"); + var violations = service.validatePropertyValue(definition, "too-long-value"); - assertEquals(List.of(ValidationMessages.PROPERTY_REGEX_VIOLATION.formatted("code")), violations); - } + assertEquals(List.of(ValidationMessages.PROPERTY_MAX_LENGTH_VIOLATION.formatted("name", 5)), + violations); + } - @Test - @DisplayName("Should accept value matching regex") - void shouldAcceptValueMatchingRegex() { - var rules = new PropertyRules(null, null, null, "^[0-9]+$", null, null, null, null); - var definition = propertyDefinition("code", PropertyType.STRING, rules); + @Test + @DisplayName("Should report regex violation") + void shouldReportRegexViolation() { + var rules = new PropertyRules(null, null, null, "^[0-9]+$", null, null, null, null); + var definition = propertyDefinition("code", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "12345"); + var violations = service.validatePropertyValue(definition, "abc"); - assertEquals(List.of(), violations); - } + assertEquals(List.of(ValidationMessages.PROPERTY_REGEX_VIOLATION.formatted("code")), + violations); + } - @Test - @DisplayName("Should report enum violation when value not in allowed list") - void shouldReportEnumViolation() { - var rules = new PropertyRules(null, null, List.of("ACTIVE", "INACTIVE"), null, null, null, null, null); - var definition = propertyDefinition("status", PropertyType.STRING, rules); + @Test + @DisplayName("Should accept value matching regex") + void shouldAcceptValueMatchingRegex() { + var rules = new PropertyRules(null, null, null, "^[0-9]+$", null, null, null, null); + var definition = propertyDefinition("code", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "UNKNOWN"); + var violations = service.validatePropertyValue(definition, "12345"); - assertEquals(List.of(ValidationMessages.PROPERTY_ENUM_VIOLATION.formatted("status", List.of("ACTIVE", "INACTIVE"))), violations); - } + assertEquals(List.of(), violations); + } - @Test - @DisplayName("Should accept enum value with case-insensitive match") - void shouldAcceptEnumValueCaseInsensitive() { - var rules = new PropertyRules(null, null, List.of("ACTIVE", "INACTIVE"), null, null, null, null, null); - var definition = propertyDefinition("status", PropertyType.STRING, rules); + @Test + @DisplayName("Should report enum violation when value not in allowed list") + void shouldReportEnumViolation() { + var rules = new PropertyRules(null, null, List.of("ACTIVE", "INACTIVE"), null, null, null, + null, null); + var definition = propertyDefinition("status", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "active"); + var violations = service.validatePropertyValue(definition, "UNKNOWN"); - assertEquals(List.of(), violations); - } + assertEquals(List.of(ValidationMessages.PROPERTY_ENUM_VIOLATION.formatted("status", + List.of("ACTIVE", "INACTIVE"))), violations); + } - @Test - @DisplayName("Should skip enum check when enumValues is empty") - void shouldSkipEnumCheckWhenEnumValuesIsEmpty() { - var rules = new PropertyRules(null, null, List.of(), null, null, null, null, null); - var definition = propertyDefinition("status", PropertyType.STRING, rules); + @Test + @DisplayName("Should accept enum value with case-insensitive match") + void shouldAcceptEnumValueCaseInsensitive() { + var rules = new PropertyRules(null, null, List.of("ACTIVE", "INACTIVE"), null, null, null, + null, null); + var definition = propertyDefinition("status", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "anything"); + var violations = service.validatePropertyValue(definition, "active"); - assertEquals(List.of(), violations); - } + assertEquals(List.of(), violations); + } - @Test - @DisplayName("Should report format violation for invalid EMAIL") - void shouldReportFormatViolationForInvalidEmail() { - var rules = new PropertyRules(null, PropertyFormat.EMAIL, null, null, null, null, null, null); - var definition = propertyDefinition("email", PropertyType.STRING, rules); + @Test + @DisplayName("Should skip enum check when enumValues is empty") + void shouldSkipEnumCheckWhenEnumValuesIsEmpty() { + var rules = new PropertyRules(null, null, List.of(), null, null, null, null, null); + var definition = propertyDefinition("status", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "not-an-email"); + var violations = service.validatePropertyValue(definition, "anything"); - assertEquals(List.of(ValidationMessages.PROPERTY_FORMAT_VIOLATION.formatted("email", PropertyFormat.EMAIL)), violations); - } + assertEquals(List.of(), violations); + } - @Test - @DisplayName("Should accept valid EMAIL format") - void shouldAcceptValidEmailFormat() { - var rules = new PropertyRules(null, PropertyFormat.EMAIL, null, null, null, null, null, null); - var definition = propertyDefinition("email", PropertyType.STRING, rules); + @Test + @DisplayName("Should report format violation for invalid EMAIL") + void shouldReportFormatViolationForInvalidEmail() { + var rules = new PropertyRules(null, PropertyFormat.EMAIL, null, null, null, null, null, null); + var definition = propertyDefinition("email", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "user@example.com"); + var violations = service.validatePropertyValue(definition, "not-an-email"); - assertEquals(List.of(), violations); - } + assertEquals(List.of( + ValidationMessages.PROPERTY_FORMAT_VIOLATION.formatted("email", PropertyFormat.EMAIL)), + violations); + } - @Test - @DisplayName("Should report format violation for invalid URL") - void shouldReportFormatViolationForInvalidUrl() { - var rules = new PropertyRules(null, PropertyFormat.URL, null, null, null, null, null, null); - var definition = propertyDefinition("url", PropertyType.STRING, rules); + @Test + @DisplayName("Should accept valid EMAIL format") + void shouldAcceptValidEmailFormat() { + var rules = new PropertyRules(null, PropertyFormat.EMAIL, null, null, null, null, null, null); + var definition = propertyDefinition("email", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "not-a-url"); + var violations = service.validatePropertyValue(definition, "user@example.com"); - assertEquals(List.of(ValidationMessages.PROPERTY_FORMAT_VIOLATION.formatted("url", PropertyFormat.URL)), violations); - } + assertEquals(List.of(), violations); + } - @Test - @DisplayName("Should accept valid URL format") - void shouldAcceptValidUrlFormat() { - var rules = new PropertyRules(null, PropertyFormat.URL, null, null, null, null, null, null); - var definition = propertyDefinition("url", PropertyType.STRING, rules); + @Test + @DisplayName("Should report format violation for invalid URL") + void shouldReportFormatViolationForInvalidUrl() { + var rules = new PropertyRules(null, PropertyFormat.URL, null, null, null, null, null, null); + var definition = propertyDefinition("url", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "https://github.com/org/repo"); + var violations = service.validatePropertyValue(definition, "not-a-url"); - assertEquals(List.of(), violations); - } + assertEquals( + List.of( + ValidationMessages.PROPERTY_FORMAT_VIOLATION.formatted("url", PropertyFormat.URL)), + violations); + } - @Test - @DisplayName("Should report multiple violations at once") - void shouldReportMultipleStringViolations() { - var rules = new PropertyRules(null, PropertyFormat.EMAIL, List.of("prod", "dev"), "^[a-z]+$", 5, 3, null, null); - var definition = propertyDefinition("name", PropertyType.STRING, rules); + @Test + @DisplayName("Should accept valid URL format") + void shouldAcceptValidUrlFormat() { + var rules = new PropertyRules(null, PropertyFormat.URL, null, null, null, null, null, null); + var definition = propertyDefinition("url", PropertyType.STRING, rules); - var violations = service.validatePropertyValue(definition, "AA"); + var violations = service.validatePropertyValue(definition, "https://github.com/org/repo"); - assertEquals(4, violations.size()); - } + assertEquals(List.of(), violations); + } - @Test - @DisplayName("Should use cached pattern for repeated regex validations") - void shouldUseCachedPatternForRepeatedRegex() { - var rules = new PropertyRules(null, null, null, "^[a-z]+$", null, null, null, null); - var definition = propertyDefinition("code", PropertyType.STRING, rules); + @Test + @DisplayName("Should report multiple violations at once") + void shouldReportMultipleStringViolations() { + var rules = new PropertyRules(null, PropertyFormat.EMAIL, List.of("prod", "dev"), "^[a-z]+$", + 5, 3, null, null); + var definition = propertyDefinition("name", PropertyType.STRING, rules); - // Validate twice with the same regex to exercise the cache - var violations1 = service.validatePropertyValue(definition, "abc"); - var violations2 = service.validatePropertyValue(definition, "def"); + var violations = service.validatePropertyValue(definition, "AA"); - assertEquals(List.of(), violations1); - assertEquals(List.of(), violations2); - } + assertEquals(4, violations.size()); } - @Nested - @DisplayName("NUMBER validation") - class NumberValidationTests { + @Test + @DisplayName("Should use cached pattern for repeated regex validations") + void shouldUseCachedPatternForRepeatedRegex() { + var rules = new PropertyRules(null, null, null, "^[a-z]+$", null, null, null, null); + var definition = propertyDefinition("code", PropertyType.STRING, rules); - @Test - @DisplayName("Should report type mismatch when NUMBER value is null") - void shouldReportTypeMismatchWhenNumberValueIsNull() { - var definition = propertyDefinition("score", PropertyType.NUMBER, null); - var violations = service.validatePropertyValue(definition, null); + // Validate twice with the same regex to exercise the cache + var violations1 = service.validatePropertyValue(definition, "abc"); + var violations2 = service.validatePropertyValue(definition, "def"); - assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("score", PropertyType.NUMBER)), violations); - } + assertEquals(List.of(), violations1); + assertEquals(List.of(), violations2); + } + } + + @Nested + @DisplayName("NUMBER validation") + class NumberValidationTests { + + @Test + @DisplayName("Should report type mismatch when NUMBER value is null") + void shouldReportTypeMismatchWhenNumberValueIsNull() { + var definition = propertyDefinition("score", PropertyType.NUMBER, null); + var violations = service.validatePropertyValue(definition, null); + + assertEquals( + List.of( + ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("score", PropertyType.NUMBER)), + violations); + } - @Test - @DisplayName("Should report type mismatch for non-numeric NUMBER value") - void shouldReportTypeMismatchWhenNumberValueIsInvalid() { - var definition = propertyDefinition("score", PropertyType.NUMBER, null); + @Test + @DisplayName("Should report type mismatch for non-numeric NUMBER value") + void shouldReportTypeMismatchWhenNumberValueIsInvalid() { + var definition = propertyDefinition("score", PropertyType.NUMBER, null); - var violations = service.validatePropertyValue(definition, "not-a-number"); + var violations = service.validatePropertyValue(definition, "not-a-number"); - assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("score", PropertyType.NUMBER)), violations); - } + assertEquals( + List.of( + ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("score", PropertyType.NUMBER)), + violations); + } - @Test - @DisplayName("Should accept primitive/boxed Number objects") - void shouldAcceptBoxedNumberObjects() { - var definition = propertyDefinition("score", PropertyType.NUMBER, null); - var violationsInt = service.validatePropertyValue(definition, 42); - var violationsDouble = service.validatePropertyValue(definition, 42.5); + @Test + @DisplayName("Should accept primitive/boxed Number objects") + void shouldAcceptBoxedNumberObjects() { + var definition = propertyDefinition("score", PropertyType.NUMBER, null); + var violationsInt = service.validatePropertyValue(definition, 42); + var violationsDouble = service.validatePropertyValue(definition, 42.5); - assertEquals(List.of(), violationsInt); - assertEquals(List.of(), violationsDouble); - } + assertEquals(List.of(), violationsInt); + assertEquals(List.of(), violationsDouble); + } - @Test - @DisplayName("Should return no violations when NUMBER has no rules") - void shouldReturnNoViolationsWhenNumberHasNoRules() { - var definition = propertyDefinition("count", PropertyType.NUMBER, null); + @Test + @DisplayName("Should return no violations when NUMBER has no rules") + void shouldReturnNoViolationsWhenNumberHasNoRules() { + var definition = propertyDefinition("count", PropertyType.NUMBER, null); - var violations = service.validatePropertyValue(definition, "42"); + var violations = service.validatePropertyValue(definition, "42"); - assertEquals(List.of(), violations); - } + assertEquals(List.of(), violations); + } - @Test - @DisplayName("Should return no violations when NUMBER is within bounds") - void shouldReturnNoViolationsWhenNumberIsWithinBounds() { - var rules = new PropertyRules(null, null, null, null, null, null, 100, 0); - var definition = propertyDefinition("score", PropertyType.NUMBER, rules); + @Test + @DisplayName("Should return no violations when NUMBER is within bounds") + void shouldReturnNoViolationsWhenNumberIsWithinBounds() { + var rules = new PropertyRules(null, null, null, null, null, null, 100, 0); + var definition = propertyDefinition("score", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "50"); + var violations = service.validatePropertyValue(definition, "50"); - assertEquals(List.of(), violations); - } + assertEquals(List.of(), violations); + } - @Test - @DisplayName("Should report minValue violation") - void shouldReportMinValueViolation() { - var rules = new PropertyRules(null, null, null, null, null, null, 10, 5); - var definition = propertyDefinition("size", PropertyType.NUMBER, rules); + @Test + @DisplayName("Should report minValue violation") + void shouldReportMinValueViolation() { + var rules = new PropertyRules(null, null, null, null, null, null, 10, 5); + var definition = propertyDefinition("size", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "3"); + var violations = service.validatePropertyValue(definition, "3"); - assertEquals(List.of(ValidationMessages.PROPERTY_MIN_VALUE_VIOLATION.formatted("size", 5)), violations); - } + assertEquals(List.of(ValidationMessages.PROPERTY_MIN_VALUE_VIOLATION.formatted("size", 5)), + violations); + } - @Test - @DisplayName("Should report maxValue violation") - void shouldReportMaxValueViolation() { - var rules = new PropertyRules(null, null, null, null, null, null, 10, 0); - var definition = propertyDefinition("size", PropertyType.NUMBER, rules); + @Test + @DisplayName("Should report maxValue violation") + void shouldReportMaxValueViolation() { + var rules = new PropertyRules(null, null, null, null, null, null, 10, 0); + var definition = propertyDefinition("size", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "15"); + var violations = service.validatePropertyValue(definition, "15"); - assertEquals(List.of(ValidationMessages.PROPERTY_MAX_VALUE_VIOLATION.formatted("size", 10)), violations); - } + assertEquals(List.of(ValidationMessages.PROPERTY_MAX_VALUE_VIOLATION.formatted("size", 10)), + violations); + } - @Test - @DisplayName("Should report both minValue and maxValue violations") - void shouldReportBothMinAndMaxViolations() { - // minValue > maxValue edge case — value below min triggers min violation - var rules = new PropertyRules(null, null, null, null, null, null, 5, 10); - var definition = propertyDefinition("range", PropertyType.NUMBER, rules); + @Test + @DisplayName("Should report both minValue and maxValue violations") + void shouldReportBothMinAndMaxViolations() { + // minValue > maxValue edge case — value below min triggers min violation + var rules = new PropertyRules(null, null, null, null, null, null, 5, 10); + var definition = propertyDefinition("range", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "7"); + var violations = service.validatePropertyValue(definition, "7"); - // 7 < 10 (minValue) → min violation; 7 > 5 (maxValue) → max violation - assertEquals(2, violations.size()); - } + // 7 < 10 (minValue) → min violation; 7 > 5 (maxValue) → max violation + assertEquals(2, violations.size()); + } - @Test - @DisplayName("Should accept decimal number values") - void shouldAcceptDecimalNumberValues() { - var rules = new PropertyRules(null, null, null, null, null, null, 100, 0); - var definition = propertyDefinition("rate", PropertyType.NUMBER, rules); + @Test + @DisplayName("Should accept decimal number values") + void shouldAcceptDecimalNumberValues() { + var rules = new PropertyRules(null, null, null, null, null, null, 100, 0); + var definition = propertyDefinition("rate", PropertyType.NUMBER, rules); - var violations = service.validatePropertyValue(definition, "99.5"); + var violations = service.validatePropertyValue(definition, "99.5"); - assertEquals(List.of(), violations); - } + assertEquals(List.of(), violations); + } - @Test - @DisplayName("Should report type mismatch when a boolean is sent for a NUMBER property") - void shouldReportTypeMismatchWhenBooleanSentForNumber() { - var definition = propertyDefinition("count", PropertyType.NUMBER, null); + @Test + @DisplayName("Should report type mismatch when a boolean is sent for a NUMBER property") + void shouldReportTypeMismatchWhenBooleanSentForNumber() { + var definition = propertyDefinition("count", PropertyType.NUMBER, null); - var violations = service.validatePropertyValue(definition, "true"); + var violations = service.validatePropertyValue(definition, "true"); - assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("count", PropertyType.NUMBER)), violations); - } + assertEquals( + List.of( + ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("count", PropertyType.NUMBER)), + violations); } + } - @Nested - @DisplayName("BOOLEAN validation") - class BooleanValidationTests { - - @Test - @DisplayName("Should accept raw Boolean objects") - void shouldAcceptRawBooleanObjects() { - var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); + @Nested + @DisplayName("BOOLEAN validation") + class BooleanValidationTests { - var violationsTrue = service.validatePropertyValue(definition, true); - var violationsFalse = service.validatePropertyValue(definition, Boolean.FALSE); + @Test + @DisplayName("Should accept raw Boolean objects") + void shouldAcceptRawBooleanObjects() { + var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); - assertEquals(List.of(), violationsTrue); - assertEquals(List.of(), violationsFalse); - } + var violationsTrue = service.validatePropertyValue(definition, true); + var violationsFalse = service.validatePropertyValue(definition, Boolean.FALSE); - @ParameterizedTest(name = "Should accept valid boolean string value: ''{0}''") - @ValueSource(strings = {"true", "false", "TRUE", "FALSE"}) - void shouldAcceptValidBooleanValues(String value) { - var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); + assertEquals(List.of(), violationsTrue); + assertEquals(List.of(), violationsFalse); + } - var violations = service.validatePropertyValue(definition, value); + @ParameterizedTest(name = "Should accept valid boolean string value: ''{0}''") + @ValueSource(strings = {"true", "false", "TRUE", "FALSE"}) + void shouldAcceptValidBooleanValues(String value) { + var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); - assertEquals(List.of(), violations); - } + var violations = service.validatePropertyValue(definition, value); - @ParameterizedTest(name = "Should report type mismatch for invalid BOOLEAN input: {0}") - @MethodSource("invalidBooleanValues") - void shouldReportTypeMismatchForInvalidBooleanInputs(Object value) { - var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); + assertEquals(List.of(), violations); + } - var violations = service.validatePropertyValue(definition, value); + @ParameterizedTest(name = "Should report type mismatch for invalid BOOLEAN input: {0}") + @MethodSource("invalidBooleanValues") + void shouldReportTypeMismatchForInvalidBooleanInputs(Object value) { + var definition = propertyDefinition("flag", PropertyType.BOOLEAN, null); - assertEquals(List.of(ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("flag", PropertyType.BOOLEAN)), violations); - } + var violations = service.validatePropertyValue(definition, value); - private static Stream invalidBooleanValues() { - return Stream.of(null, "yes", "42"); - } + assertEquals( + List.of( + ValidationMessages.PROPERTY_TYPE_MISMATCH.formatted("flag", PropertyType.BOOLEAN)), + violations); } - private PropertyDefinition propertyDefinition(String name, PropertyType type, PropertyRules rules) { - return new PropertyDefinition(null, name, "description", type, true, rules); + private static Stream invalidBooleanValues() { + return Stream.of(null, "yes", "42"); } + } + + private PropertyDefinition propertyDefinition(String name, PropertyType type, + PropertyRules rules) { + return new PropertyDefinition(null, name, "description", type, true, rules); + } } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/relation/RelationValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/relation/RelationValidationServiceTest.java index d53ff01f..2e4ca801 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/relation/RelationValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/relation/RelationValidationServiceTest.java @@ -14,10 +14,10 @@ import java.util.List; import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -33,212 +33,196 @@ @ExtendWith(MockitoExtension.class) class RelationValidationServiceTest { - @Mock - private EntityRepositoryPort entityRepository; + @Mock + private EntityRepositoryPort entityRepository; - private RelationValidationService service; + private RelationValidationService service; - @BeforeEach - void setUp() { - service = new RelationValidationService(entityRepository); - } + @BeforeEach + void setUp() { + service = new RelationValidationService(entityRepository); + } - private void mockExistingEntities(String... identifiers) { - var summaries = Arrays.stream(identifiers) - .map(id -> new EntitySummary(id, "Name", "template")) - .toList(); - when(entityRepository.findByIdentifierIn(any())).thenReturn(summaries); - } + private void mockExistingEntities(String... identifiers) { + var summaries = Arrays.stream(identifiers).map(id -> new EntitySummary(id, "Name", "template")) + .toList(); + when(entityRepository.findByIdentifierIn(any())).thenReturn(summaries); + } - @Test - @DisplayName("Should pass all checks cleanly when relations map exactly to definitions") - void shouldPassCleanlyOnValidEntity() { - mockExistingEntities("team-a", "service-x", "service-y"); - var definition1 = definition("owned-by", true, false); - var definition2 = definition("depends-on", false, true); - var template = template("system-template", List.of(definition1, definition2)); + @Test + @DisplayName("Should pass all checks cleanly when relations map exactly to definitions") + void shouldPassCleanlyOnValidEntity() { + mockExistingEntities("team-a", "service-x", "service-y"); + var definition1 = definition("owned-by", true, false); + var definition2 = definition("depends-on", false, true); + var template = template("system-template", List.of(definition1, definition2)); - var relation1 = relation("owned-by", List.of("team-a")); - var relation2 = relation("depends-on", List.of("service-x", "service-y")); + var relation1 = relation("owned-by", List.of("team-a")); + var relation2 = relation("depends-on", List.of("service-x", "service-y")); - var violations = mock(Violations.class); + var violations = mock(Violations.class); - service.validateRelationsAgainstTemplate(template, List.of(relation1, relation2), violations); + service.validateRelationsAgainstTemplate(template, List.of(relation1, relation2), violations); - verifyNoInteractions(violations); - } + verifyNoInteractions(violations); + } - private EntityTemplate template(String identifier, List relationDefinitions) { - return new EntityTemplate( - UUID.randomUUID(), - identifier, - "Template Name", - "Description", - List.of(), - relationDefinitions - ); - } + private EntityTemplate template(String identifier, List relationDefinitions) { + return new EntityTemplate(UUID.randomUUID(), identifier, "Template Name", "Description", + List.of(), relationDefinitions); + } + + private RelationDefinition definition(String name, boolean required, boolean toMany) { + return new RelationDefinition(UUID.randomUUID(), name, "targetType", required, toMany); + } - private RelationDefinition definition(String name, boolean required, boolean toMany) { - return new RelationDefinition( - UUID.randomUUID(), - name, - "targetType", - required, - toMany - ); + private Relation relation(String name, List targets) { + return new Relation(UUID.randomUUID(), name, "targetType", targets); + } + + @Nested + @DisplayName("Relation Existence Checks") + class ExistenceTests { + + @Test + @DisplayName("Should report violation when relation is not defined in the template") + void shouldReportViolationWhenRelationNotDefined() { + var template = template("system-template", List.of()); + var relation = relation("unknown-relation", List.of("target-1")); + var violations = mock(Violations.class); + + service.validateRelationsAgainstTemplate(template, List.of(relation), violations); + + verify(violations).add(RELATION_NOT_DEFINED_IN_TEMPLATE, "unknown-relation", + "system-template"); } - private Relation relation(String name, List targets) { - return new Relation( - UUID.randomUUID(), - name, - "targetType", - targets - ); + @Test + @DisplayName("Should handle missing definition lists and relation lists gracefully") + void shouldHandleNullListsGracefully() { + var template = new EntityTemplate(UUID.randomUUID(), "system-template", "System", "desc", + List.of(), null); + var violations = mock(Violations.class); + + service.validateRelationsAgainstTemplate(template, null, violations); + + verifyNoInteractions(violations); } - @Nested - @DisplayName("Relation Existence Checks") - class ExistenceTests { + @Test + @DisplayName("Should report violation when relation target entity does not exist") + void shouldReportViolationWhenRelationTargetEntityDoesNotExist() { + mockExistingEntities("existing-team"); + var definition = definition("owned-by", true, false); + var template = template("system-template", List.of(definition)); + var relation = relation("owned-by", List.of("missing-team")); + var violations = mock(Violations.class); - @Test - @DisplayName("Should report violation when relation is not defined in the template") - void shouldReportViolationWhenRelationNotDefined() { - var template = template("system-template", List.of()); - var relation = relation("unknown-relation", List.of("target-1")); - var violations = mock(Violations.class); + service.validateRelationsAgainstTemplate(template, List.of(relation), violations); - service.validateRelationsAgainstTemplate(template, List.of(relation), violations); + verify(violations).add(RELATION_TARGET_ENTITY_NOT_FOUND, "owned-by", "missing-team"); + } + } - verify(violations).add(RELATION_NOT_DEFINED_IN_TEMPLATE, "unknown-relation", "system-template"); - } + @Nested + @DisplayName("Relation Requirement Checks") + class RequirementTests { - @Test - @DisplayName("Should handle missing definition lists and relation lists gracefully") - void shouldHandleNullListsGracefully() { - var template = new EntityTemplate(UUID.randomUUID(), "system-template", "System", "desc", List.of(), null); - var violations = mock(Violations.class); + @Test + @DisplayName("Should report violation when required relation is missing completely") + void shouldReportViolationWhenRequiredRelationMissing() { + var definition = definition("owned-by", true, false); + var template = template("system-template", List.of(definition)); + var violations = mock(Violations.class); - service.validateRelationsAgainstTemplate(template, null, violations); + service.validateRelationsAgainstTemplate(template, List.of(), violations); - verifyNoInteractions(violations); - } + verify(violations).add(RELATION_REQUIRED_MISSING, "owned-by", "system-template"); + } - @Test - @DisplayName("Should report violation when relation target entity does not exist") - void shouldReportViolationWhenRelationTargetEntityDoesNotExist() { - mockExistingEntities("existing-team"); - var definition = definition("owned-by", true, false); - var template = template("system-template", List.of(definition)); - var relation = relation("owned-by", List.of("missing-team")); - var violations = mock(Violations.class); + @Test + @DisplayName("Should report violation when required relation is provided but target list is empty") + void shouldReportViolationWhenRequiredRelationHasEmptyTargets() { + var definition = definition("owned-by", true, false); + var template = template("system-template", List.of(definition)); + var relation = relation("owned-by", List.of()); + var violations = mock(Violations.class); - service.validateRelationsAgainstTemplate(template, List.of(relation), violations); + service.validateRelationsAgainstTemplate(template, List.of(relation), violations); - verify(violations).add(RELATION_TARGET_ENTITY_NOT_FOUND, "owned-by", "missing-team"); - } + verify(violations).add(RELATION_REQUIRED_MISSING, "owned-by", "system-template"); } - @Nested - @DisplayName("Relation Requirement Checks") - class RequirementTests { + @Test + @DisplayName("Should report violation when required relation only has blank targets") + void shouldReportViolationWhenRequiredRelationHasOnlyBlankTargets() { + var definition = definition("owned-by", true, false); + var template = template("system-template", List.of(definition)); + var relation = relation("owned-by", List.of("", " ")); + var violations = mock(Violations.class); - @Test - @DisplayName("Should report violation when required relation is missing completely") - void shouldReportViolationWhenRequiredRelationMissing() { - var definition = definition("owned-by", true, false); - var template = template("system-template", List.of(definition)); - var violations = mock(Violations.class); + service.validateRelationsAgainstTemplate(template, List.of(relation), violations); - service.validateRelationsAgainstTemplate(template, List.of(), violations); + verify(violations).add(RELATION_REQUIRED_MISSING, "owned-by", "system-template"); + } - verify(violations).add(RELATION_REQUIRED_MISSING, "owned-by", "system-template"); - } + @Test + @DisplayName("Should not report violation when an optional relation is omitted") + void shouldNotReportViolationWhenOptionalRelationOmitted() { + var definition = definition("depends-on", false, true); + var template = template("system-template", List.of(definition)); + var violations = mock(Violations.class); - @Test - @DisplayName("Should report violation when required relation is provided but target list is empty") - void shouldReportViolationWhenRequiredRelationHasEmptyTargets() { - var definition = definition("owned-by", true, false); - var template = template("system-template", List.of(definition)); - var relation = relation("owned-by", List.of()); - var violations = mock(Violations.class); + service.validateRelationsAgainstTemplate(template, List.of(), violations); - service.validateRelationsAgainstTemplate(template, List.of(relation), violations); + verifyNoInteractions(violations); + } + } - verify(violations).add(RELATION_REQUIRED_MISSING, "owned-by", "system-template"); - } + @Nested + @DisplayName("Relation Cardinality Checks") + class CardinalityTests { - @Test - @DisplayName("Should report violation when required relation only has blank targets") - void shouldReportViolationWhenRequiredRelationHasOnlyBlankTargets() { - var definition = definition("owned-by", true, false); - var template = template("system-template", List.of(definition)); - var relation = relation("owned-by", List.of("", " ")); - var violations = mock(Violations.class); + @Test + @DisplayName("Should report violation when a non-toMany relation has multiple valid targets") + void shouldReportViolationForMultipleTargetsOnSingleRelation() { + mockExistingEntities("team-a", "team-b"); + var definition = definition("owned-by", true, false); + var template = template("system-template", List.of(definition)); + var relation = relation("owned-by", List.of("team-a", "team-b")); + var violations = mock(Violations.class); - service.validateRelationsAgainstTemplate(template, List.of(relation), violations); + service.validateRelationsAgainstTemplate(template, List.of(relation), violations); - verify(violations).add(RELATION_REQUIRED_MISSING, "owned-by", "system-template"); - } + verify(violations).add(RELATION_TOO_MANY_TARGETS, "owned-by", "system-template"); + } - @Test - @DisplayName("Should not report violation when an optional relation is omitted") - void shouldNotReportViolationWhenOptionalRelationOmitted() { - var definition = definition("depends-on", false, true); - var template = template("system-template", List.of(definition)); - var violations = mock(Violations.class); + @Test + @DisplayName("Should not report violation for multiple targets if toMany is true") + void shouldNotReportViolationForMultipleTargetsWhenToManyIsTrue() { + mockExistingEntities("service-a", "service-b", "service-c"); + var definition = definition("depends-on", false, true); + var template = template("system-template", List.of(definition)); + var relation = relation("depends-on", List.of("service-a", "service-b", "service-c")); + var violations = mock(Violations.class); - service.validateRelationsAgainstTemplate(template, List.of(), violations); + service.validateRelationsAgainstTemplate(template, List.of(relation), violations); - verifyNoInteractions(violations); - } + verifyNoInteractions(violations); } - @Nested - @DisplayName("Relation Cardinality Checks") - class CardinalityTests { - - @Test - @DisplayName("Should report violation when a non-toMany relation has multiple valid targets") - void shouldReportViolationForMultipleTargetsOnSingleRelation() { - mockExistingEntities("team-a", "team-b"); - var definition = definition("owned-by", true, false); - var template = template("system-template", List.of(definition)); - var relation = relation("owned-by", List.of("team-a", "team-b")); - var violations = mock(Violations.class); - - service.validateRelationsAgainstTemplate(template, List.of(relation), violations); - - verify(violations).add(RELATION_TOO_MANY_TARGETS, "owned-by", "system-template"); - } - - @Test - @DisplayName("Should not report violation for multiple targets if toMany is true") - void shouldNotReportViolationForMultipleTargetsWhenToManyIsTrue() { - mockExistingEntities("service-a", "service-b", "service-c"); - var definition = definition("depends-on", false, true); - var template = template("system-template", List.of(definition)); - var relation = relation("depends-on", List.of("service-a", "service-b", "service-c")); - var violations = mock(Violations.class); - - service.validateRelationsAgainstTemplate(template, List.of(relation), violations); - - verifyNoInteractions(violations); - } - - @Test - @DisplayName("Should ignore blank targets when checking cardinality constraints") - void shouldIgnoreBlankTargetsForCardinality() { - mockExistingEntities("team-a"); - var definition = definition("owned-by", true, false); - var template = template("system-template", List.of(definition)); - var relation = relation("owned-by", List.of("team-a", " ", "")); - var violations = mock(Violations.class); - - service.validateRelationsAgainstTemplate(template, List.of(relation), violations); - - verifyNoInteractions(violations); - } + @Test + @DisplayName("Should ignore blank targets when checking cardinality constraints") + void shouldIgnoreBlankTargetsForCardinality() { + mockExistingEntities("team-a"); + var definition = definition("owned-by", true, false); + var template = template("system-template", List.of(definition)); + var relation = relation("owned-by", List.of("team-a", " ", "")); + var violations = mock(Violations.class); + + service.validateRelationsAgainstTemplate(template, List.of(relation), violations); + + verifyNoInteractions(violations); } + } } 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 f5aace73..a15741b0 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 @@ -29,13 +29,13 @@ /// authentication, and lookup by template identifier and entity identifier. public class EntityControllerTest extends AbstractIntegrationTest { - private static final String TEMPLATE_IDENTIFIER = "web-service"; - private static final String ENTITY_IDENTIFIER = "web-api-2"; - private static final String ENTITIES_BY_IDENTIFIER_PATH = "/api/v1/entities/{template-identifier}/{identifier}"; - private static final String ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH = "/api/v1/entities/{template-identifier}"; - private static final String ENTITY_JSON_FILES_TEST_PATH = "integration_test/json/entity/v1/"; - @Autowired - private MockMvc mockMvc; + private static final String TEMPLATE_IDENTIFIER = "web-service"; + private static final String ENTITY_IDENTIFIER = "web-api-2"; + private static final String ENTITIES_BY_IDENTIFIER_PATH = "/api/v1/entities/{template-identifier}/{identifier}"; + private static final String ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH = "/api/v1/entities/{template-identifier}"; + private static final String ENTITY_JSON_FILES_TEST_PATH = "integration_test/json/entity/v1/"; + @Autowired + private MockMvc mockMvc; /// Tests for GET /api/v1/entities/{template-identifier} endpoint /// (paginated retrieval). @@ -43,20 +43,17 @@ public class EntityControllerTest extends AbstractIntegrationTest { @DisplayName("GET /api/v1/entities/{template-identifier} - Get Templates Paginated") class GetEntitiesByTemplateIdentifierTests { - @Test @DisplayName("Should return paginated entities with default pagination") @WithMockUser void getEntities_paginated_200() throws Exception { - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .param("page", "0") - .param("size", "15") - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(APPLICATION_JSON)) + mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER).param("page", "0") + .param("size", "15").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) .andExpect(jsonPath("$.content").isArray()) - .andExpect(jsonPath("$.content.length()").value(2)) - .andExpect(jsonPath("$.page.total_elements").value(2)) + .andExpect(jsonPath("$.content.length()").value(5)) + .andExpect(jsonPath("$.page.total_elements").value(5)) .andExpect(jsonPath("$.page.total_pages").value(1)) .andExpect(jsonPath("$.page.size").value(15)) .andExpect(jsonPath("$.page.number").value(0)) @@ -68,8 +65,7 @@ void getEntities_paginated_200() throws Exception { @WithMockUser void getEntities_paginated_404_when_non_existent_template() throws Exception { mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "non-existent-template-identifier") - .accept(APPLICATION_JSON)) - .andExpect(status().isNotFound()); + .accept(APPLICATION_JSON)).andExpect(status().isNotFound()); } @Test @@ -77,57 +73,52 @@ void getEntities_paginated_404_when_non_existent_template() throws Exception { @WithMockUser void getEntities_404_nonExistentTemplate_withFilter() throws Exception { mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "non-existent-template-identifier") - .param("q", "name=foo") - .accept(APPLICATION_JSON)) - .andExpect(status().isNotFound()); + .param("q", "name=foo").accept(APPLICATION_JSON)).andExpect(status().isNotFound()); } - @Test @DisplayName("Should return 401 without authentication") void getTemplates_paginated_401_without_user_token() throws Exception { - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .accept(APPLICATION_JSON)) + mockMvc.perform( + get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER).accept(APPLICATION_JSON)) .andExpect(status().isUnauthorized()); } - @Test - @DisplayName("Should return paginated entities with custom pagination") - @WithMockUser - void getEntities_paginated_200_custom() throws Exception { - - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "monitoring-service") - .param("page", "1") - .param("size", "5") - .param("sort", "template_identifier,asc") - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(APPLICATION_JSON)) - .andExpect(jsonPath("$.content.length()").value(1)) - .andExpect(jsonPath("$.content[0].name").value("Monitoring Service 6")) - .andExpect(jsonPath("$.page.total_elements").value(6)) - .andExpect(jsonPath("$.page.total_pages").value(2)) - .andExpect(jsonPath("$.page.size").value(5)) - .andExpect(jsonPath("$.page.number").value(1)); - } + @Test + @DisplayName("Should return paginated entities with custom pagination") + @WithMockUser + void getEntities_paginated_200_custom() throws Exception { + + mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "monitoring-service") + .param("page", "1").param("size", "5").param("sort", "template_identifier,asc") + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.content.length()").value(1)) + .andExpect(jsonPath("$.content[0].name").value("Monitoring Service 6")) + .andExpect(jsonPath("$.page.total_elements").value(6)) + .andExpect(jsonPath("$.page.total_pages").value(2)) + .andExpect(jsonPath("$.page.size").value(5)) + .andExpect(jsonPath("$.page.number").value(1)); + } - @Test - @DisplayName("Should return paginated entities with default pagination") - @WithMockUser - void getEntities_invalid_pagination_200() throws Exception { - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(APPLICATION_JSON)) - .andExpect(jsonPath("$.content").isArray()) - .andExpect(jsonPath("$.content.length()").value(2)) - .andExpect(jsonPath("$.page.total_elements").value(2)) - .andExpect(jsonPath("$.page.total_pages").value(1)) - .andExpect(jsonPath("$.page.size").value(20)) - .andExpect(jsonPath("$.page.number").value(0)) - .andExpect(jsonPath("$.content[0].template_identifier").value(TEMPLATE_IDENTIFIER)); - } + @Test + @DisplayName("Should return paginated entities with default pagination") + @WithMockUser + void getEntities_invalid_pagination_200() throws Exception { + mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content.length()").value(5)) + .andExpect(jsonPath("$.page.total_elements").value(5)) + .andExpect(jsonPath("$.page.total_pages").value(1)) + .andExpect(jsonPath("$.page.size").value(20)) + .andExpect(jsonPath("$.page.number").value(0)) + .andExpect(jsonPath("$.content[0].template_identifier").value(TEMPLATE_IDENTIFIER)); } + } /// Tests for GET /api/v1/entities/{template-identifier}?q= endpoint @Nested @@ -135,92 +126,82 @@ void getEntities_invalid_pagination_200() throws Exception { class GetEntitiesByTemplateIdentifierWithFilterTests { @ParameterizedTest - @CsvSource({ - "identifier=web-api-1", - "name:Web API 1", - "property.programmingLanguage=JAVA", - "relation=api-link", - "relation.api-link.name:microservice", - "relation=api-link;relation.api-link.name:microservice" - }) + @CsvSource({"identifier=web-api-1", "name:Web API 1", "property.programmingLanguage=JAVA", + "relation=api-link", "relation.api-link.name:microservice", + "relation=api-link;relation.api-link.name:microservice"}) @DisplayName("Should filter entities by various criteria") @WithMockUser void getEntities_200_withFilter(String query) throws Exception { - MvcResult mvcResult = mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .param("q", query) - .accept(APPLICATION_JSON) - .with(csrf())) - .andExpect(status().isOk()) - .andReturn(); + MvcResult mvcResult = mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER).param("q", query) + .accept(APPLICATION_JSON).with(csrf())) + .andExpect(status().isOk()).andReturn(); JSONAssert.assertEquals( - getJsonTestFileContent(ENTITY_JSON_FILES_TEST_PATH + "getEntities_200_identifierEquals.json"), - mvcResult.getResponse().getContentAsString(), - JSONCompareMode.STRICT); + getJsonTestFileContent( + ENTITY_JSON_FILES_TEST_PATH + "getEntities_200_identifierEquals.json"), + mvcResult.getResponse().getContentAsString(), JSONCompareMode.STRICT); } @Test @DisplayName("Should return empty page when no entity matches filter") @WithMockUser void getEntities_200_noMatch() throws Exception { - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .param("q", "name=nonexistent-entity") - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.content.length()").value(0)); + mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .param("q", "name=nonexistent-entity").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(jsonPath("$.content.length()").value(0)); } @Test @DisplayName("Should filter microservices by relations_as_target identifier") @WithMockUser void getEntities_200_relationsAsTargetIdentifier() throws Exception { - MvcResult mvcResult = mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "microservice") - .param("q", "relations_as_target.api-link.identifier=web-api-1") - .accept(APPLICATION_JSON) - .with(csrf())) - .andExpect(status().isOk()) - .andReturn(); + MvcResult mvcResult = mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "microservice") + .param("q", "relations_as_target.api-link.identifier=web-api-1") + .accept(APPLICATION_JSON).with(csrf())) + .andExpect(status().isOk()).andReturn(); JSONAssert.assertEquals( - getJsonTestFileContent(ENTITY_JSON_FILES_TEST_PATH + "getEntities_200_relationsAsTargetIdentifier.json"), - mvcResult.getResponse().getContentAsString(), - JSONCompareMode.STRICT); + getJsonTestFileContent( + ENTITY_JSON_FILES_TEST_PATH + "getEntities_200_relationsAsTargetIdentifier.json"), + mvcResult.getResponse().getContentAsString(), JSONCompareMode.STRICT); } @Test @DisplayName("Should filter microservices by relations_as_target name contains") @WithMockUser void getEntities_200_relationsAsTargetNameContains() throws Exception { - MvcResult mvcResult = mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "microservice") - .param("q", "relations_as_target.api-link.name:Web API") - .accept(APPLICATION_JSON) - .with(csrf())) - .andExpect(status().isOk()) - .andReturn(); + MvcResult mvcResult = mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "microservice") + .param("q", "relations_as_target.api-link.name:Web API").accept(APPLICATION_JSON) + .with(csrf())) + .andExpect(status().isOk()).andReturn(); JSONAssert.assertEquals( - getJsonTestFileContent(ENTITY_JSON_FILES_TEST_PATH + "getEntities_200_relationsAsTargetIdentifier.json"), - mvcResult.getResponse().getContentAsString(), - JSONCompareMode.STRICT); + getJsonTestFileContent( + ENTITY_JSON_FILES_TEST_PATH + "getEntities_200_relationsAsTargetIdentifier.json"), + mvcResult.getResponse().getContentAsString(), JSONCompareMode.STRICT); } @Test @DisplayName("Should return 400 for malformed query without operator") @WithMockUser void getEntities_400_malformedQuery() throws Exception { - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .param("q", "noOperator") - .accept(APPLICATION_JSON)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error_description").value("Invalid query format, expected field:operator:value")); + mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .param("q", "noOperator").accept(APPLICATION_JSON)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error_description") + .value("Invalid query format, expected field:operator:value")); } @Test @DisplayName("Should return 400 for duplicate criterion on the same field") @WithMockUser void getEntities_400_duplicateCriterion() throws Exception { - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .param("q", "name=A;name=B") - .accept(APPLICATION_JSON)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error_description").value("Multiple filters for the same property are not supported")); + mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .param("q", "name=A;name=B").accept(APPLICATION_JSON)) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error_description") + .value("Multiple filters for the same property are not supported")); } @Test @@ -229,38 +210,32 @@ void getEntities_400_duplicateCriterion() throws Exception { void getEntities_400_tooManyCriteria() throws Exception { 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"; - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .param("q", query) - .accept(APPLICATION_JSON)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error_description").value("Filter query exceeds maximum of 10 criteria")); + mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER).param("q", query) + .accept(APPLICATION_JSON)) + .andExpect(status().isBadRequest()).andExpect( + jsonPath("$.error_description").value("Filter query exceeds maximum of 10 criteria")); } @ParameterizedTest(name = "comparison filter ''{0}'' returns 400") - @CsvSource({ - "nameWeb API 1" - }) + @CsvSource({"nameWeb API 1"}) @DisplayName("Should return 400 when < or > is used on attribute fields") @WithMockUser void getEntities_400_comparisonOnAttribute(String query) throws Exception { - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .param("q", query.trim()) - .accept(APPLICATION_JSON)) + mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .param("q", query.trim()).accept(APPLICATION_JSON)) .andExpect(status().isBadRequest()); } @ParameterizedTest(name = "comparison filter ''{0}'' returns 400") - @CsvSource({ - "property.programmingLanguageJAVA" - }) + @CsvSource({"property.programmingLanguageJAVA"}) @DisplayName("Should return 400 when < or > is used on a STRING property") @WithMockUser void getEntities_400_comparisonOnStringProperty(String query) throws Exception { - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .param("q", query.trim()) - .accept(APPLICATION_JSON)) + mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .param("q", query.trim()).accept(APPLICATION_JSON)) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.error_description").value( "Operation '%s' is not applicable for property 'programmingLanguage': only NUMBER properties support comparison operators." @@ -268,16 +243,14 @@ void getEntities_400_comparisonOnStringProperty(String query) throws Exception { } @ParameterizedTest(name = "comparison filter ''{0}'' returns {1} result(s)") - @CsvSource({ - "property.port<9090, 1", - "property.port>8080, 1" - }) + @CsvSource({"property.port<9090, 1", "property.port>8080, 1"}) @DisplayName("Should filter entities using < and > comparison operators on a NUMBER property") @WithMockUser - void getEntities_200_comparisonOnNumberProperty(String query, int expectedCount) throws Exception { - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .param("q", query.trim()) - .accept(APPLICATION_JSON)) + void getEntities_200_comparisonOnNumberProperty(String query, int expectedCount) + throws Exception { + mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .param("q", query.trim()).accept(APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.content.length()").value(expectedCount)); } @@ -287,25 +260,22 @@ void getEntities_200_comparisonOnNumberProperty(String query, int expectedCount) @DisplayName("Should return all entities when q is empty or blank") @WithMockUser void getEntities_200_emptyOrBlankQ_returnsAllEntities(String q) throws Exception { - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .param("q", q) - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.content.length()").value(2)) - .andExpect(jsonPath("$.page.total_elements").value(2)); + mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER).param("q", q) + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(jsonPath("$.content.length()").value(5)) + .andExpect(jsonPath("$.page.total_elements").value(5)); } @Test @DisplayName("Should filter and paginate when q and page/size are combined") @WithMockUser void getEntities_200_paginationWithFilter() throws Exception { - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "monitoring-service") - .param("q", "name:Monitoring") - .param("page", "1") - .param("size", "3") - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.content.length()").value(3)) + mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "monitoring-service") + .param("q", "name:Monitoring").param("page", "1").param("size", "3") + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(jsonPath("$.content.length()").value(3)) .andExpect(jsonPath("$.page.total_elements").value(6)) .andExpect(jsonPath("$.page.total_pages").value(2)) .andExpect(jsonPath("$.page.number").value(1)); @@ -316,59 +286,57 @@ void getEntities_200_paginationWithFilter() throws Exception { @WithMockUser void getEntities_200_comparisonOperators_areCaseSensitive() throws Exception { // EQUALS normalises both sides to lowercase → case-insensitive match - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .param("q", "property.programmingLanguage=python") - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.content.length()").value(1)); + mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .param("q", "property.programmingLanguage=python").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(jsonPath("$.content.length()").value(1)); // LESS_THAN and GREATER_THAN pass values to the DB without lowercasing. // port is a NUMBER property (8080 for web-api-1, 9090 for web-api-2). - // These assertions verify correct boundary semantics using raw numeric string comparison. - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .param("q", "property.port>8080") - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.content.length()").value(1)); - - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .param("q", "property.port<9090") - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.content.length()").value(1)); + // These assertions verify correct boundary semantics using raw numeric string + // comparison. + mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .param("q", "property.port>8080").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(jsonPath("$.content.length()").value(1)); + + mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .param("q", "property.port<9090").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(jsonPath("$.content.length()").value(1)); } @Test @DisplayName("Should return 400 for operator mismatch on criterion type") @WithMockUser void getEntities_400_typeMismatch() throws Exception { - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .param("q", "relation updatedTemplateOpt = entityTemplateRepository + .findByIdentifier("template-rel-test"); + assertThat(updatedTemplateOpt).isPresent(); + + EntityTemplate updatedTemplate = updatedTemplateOpt.get(); + + // Vérifier description mise à jour + assertThat(updatedTemplate.description()).isEqualTo("Updated template with new relation"); + + // Vérifier properties + assertThat(updatedTemplate.propertiesDefinitions()).hasSize(1); + assertThat(updatedTemplate.propertiesDefinitions().get(0).description()) + .isEqualTo("Updated description"); + + // Vérifier relations + assertThat(updatedTemplate.relationsDefinitions()).hasSize(2); + + Map relationsMap = updatedTemplate.relationsDefinitions().stream() + .collect(Collectors.toMap(RelationDefinition::name, r -> r)); + + assertThat(relationsMap.get("owns").targetTemplateIdentifier()).isEqualTo("microservice"); + assertThat(relationsMap.get("owns").required()).isFalse(); + assertThat(relationsMap.get("owns").toMany()).isFalse(); + + assertThat(relationsMap.get("belongsTo").targetTemplateIdentifier()) + .isEqualTo("database-service"); + assertThat(relationsMap.get("belongsTo").required()).isTrue(); + assertThat(relationsMap.get("belongsTo").toMany()).isFalse(); + } + + @Test + @WithMockUser() + @DisplayName("Should update template and return 201") + void putTemplate_200() throws Exception { + String identifier = "web-service"; + mockMvc + .perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_200.json"))) + .andExpect(status().isOk()); + + Optional entityTemplateUpdated = entityTemplateRepository + .findByIdentifier("web-service"); + assertThat(entityTemplateUpdated).isPresent(); + assertThat(entityTemplateUpdated.get().propertiesDefinitions()).hasSize(2); + assertThat(entityTemplateUpdated.get().relationsDefinitions()).isEmpty(); + } + + /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint without + /// properties. + /// This test verifies that: + /// - Templates can be updated without any properties + /// - The endpoint returns HTTP 200 OK status + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Should update template without properties and return 200") + void putTemplate_200_without_properties() throws Exception { + String identifier = "web-service"; + mockMvc + .perform( + MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + + "putEntityTemplate_200_without_properties.json"))) + .andExpect(status().isOk()); + } + + /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint with empty + /// properties array. + /// This test verifies that: + /// - Templates can be updated with an empty properties array + /// - The endpoint returns HTTP 200 OK status + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Should update template with empty properties array and return 200") + void putTemplate_200_with_empty_properties() throws Exception { + String identifier = "web-service"; + mockMvc + .perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + + "putEntityTemplate_200_with_empty_properties.json"))) + .andExpect(status().isOk()); + } + @Test + @WithMockUser + void putTemplate_404_withUnknownIdentifier() throws Exception { + String identifier = "unknown-identifier"; + mockMvc + .perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_200.json"))) + .andExpect(status().isNotFound()).andExpect(content().string( + "{\"error\":\"NOT_FOUND\",\"error_description\":\"Template not found with identifier: unknown-identifier\"}")); } - @Nested - @DisplayName("PUT /api/v1/entity-templates - Update Template") - @Order(3) - class PutTemplateTests { - - @Test - void putTemplate_without_user_token_401() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - "integration_test/json/entity-template/v1/putEntityTemplate_200.json"))) - .andExpect(status().isUnauthorized()); - } - - @Test - @WithMockUser - @DisplayName("Should update existing property rules using PUT") - void putTemplate_shouldMergePropertyRules() throws Exception { - - mockMvc.perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH - + "postEntityTemplateWithoutRelationsDefinitions_201.json"))) - .andExpect(status().isCreated()); - - EntityTemplate initialTemplate = entityTemplateRepository - .findByIdentifier("temp-test-99") - .orElseThrow(); - - PropertyDefinition initialProperty = initialTemplate.propertiesDefinitions().get(0); - UUID initialRulesId = initialProperty.rules().id(); - - mockMvc.perform(MockMvcRequestBuilders.put("/api/v1/entity-templates/temp-test-99") - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH - + "putEntityTemplate_updateRules_200.json"))) - .andExpect(status().isOk()); - - EntityTemplate updatedTemplate = entityTemplateRepository - .findByIdentifier("temp-test-99") - .orElseThrow(); - - assertThat(updatedTemplate.propertiesDefinitions()).hasSize(1); - - PropertyDefinition updatedProperty = updatedTemplate.propertiesDefinitions().get(0); - - assertThat(updatedProperty.name()).isEqualTo("property-test"); - - PropertyRules updatedRules = updatedProperty.rules(); - assertThat(updatedRules.format()).isNull(); - assertThat(updatedRules.regex()).isEqualTo("^[a-zA-Z0-9]+$"); - assertThat(updatedRules.maxLength()).isEqualTo(255); - assertThat(updatedRules.minLength()).isNull(); - - assertThat(updatedRules.id()).isEqualTo(initialRulesId); - - assertThat(updatedTemplate.relationsDefinitions()).isEmpty(); - } - - @Test - @WithMockUser - @DisplayName("Should update template with relations and return 200") - void putTemplate_updateRelations_200() throws Exception { - mockMvc.perform(MockMvcRequestBuilders.post(ENTITY_TEMPLATE_PATH) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(""" - { - "identifier": "template-rel-test", - "name": "Template Rel Test", - "description": "Initial template", - "properties_definitions": [ - { - "name": "property1", - "description": "description", - "required": true, - "type": "STRING", - "rules": {} - } - ], - "relations_definitions": [ - { - "name": "owns", - "target_template_identifier": "microservice", - "required": true, - "to_many": true - } - ] - } - """)) - .andExpect(status().isCreated()); - - String updateJson = """ - { - "name": "Template Rel Test", - "description": "Updated template with new relation", - "properties_definitions": [ - { - "name": "property1", - "description": "Updated description", - "type": "STRING", - "required": true, - "rules": {} - } - ], - "relations_definitions": [ - { - "name": "owns", - "target_template_identifier": "microservice", - "required": false, - "to_many": false - }, - { - "name": "belongsTo", - "target_template_identifier": "database-service", - "required": true, - "to_many": false - } - ] - } - """; - - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/template-rel-test") - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(updateJson)) - .andExpect(status().isOk()); - - Optional updatedTemplateOpt = entityTemplateRepository - .findByIdentifier("template-rel-test"); - assertThat(updatedTemplateOpt).isPresent(); - - EntityTemplate updatedTemplate = updatedTemplateOpt.get(); - - // Vérifier description mise à jour - assertThat(updatedTemplate.description()).isEqualTo("Updated template with new relation"); - - // Vérifier properties - assertThat(updatedTemplate.propertiesDefinitions()).hasSize(1); - assertThat(updatedTemplate.propertiesDefinitions().get(0).description()) - .isEqualTo("Updated description"); - - // Vérifier relations - assertThat(updatedTemplate.relationsDefinitions()).hasSize(2); - - Map relationsMap = updatedTemplate.relationsDefinitions() - .stream() - .collect(Collectors.toMap(RelationDefinition::name, r -> r)); - - assertThat(relationsMap.get("owns").targetTemplateIdentifier()).isEqualTo("microservice"); - assertThat(relationsMap.get("owns").required()).isFalse(); - assertThat(relationsMap.get("owns").toMany()).isFalse(); - - assertThat(relationsMap.get("belongsTo").targetTemplateIdentifier()).isEqualTo("database-service"); - assertThat(relationsMap.get("belongsTo").required()).isTrue(); - assertThat(relationsMap.get("belongsTo").toMany()).isFalse(); - } - - @Test - @WithMockUser() - @DisplayName("Should update template and return 201") - void putTemplate_200() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - "integration_test/json/entity-template/v1/putEntityTemplate_200.json"))) - .andExpect(status().isOk()); - - Optional entityTemplateUpdated = entityTemplateRepository.findByIdentifier("web-service"); - assertThat(entityTemplateUpdated).isPresent(); - assertThat(entityTemplateUpdated.get().propertiesDefinitions()).hasSize(2); - assertThat(entityTemplateUpdated.get().relationsDefinitions()).isEmpty(); - } - - /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint without properties. - /// This test verifies that: - /// - Templates can be updated without any properties - /// - The endpoint returns HTTP 200 OK status - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Should update template without properties and return 200") - void putTemplate_200_without_properties() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + "putEntityTemplate_200_without_properties.json"))) - .andExpect(status().isOk()); - } - - /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint with empty properties array. - /// This test verifies that: - /// - Templates can be updated with an empty properties array - /// - The endpoint returns HTTP 200 OK status - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Should update template with empty properties array and return 200") - void putTemplate_200_with_empty_properties() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + "putEntityTemplate_200_with_empty_properties.json"))) - .andExpect(status().isOk()); - } - - @Test - @WithMockUser - void putTemplate_404_withUnknownIdentifier() throws Exception { - String identifier = "unknown-identifier"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - "integration_test/json/entity-template/v1/putEntityTemplate_200.json"))) - .andExpect(status().isNotFound()) - .andExpect(content().string( - "{\"error\":\"NOT_FOUND\",\"error_description\":\"Template not found with identifier: unknown-identifier\"}")); - } - - @Test - @WithMockUser() - void putTemplate_400_propertyNameIsMissing() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - "integration_test/json/entity-template/v1/putEntityTemplate_400_propertyNameIsMissing.json"))) - .andExpect(status().isBadRequest()) - .andExpect(content().string( - "{\"error\":\"BAD_REQUEST\",\"error_description\":\"Property name is mandatory and cannot be blank\"}")); - } - - @Test - @WithMockUser() - void putTemplate_400_propertyNameIsBlank() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - "integration_test/json/entity-template/v1/putEntityTemplate_400_propertyNameIsBlank.json"))) - .andExpect(status().isBadRequest()) - .andExpect(content().string( - "{\"error\":\"BAD_REQUEST\",\"error_description\":\"Property name is mandatory and cannot be blank\"}")); - } - - @Test - @WithMockUser() - void putTemplate_400_propertyDescriptionIsBlank() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - "integration_test/json/entity-template/v1/putEntityTemplate_400_propertyDescriptionIsBlank.json"))) - .andExpect(status().isBadRequest()) - .andExpect(content().string( - "{\"error\":\"BAD_REQUEST\",\"error_description\":\"Property description is mandatory and cannot be blank\"}")); - } - - @Test - @WithMockUser() - void putTemplate_400_propertyDescriptionIsMissing() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - "integration_test/json/entity-template/v1/putEntityTemplate_400_propertyDescriptionIsMissing.json"))) - .andExpect(status().isBadRequest()) - .andExpect(content().string( - "{\"error\":\"BAD_REQUEST\",\"error_description\":\"Property description is mandatory and cannot be blank\"}")); - } - - @Test - @WithMockUser() - void putTemplate_400_propertyTypeIsMissing() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - "integration_test/json/entity-template/v1/putEntityTemplate_400_propertyTypeIsMissing.json"))) - .andExpect(status().isBadRequest()) - .andExpect(content().string( - "{\"error\":\"BAD_REQUEST\",\"error_description\":\"Property type is mandatory\"}")); - } - - @Test - @WithMockUser() - void putTemplate_409_whenIdentifierAlreadyExists() throws Exception { - String identifier = "web-service"; - Optional entityTemplateUpdated = entityTemplateRepository.findByIdentifier("microservice"); - assertThat(entityTemplateUpdated).isPresent(); - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - "integration_test/json/entity-template/v1/putEntityTemplate_409_withIdentifierAlreadyExists.json"))) - .andExpect(status().isConflict()) - .andExpect(content().string( - "{\"error\":\"CONFLICT\",\"error_description\":\"The entity template name Microservice already exists\"}")); - } - - /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint when the name field is - /// missing. - /// This test verifies that: - /// - Validation error message matches expected template name mandatory - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 400 when name is missing") - void putTemplate_400_name_missing() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent("integration_test/json/entity-template/v1/putEntityTemplate_400_name_missing.json"))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("BAD_REQUEST")) - .andExpect(jsonPath("$.error_description").value(ValidationMessages.TEMPLATE_NAME_MANDATORY)); - } - - /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint when name field is - /// blank. - /// This test verifies that: - /// - Validation error message contains expected template name mandatory message - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 400 when name is blank") - void putTemplate_400_name_blank() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent("integration_test/json/entity-template/v1/putEntityTemplate_400_name_blank.json"))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("BAD_REQUEST")) - .andExpect(jsonPath("$.error_description").value(containsString(ValidationMessages.TEMPLATE_NAME_MANDATORY))); - } - - /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint when name field - /// already exists. - /// This test verifies that: - /// - Validation error message contains expected template name already exists - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 409 when name already exists") - void putTemplate_409_name_already_exists() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent("integration_test/json/entity-template/v1/putEntityTemplate_409_name_already_exists.json"))) - .andExpect(status().isConflict()) - .andExpect(content().string("{\"error\":\"CONFLICT\",\"error_description\":\"The entity template name Microservice already exists\"}")); - } - - /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint when name field is - /// too long. - /// This test verifies that: - /// - Validation error message matches expected template name too long - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 400 when name is too long") - void putTemplate_400_name_too_long() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent("integration_test/json/entity-template/v1/putEntityTemplate_400_name_wrong_size.json"))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("BAD_REQUEST")) - .andExpect(jsonPath("$.error_description").value(ValidationMessages.TEMPLATE_NAME_MAX_SIZE)); - } - - /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint when name field does - /// not respect regex pattern. - /// This test verifies that: - /// - Validation error message matches expected template name pattern - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Returns 400 when name does not respect regex pattern") - void putTemplate_400_name_invalid_pattern() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent("integration_test/json/entity-template/v1/putEntityTemplate_400_name_invalid_pattern.json"))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("BAD_REQUEST")) - .andExpect(jsonPath("$.error_description").value(ValidationMessages.TEMPLATE_NAME_FORMAT)); - } - - /// Tests that the PUT /api/v1/entity-templates/{identifier} endpoint rejects - /// requests with an identifier field in the request body. - /// **This test verifies that:** - /// - The endpoint returns HTTP 400 Bad Request when identifier is in body - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Should reject PUT request with identifier in body and return 400") - void putTemplate_400_identifier_in_body() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + "putEntityTemplate_400_identifier_in_body.json"))) - .andExpect(status().isBadRequest()); - } - - /// Tests PUT endpoint when attempting to change property type on existing property. - /// Verifies that PropertyTypeChangeException is thrown and returns 400 Bad Request. - @Test - @WithMockUser() - @DisplayName("Should return 400 when changing existing property type") - void putTemplate_400_type_change() throws Exception { - String identifier = "web-service"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + "putEntityTemplate_200.json"))) - .andExpect(status().isOk()); - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + "putTemplate_400_type_change.json"))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("BAD_REQUEST")) - .andExpect(jsonPath("$.error_description").value("Cannot change type of property 'name' from STRING to NUMBER. Property types cannot be modified after creation. Please delete and recreate the property instead.")); - } - - /// Tests PUT endpoint when attempting to change targetTemplateIdentifier on an existing relation. - /// Verifies that RelationTargetTemplateChangeException is thrown and returns 400 Bad Request. - @Test - @WithMockUser() - @DisplayName("Should return 400 when changing existing relation targetTemplateIdentifier") - void putTemplate_400_target_template_identifier_change() throws Exception { - String identifier = "microservice"; - mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(getJsonTestFileContent( - PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + "putTemplate_400_target_template_identifier_change.json"))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("BAD_REQUEST")) - .andExpect(jsonPath("$.error_description").value( - containsString("Cannot change target template of relation 'dependencies' from 'service' to 'service-modified'"))); - } + @Test + @WithMockUser() + void putTemplate_400_propertyNameIsMissing() throws Exception { + String identifier = "web-service"; + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_400_propertyNameIsMissing.json"))) + .andExpect(status().isBadRequest()).andExpect(content().string( + "{\"error\":\"BAD_REQUEST\",\"error_description\":\"Property name is mandatory and cannot be blank\"}")); + } + + @Test + @WithMockUser() + void putTemplate_400_propertyNameIsBlank() throws Exception { + String identifier = "web-service"; + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_400_propertyNameIsBlank.json"))) + .andExpect(status().isBadRequest()).andExpect(content().string( + "{\"error\":\"BAD_REQUEST\",\"error_description\":\"Property name is mandatory and cannot be blank\"}")); + } + + @Test + @WithMockUser() + void putTemplate_400_propertyDescriptionIsBlank() throws Exception { + String identifier = "web-service"; + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_400_propertyDescriptionIsBlank.json"))) + .andExpect(status().isBadRequest()).andExpect(content().string( + "{\"error\":\"BAD_REQUEST\",\"error_description\":\"Property description is mandatory and cannot be blank\"}")); + } + + @Test + @WithMockUser() + void putTemplate_400_propertyDescriptionIsMissing() throws Exception { + String identifier = "web-service"; + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_400_propertyDescriptionIsMissing.json"))) + .andExpect(status().isBadRequest()).andExpect(content().string( + "{\"error\":\"BAD_REQUEST\",\"error_description\":\"Property description is mandatory and cannot be blank\"}")); + } + @Test + @WithMockUser() + void putTemplate_400_propertyTypeIsMissing() throws Exception { + String identifier = "web-service"; + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_400_propertyTypeIsMissing.json"))) + .andExpect(status().isBadRequest()).andExpect(content().string( + "{\"error\":\"BAD_REQUEST\",\"error_description\":\"Property type is mandatory\"}")); } - @Nested - @DisplayName("DELETE /api/v1/entity-templates/{id} - Delete Template") - @Order(4) - class DeleteTemplateTests { - - private static final String ENTITY_TEMPLATE_PATH = "/api/v1/entity-templates"; - - /// Tests the DELETE /api/v1/entity-templates/{id} endpoint for successful - /// template deletion. - /// This test verifies that: - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Should delete template and return 204") - void deleteTemplate_204() throws Exception { - // Use an existing template ID from test data - String templateId = "monitoring-service"; - - mockMvc.perform(MockMvcRequestBuilders.delete(ENTITY_TEMPLATE_PATH + "/" + templateId) - .accept(APPLICATION_JSON) - .with(csrf())) - .andExpect(status().isNoContent()); - - assertNotNull(templateId, "Test executed successfully"); - } - - /// Tests the DELETE /api/v1/entity-templates/{id} endpoint when template does - /// not exist. - /// This test verifies that: - /// @throws Exception if the MockMvc request fails - @Test - @WithMockUser() - @DisplayName("Should return 404 when template not found") - void deleteTemplate_404_not_found() throws Exception { - // Use a non-existent template ID - String nonExistentId = "non-existing-identifier"; - - mockMvc.perform(MockMvcRequestBuilders.delete(ENTITY_TEMPLATE_PATH + "/" + nonExistentId) - .accept(APPLICATION_JSON) - .with(csrf())) - .andExpect(status().isNotFound()) - .andExpect(content().contentType(APPLICATION_JSON)) - .andExpect(jsonPath("$.error").value("NOT_FOUND")) - .andExpect(jsonPath("$.error_description").exists()); - - assertNotNull(nonExistentId, "Test executed successfully"); - } - - /// Tests the DELETE /api/v1/entity-templates/{id} endpoint when authentication is missing. - /// This test verifies that: - /// @throws Exception if the MockMvc request fails - @Test - @DisplayName("Should return 401 when deleting without user token") - void deleteTemplate_401_without_user_token() throws Exception { - String templateId = "123e4567-e89b-12d3-a456-426614174001"; - mockMvc.perform(MockMvcRequestBuilders.delete(ENTITY_TEMPLATE_PATH + "/" + templateId) - .accept(APPLICATION_JSON) - .with(csrf())) - .andExpect(status().isUnauthorized()); - - } + @Test + @WithMockUser() + void putTemplate_409_whenIdentifierAlreadyExists() throws Exception { + String identifier = "web-service"; + Optional entityTemplateUpdated = entityTemplateRepository + .findByIdentifier("microservice"); + assertThat(entityTemplateUpdated).isPresent(); + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_409_withIdentifierAlreadyExists.json"))) + .andExpect(status().isConflict()).andExpect(content().string( + "{\"error\":\"CONFLICT\",\"error_description\":\"The entity template name Microservice already exists\"}")); + } + + /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint when the name + /// field is + /// missing. + /// This test verifies that: + /// - Validation error message matches expected template name mandatory + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 400 when name is missing") + void putTemplate_400_name_missing() throws Exception { + String identifier = "web-service"; + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_400_name_missing.json"))) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect( + jsonPath("$.error_description").value(ValidationMessages.TEMPLATE_NAME_MANDATORY)); + } + + /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint when name field + /// is + /// blank. + /// This test verifies that: + /// - Validation error message contains expected template name mandatory message + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 400 when name is blank") + void putTemplate_400_name_blank() throws Exception { + String identifier = "web-service"; + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_400_name_blank.json"))) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description") + .value(containsString(ValidationMessages.TEMPLATE_NAME_MANDATORY))); + } + + /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint when name field + /// already exists. + /// This test verifies that: + /// - Validation error message contains expected template name already exists + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 409 when name already exists") + void putTemplate_409_name_already_exists() throws Exception { + String identifier = "web-service"; + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_409_name_already_exists.json"))) + .andExpect(status().isConflict()).andExpect(content().string( + "{\"error\":\"CONFLICT\",\"error_description\":\"The entity template name Microservice already exists\"}")); + } + + /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint when name field + /// is + /// too long. + /// This test verifies that: + /// - Validation error message matches expected template name too long + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 400 when name is too long") + void putTemplate_400_name_too_long() throws Exception { + String identifier = "web-service"; + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_400_name_wrong_size.json"))) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect( + jsonPath("$.error_description").value(ValidationMessages.TEMPLATE_NAME_MAX_SIZE)); + } + + /// Tests the PUT /api/v1/entity-templates/{identifier} endpoint when name field + /// does + /// not respect regex pattern. + /// This test verifies that: + /// - Validation error message matches expected template name pattern + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Returns 400 when name does not respect regex pattern") + void putTemplate_400_name_invalid_pattern() throws Exception { + String identifier = "web-service"; + mockMvc.perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + "integration_test/json/entity-template/v1/putEntityTemplate_400_name_invalid_pattern.json"))) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect( + jsonPath("$.error_description").value(ValidationMessages.TEMPLATE_NAME_FORMAT)); + } + + /// Tests that the PUT /api/v1/entity-templates/{identifier} endpoint rejects + /// requests with an identifier field in the request body. + /// **This test verifies that:** + /// - The endpoint returns HTTP 400 Bad Request when identifier is in body + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Should reject PUT request with identifier in body and return 400") + void putTemplate_400_identifier_in_body() throws Exception { + String identifier = "web-service"; + mockMvc + .perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + + "putEntityTemplate_400_identifier_in_body.json"))) + .andExpect(status().isBadRequest()); + } + + /// Tests PUT endpoint when attempting to change property type on existing + /// property. + /// Verifies that PropertyTypeChangeException is thrown and returns 400 Bad + /// Request. + @Test + @WithMockUser() + @DisplayName("Should return 400 when changing existing property type") + void putTemplate_400_type_change() throws Exception { + String identifier = "web-service"; + mockMvc + .perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + "putEntityTemplate_200.json"))) + .andExpect(status().isOk()); + mockMvc + .perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + + "putTemplate_400_type_change.json"))) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value( + "Cannot change type of property 'name' from STRING to NUMBER. Property types cannot be modified after creation. Please delete and recreate the property instead.")); + } + + /// Tests PUT endpoint when attempting to change targetTemplateIdentifier on an + /// existing relation. + /// Verifies that RelationTargetTemplateChangeException is thrown and returns + /// 400 Bad Request. + @Test + @WithMockUser() + @DisplayName("Should return 400 when changing existing relation targetTemplateIdentifier") + void putTemplate_400_target_template_identifier_change() throws Exception { + String identifier = "microservice"; + mockMvc + .perform(MockMvcRequestBuilders.put(ENTITY_TEMPLATE_PATH + "/" + identifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(PostTemplateTests.ENTITY_TEMPLATE_JSON_TEST_PATH + + "putTemplate_400_target_template_identifier_change.json"))) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").value(containsString( + "Cannot change target template of relation 'dependencies' from 'service' to 'service-modified'"))); + } + + } + + @Nested + @DisplayName("DELETE /api/v1/entity-templates/{id} - Delete Template") + @Order(4) + class DeleteTemplateTests { + + private static final String ENTITY_TEMPLATE_PATH = "/api/v1/entity-templates"; + + /// Tests the DELETE /api/v1/entity-templates/{id} endpoint for successful + /// template deletion. + /// This test verifies that: + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Should delete template and return 204") + void deleteTemplate_204() throws Exception { + // Use an existing template ID from test data + String templateId = "monitoring-service"; + + mockMvc.perform(MockMvcRequestBuilders.delete(ENTITY_TEMPLATE_PATH + "/" + templateId) + .accept(APPLICATION_JSON).with(csrf())).andExpect(status().isNoContent()); + + assertNotNull(templateId, "Test executed successfully"); + } + + /// Tests the DELETE /api/v1/entity-templates/{id} endpoint when template does + /// not exist. + /// This test verifies that: + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Should return 404 when template not found") + void deleteTemplate_404_not_found() throws Exception { + // Use a non-existent template ID + String nonExistentId = "non-existing-identifier"; + + mockMvc + .perform(MockMvcRequestBuilders.delete(ENTITY_TEMPLATE_PATH + "/" + nonExistentId) + .accept(APPLICATION_JSON).with(csrf())) + .andExpect(status().isNotFound()).andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.error").value("NOT_FOUND")) + .andExpect(jsonPath("$.error_description").exists()); + + assertNotNull(nonExistentId, "Test executed successfully"); + } + + /// Tests the DELETE /api/v1/entity-templates/{id} endpoint when authentication + /// is missing. + /// This test verifies that: + /// @throws Exception if the MockMvc request fails + @Test + @DisplayName("Should return 401 when deleting without user token") + void deleteTemplate_401_without_user_token() throws Exception { + String templateId = "123e4567-e89b-12d3-a456-426614174001"; + mockMvc.perform(MockMvcRequestBuilders.delete(ENTITY_TEMPLATE_PATH + "/" + templateId) + .accept(APPLICATION_JSON).with(csrf())).andExpect(status().isUnauthorized()); + } + } } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java index d44ce9f8..8dff1a38 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandlerTest.java @@ -10,6 +10,9 @@ import java.util.Set; import java.util.stream.Stream; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -33,9 +36,6 @@ import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse; -import jakarta.validation.ConstraintViolation; -import jakarta.validation.ConstraintViolationException; - /// Comprehensive unit tests for [ApiExceptionHandler]. /// /// Tests all exception handler methods and utility functions to ensure proper @@ -43,461 +43,476 @@ @DisplayName("ApiExceptionHandler Tests") class ApiExceptionHandlerTest { - private ApiExceptionHandler exceptionHandler; + private ApiExceptionHandler exceptionHandler; + + @BeforeEach + void setUp() throws Exception { + // Use reflection to create instance since constructor is private + Constructor constructor = ApiExceptionHandler.class + .getDeclaredConstructor(); + constructor.setAccessible(true); + exceptionHandler = constructor.newInstance(); + } + + @Nested + @DisplayName("Domain Exception Handling") + class DomainExceptionTests { + + /// Tests the handling of [EntityTemplateNotFoundException] by the + /// [ApiExceptionHandler]. + /// + /// **This test verifies that:** + /// - EntityTemplateNotFoundException is properly caught and handled + /// - HTTP 404 Not Found status is returned + /// - Error response contains the correct error status and description + /// - Original exception message is preserved in the response + @Test + @DisplayName("Should handle EntityTemplateNotFoundException with 404 status") + void shouldHandleEntityTemplateNotFoundException() { + // Given + String errorMessage = "Template with ID 'test-id' not found"; + EntityTemplateNotFoundException exception = new EntityTemplateNotFoundException(errorMessage); + + // When + ResponseEntity response = exceptionHandler + .handleTemplateNotFoundException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.NOT_FOUND.name(), body.getError()); + assertEquals(errorMessage, body.getErrorDescription()); + } + + /// Tests the handling of [EntityTemplateAlreadyExistsException] by the + /// [ApiExceptionHandler]. + /// + /// **This test verifies that:** + /// - EntityTemplateAlreadyExistsException is properly caught and handled + /// - HTTP 409 Conflict status is returned + /// - Error response contains the correct error status and formatted description + /// - Exception message is properly formatted with validation constants + @Test + @DisplayName("Should handle EntityTemplateAlreadyExistsException with 409 status") + void shouldHandleEntityTemplateAlreadyExistsException() { + // Given + String identifier = "duplicate-id"; + EntityTemplateAlreadyExistsException exception = new EntityTemplateAlreadyExistsException( + identifier); + String expectedMessage = "An Entity Template already exists with the same identifier:duplicate-id"; + + // When + ResponseEntity response = exceptionHandler + .handleEntityTemplateAlreadyExistsException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.CONFLICT.name(), body.getError()); + assertEquals(expectedMessage, body.getErrorDescription()); + } - @BeforeEach - void setUp() throws Exception { - // Use reflection to create instance since constructor is private - Constructor constructor = ApiExceptionHandler.class.getDeclaredConstructor(); - constructor.setAccessible(true); - exceptionHandler = constructor.newInstance(); + /// Tests the handling of [EntityAlreadyExistsException] by the + /// [ApiExceptionHandler]. + /// + /// **This test verifies that:** + /// - EntityAlreadyExistsException is properly caught and handled + /// - HTTP 409 Conflict status is returned + /// - Error response contains the original domain exception message + @Test + @DisplayName("Should handle EntityAlreadyExistsException with 409 status") + void shouldHandleEntityAlreadyExistsException() { + // Given + EntityAlreadyExistsException exception = new EntityAlreadyExistsException("my-web-service", + "api-gateway"); + + // When + ResponseEntity response = exceptionHandler + .handleEntityAlreadyExistsException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.CONFLICT.name(), body.getError()); + assertEquals(exception.getMessage(), body.getErrorDescription()); } - @Nested - @DisplayName("Domain Exception Handling") - class DomainExceptionTests { - - /// Tests the handling of [EntityTemplateNotFoundException] by the - /// [ApiExceptionHandler]. - /// - /// **This test verifies that:** - /// - EntityTemplateNotFoundException is properly caught and handled - /// - HTTP 404 Not Found status is returned - /// - Error response contains the correct error status and description - /// - Original exception message is preserved in the response - @Test - @DisplayName("Should handle EntityTemplateNotFoundException with 404 status") - void shouldHandleEntityTemplateNotFoundException() { - // Given - String errorMessage = "Template with ID 'test-id' not found"; - EntityTemplateNotFoundException exception = new EntityTemplateNotFoundException(errorMessage); - - // When - ResponseEntity response = exceptionHandler.handleTemplateNotFoundException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.NOT_FOUND.name(), body.getError()); - assertEquals(errorMessage, body.getErrorDescription()); - } - - /// Tests the handling of [EntityTemplateAlreadyExistsException] by the - /// [ApiExceptionHandler]. - /// - /// **This test verifies that:** - /// - EntityTemplateAlreadyExistsException is properly caught and handled - /// - HTTP 409 Conflict status is returned - /// - Error response contains the correct error status and formatted description - /// - Exception message is properly formatted with validation constants - @Test - @DisplayName("Should handle EntityTemplateAlreadyExistsException with 409 status") - void shouldHandleEntityTemplateAlreadyExistsException() { - // Given - String identifier = "duplicate-id"; - EntityTemplateAlreadyExistsException exception = new EntityTemplateAlreadyExistsException(identifier); - String expectedMessage = "An Entity Template already exists with the same identifier:duplicate-id"; - - // When - ResponseEntity response = exceptionHandler - .handleEntityTemplateAlreadyExistsException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.CONFLICT.name(), body.getError()); - assertEquals(expectedMessage, body.getErrorDescription()); - } - - /// Tests the handling of [EntityAlreadyExistsException] by the - /// [ApiExceptionHandler]. - /// - /// **This test verifies that:** - /// - EntityAlreadyExistsException is properly caught and handled - /// - HTTP 409 Conflict status is returned - /// - Error response contains the original domain exception message - @Test - @DisplayName("Should handle EntityAlreadyExistsException with 409 status") - void shouldHandleEntityAlreadyExistsException() { - // Given - EntityAlreadyExistsException exception = new EntityAlreadyExistsException("my-web-service", "api-gateway"); - - // When - ResponseEntity response = exceptionHandler.handleEntityAlreadyExistsException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.CONFLICT.name(), body.getError()); - assertEquals(exception.getMessage(), body.getErrorDescription()); - } - - @Test - @DisplayName("Should handle EntityValidationException with 400 status") - void shouldHandleEntityValidationException() { - EntityValidationException exception = new EntityValidationException(java.util.List.of("Invalid property")); - - ResponseEntity response = exceptionHandler.handleEntityValidationException(exception); - - assertNotNull(response); - assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); - assertEquals(exception.getMessage(), body.getErrorDescription()); - } - - /// Tests the handling of [EntityTemplateNameAlreadyExistsException] by the - /// [ApiExceptionHandler]. - /// - /// **This test verifies that:** - /// - EntityTemplateNameAlreadyExistsException is properly caught and handled - /// - HTTP 409 Conflict status is returned - /// - Error response contains the correct error status and description - @Test - @DisplayName("Should handle EntityTemplateNameAlreadyExistsException with 409 status") - void shouldHandleEntityTemplateNameAlreadyExistsException() { - // Given - String name = "Duplicate Name"; - EntityTemplateNameAlreadyExistsException exception = new EntityTemplateNameAlreadyExistsException(name); - - // When - ResponseEntity response = exceptionHandler - .handleEntityTemplateNameAlreadyExistsException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.CONFLICT.name(), body.getError()); - assertEquals(exception.getMessage(), body.getErrorDescription()); - } - - /// Tests the handling of [EntityNotFoundException] by the - /// [ApiExceptionHandler]. - /// - /// **This test verifies that:** - /// - EntityNotFoundException is properly caught and handled - /// - HTTP 404 Not Found status is returned - /// - Error response contains the entity-specific context message - @Test - @DisplayName("Should handle EntityNotFoundException with 404 status") - void shouldHandleEntityNotFoundException() { - // Given - EntityNotFoundException exception = new EntityNotFoundException("web-service", "my-entity"); - - // When - ResponseEntity response = exceptionHandler.handleEntityNotFoundException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.NOT_FOUND.name(), body.getError()); - assertEquals(exception.getMessage(), body.getErrorDescription()); - } + @Test + @DisplayName("Should handle EntityValidationException with 400 status") + void shouldHandleEntityValidationException() { + EntityValidationException exception = new EntityValidationException( + java.util.List.of("Invalid property")); + + ResponseEntity response = exceptionHandler + .handleEntityValidationException(exception); + + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + assertEquals(exception.getMessage(), body.getErrorDescription()); } - @Nested - @DisplayName("Validation Exception Handling") - class ValidationExceptionTests { - - /// Tests the handling of [ConstraintViolationException] with a single - /// validation violation. - /// - /// **This test verifies that:** - /// - ConstraintViolationException is properly caught and handled - /// - HTTP 400 Bad Request status is returned - /// - Single violation message is correctly extracted and returned - /// - Error response format matches expected structure - @Test - @DisplayName("Should handle ConstraintViolationException with single violation") - void shouldHandleConstraintViolationExceptionSingleViolation() { - // Given - ConstraintViolation violation = createMockConstraintViolation("Field must not be null"); - Set> violations = Set.of(violation); - ConstraintViolationException exception = new ConstraintViolationException("Validation failed", violations); - - // When - ResponseEntity response = exceptionHandler.handleConstraintViolationException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); - assertEquals("Field must not be null", body.getErrorDescription()); - } - - /// Tests the handling of [ConstraintViolationException] with multiple - /// validation violations. - /// - /// **This test verifies that:** - /// - ConstraintViolationException with multiple violations is properly handled - /// - HTTP 400 Bad Request status is returned - /// - All violation messages are concatenated with comma separation - /// - Error response contains all validation error messages - @Test - @DisplayName("Should handle ConstraintViolationException with multiple violations") - void shouldHandleConstraintViolationExceptionMultipleViolations() { - // Given - ConstraintViolation violation1 = createMockConstraintViolation("Field1 must not be null"); - ConstraintViolation violation2 = createMockConstraintViolation("Field2 must not be blank"); - Set> violations = Set.of(violation1, violation2); - ConstraintViolationException exception = new ConstraintViolationException("Validation failed", violations); - - // When - ResponseEntity response = exceptionHandler.handleConstraintViolationException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); - - String errorDescription = body.getErrorDescription(); - assertTrue(errorDescription.contains("Field1 must not be null")); - assertTrue(errorDescription.contains("Field2 must not be blank")); - assertTrue(errorDescription.contains(", ")); - } - - /// Tests the handling of [MethodArgumentNotValidException] with field - /// validation errors. - /// - /// **This test verifies that:** - /// - MethodArgumentNotValidException is properly caught and handled - /// - HTTP 400 Bad Request status is returned - /// - Field error messages from binding result are extracted and concatenated - /// - All field validation errors are included in the response with comma - /// separation - /// - /// @throws Exception if reflection fails during test setup - @Test - @DisplayName("Should handle MethodArgumentNotValidException with field errors") - void shouldHandleMethodArgumentNotValidException() throws Exception { - // Given - Object target = new Object(); - BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(target, "testObject"); - bindingResult.addError(new FieldError("testObject", "field1", "Field1 is required")); - bindingResult.addError(new FieldError("testObject", "field2", "Field2 must be valid")); - - // Create a proper MethodParameter mock with required methods - MethodParameter methodParameter = mock(MethodParameter.class); - when(methodParameter.getExecutable()).thenReturn(this.getClass().getMethod("testMethod")); - - MethodArgumentNotValidException exception = new MethodArgumentNotValidException(methodParameter, - bindingResult); - - // When - ResponseEntity response = exceptionHandler.handleMethodArgumentNotValidException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); - String errorDescription = body.getErrorDescription(); - assertTrue(errorDescription.contains("Field1 is required")); - assertTrue(errorDescription.contains("Field2 must be valid")); - assertTrue(errorDescription.contains(", ")); - } - - // Helper method for mocking - public void testMethod() { - // Empty method for testing purposes - } - - @SuppressWarnings("unchecked") - private ConstraintViolation createMockConstraintViolation(String message) { - ConstraintViolation violation = mock(ConstraintViolation.class); - when(violation.getMessage()).thenReturn(message); - return violation; - } + /// Tests the handling of [EntityTemplateNameAlreadyExistsException] by the + /// [ApiExceptionHandler]. + /// + /// **This test verifies that:** + /// - EntityTemplateNameAlreadyExistsException is properly caught and handled + /// - HTTP 409 Conflict status is returned + /// - Error response contains the correct error status and description + @Test + @DisplayName("Should handle EntityTemplateNameAlreadyExistsException with 409 status") + void shouldHandleEntityTemplateNameAlreadyExistsException() { + // Given + String name = "Duplicate Name"; + EntityTemplateNameAlreadyExistsException exception = new EntityTemplateNameAlreadyExistsException( + name); + + // When + ResponseEntity response = exceptionHandler + .handleEntityTemplateNameAlreadyExistsException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.CONFLICT, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.CONFLICT.name(), body.getError()); + assertEquals(exception.getMessage(), body.getErrorDescription()); + } + + /// Tests the handling of [EntityNotFoundException] by the + /// [ApiExceptionHandler]. + /// + /// **This test verifies that:** + /// - EntityNotFoundException is properly caught and handled + /// - HTTP 404 Not Found status is returned + /// - Error response contains the entity-specific context message + @Test + @DisplayName("Should handle EntityNotFoundException with 404 status") + void shouldHandleEntityNotFoundException() { + // Given + EntityNotFoundException exception = new EntityNotFoundException("web-service", "my-entity"); + + // When + ResponseEntity response = exceptionHandler + .handleEntityNotFoundException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.NOT_FOUND.name(), body.getError()); + assertEquals(exception.getMessage(), body.getErrorDescription()); + } + } + + @Nested + @DisplayName("Validation Exception Handling") + class ValidationExceptionTests { + + /// Tests the handling of [ConstraintViolationException] with a single + /// validation violation. + /// + /// **This test verifies that:** + /// - ConstraintViolationException is properly caught and handled + /// - HTTP 400 Bad Request status is returned + /// - Single violation message is correctly extracted and returned + /// - Error response format matches expected structure + @Test + @DisplayName("Should handle ConstraintViolationException with single violation") + void shouldHandleConstraintViolationExceptionSingleViolation() { + // Given + ConstraintViolation violation = createMockConstraintViolation( + "Field must not be null"); + Set> violations = Set.of(violation); + ConstraintViolationException exception = new ConstraintViolationException("Validation failed", + violations); + + // When + ResponseEntity response = exceptionHandler + .handleConstraintViolationException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + assertEquals("Field must not be null", body.getErrorDescription()); } - @Nested - @DisplayName("HTTP Message Exception Handling") - class HttpMessageExceptionTests { - - /// Provides test data for [HttpMessageNotReadableException] scenarios. Each - /// argument contains: input message and expected error description. - static Stream httpMessageNotReadableExceptionTestData() { - return Stream.of( - Arguments.of( - "Required request body is missing: public ResponseEntity", - "Request body is required"), - Arguments.of( - "JSON parse error: Unexpected character", - "Invalid JSON format in request body"), - Arguments.of( - "Cannot deserialize value of type `PropertyType` from String \"INVALID_TYPE\": not one of the values accepted for Enum class", - "Invalid value 'INVALID_TYPE' for property 'type'"), - Arguments.of( - "Cannot deserialize value of type `PropertyFormat` from String \"INVALID_FORMAT\": not one of the values accepted for Enum class", - "Invalid value 'INVALID_FORMAT' for property 'format'"), - Arguments.of( - "Cannot deserialize value of type `UnknownEnum` from String \"VALUE\": not one of the values accepted for Enum class", - "Invalid enum value in request body"), - Arguments.of( - "Cannot deserialize value of type `com.example.SomeType`: some other error", - "Invalid type: expected SomeType"), - Arguments.of( - "Something completely unexpected happened", - "Invalid request body format"), - Arguments.of( - "Cannot deserialize value of type `PropertyType`: not one of the values accepted for Enum class", - "Invalid value for property 'type'")); - } - - /// Tests the handling of [HttpMessageNotReadableException] when exception - /// message is null. - /// - /// **This test verifies that:** - /// - HttpMessageNotReadableException with null message is properly handled - /// - HTTP 400 Bad Request status is returned - /// - Default error message is provided when original message is null - /// - Graceful handling of edge case scenarios - @Test - @DisplayName("Should handle HttpMessageNotReadableException with null message") - void shouldHandleHttpMessageNotReadableExceptionWithNullMessage() { - // Given - HttpMessageNotReadableException exception = mock(HttpMessageNotReadableException.class); - when(exception.getMessage()).thenReturn(null); - - // When - ResponseEntity response = exceptionHandler.handleHttpMessageNotReadableException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); - assertEquals("Invalid request body format", body.getErrorDescription()); - } - - /// Parameterized test for handling [HttpMessageNotReadableException] with - /// various error scenarios. - /// - /// **This test verifies that different types of HttpMessageNotReadableException - /// are properly parsed and converted to user-friendly error messages:** - /// - Missing request body errors → "Request body is required" - /// - JSON parse errors → "Invalid JSON format in request body" - /// - PropertyType enum deserialization errors → Specific property and value - /// information - /// - Unknown enum deserialization errors → Generic enum error message - /// - /// **Each test case validates that:** - /// - HTTP 400 Bad Request status is returned - /// - Original complex error message is parsed and simplified - /// - User-friendly error description is provided - /// - Error response structure is consistent - /// - /// @param originalMessage the original exception message to be - /// processed - /// @param expectedErrorDescription the expected user-friendly error description - @ParameterizedTest - @MethodSource("httpMessageNotReadableExceptionTestData") - @DisplayName("Should handle HttpMessageNotReadableException with various error types") - void shouldHandleHttpMessageNotReadableExceptionWithVariousErrorTypes(String originalMessage, - String expectedErrorDescription) { - // Given - HttpMessageNotReadableException exception = mock(HttpMessageNotReadableException.class); - when(exception.getMessage()).thenReturn(originalMessage); - - // When - ResponseEntity response = exceptionHandler.handleHttpMessageNotReadableException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); - assertEquals(expectedErrorDescription, body.getErrorDescription()); - } + /// Tests the handling of [ConstraintViolationException] with multiple + /// validation violations. + /// + /// **This test verifies that:** + /// - ConstraintViolationException with multiple violations is properly handled + /// - HTTP 400 Bad Request status is returned + /// - All violation messages are concatenated with comma separation + /// - Error response contains all validation error messages + @Test + @DisplayName("Should handle ConstraintViolationException with multiple violations") + void shouldHandleConstraintViolationExceptionMultipleViolations() { + // Given + ConstraintViolation violation1 = createMockConstraintViolation( + "Field1 must not be null"); + ConstraintViolation violation2 = createMockConstraintViolation( + "Field2 must not be blank"); + Set> violations = Set.of(violation1, violation2); + ConstraintViolationException exception = new ConstraintViolationException("Validation failed", + violations); + + // When + ResponseEntity response = exceptionHandler + .handleConstraintViolationException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + + String errorDescription = body.getErrorDescription(); + assertTrue(errorDescription.contains("Field1 must not be null")); + assertTrue(errorDescription.contains("Field2 must not be blank")); + assertTrue(errorDescription.contains(", ")); } - @Nested - @DisplayName("Generic Exception Handling") - class GenericExceptionTests { - - /// Tests the handling of generic Exception as a fallback mechanism. - /// - /// **This test verifies that:** - /// - Unexpected exceptions are caught by the generic handler - /// - HTTP 500 Internal Server Error status is returned - /// - Generic error message is provided to avoid exposing internal details - /// - Exception is properly logged for debugging purposes - @Test - @DisplayName("Should handle generic Exception with 500 status") - void shouldHandleGenericException() { - // Given - Exception exception = new RuntimeException("Unexpected error"); - - // When - ResponseEntity response = exceptionHandler.handleGenericException(exception); - - // Then - assertNotNull(response); - assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); - ErrorResponse body = response.getBody(); - assertNotNull(body); - assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.name(), body.getError()); - assertEquals("An unexpected error occurred. Please try again later.", body.getErrorDescription()); - } + /// Tests the handling of [MethodArgumentNotValidException] with field + /// validation errors. + /// + /// **This test verifies that:** + /// - MethodArgumentNotValidException is properly caught and handled + /// - HTTP 400 Bad Request status is returned + /// - Field error messages from binding result are extracted and concatenated + /// - All field validation errors are included in the response with comma + /// separation + /// + /// @throws Exception if reflection fails during test setup + @Test + @DisplayName("Should handle MethodArgumentNotValidException with field errors") + void shouldHandleMethodArgumentNotValidException() throws Exception { + // Given + Object target = new Object(); + BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(target, "testObject"); + bindingResult.addError(new FieldError("testObject", "field1", "Field1 is required")); + bindingResult.addError(new FieldError("testObject", "field2", "Field2 must be valid")); + + // Create a proper MethodParameter mock with required methods + MethodParameter methodParameter = mock(MethodParameter.class); + when(methodParameter.getExecutable()).thenReturn(this.getClass().getMethod("testMethod")); + + MethodArgumentNotValidException exception = new MethodArgumentNotValidException( + methodParameter, bindingResult); + + // When + ResponseEntity response = exceptionHandler + .handleMethodArgumentNotValidException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + String errorDescription = body.getErrorDescription(); + assertTrue(errorDescription.contains("Field1 is required")); + assertTrue(errorDescription.contains("Field2 must be valid")); + assertTrue(errorDescription.contains(", ")); + } + + // Helper method for mocking + public void testMethod() { + // Empty method for testing purposes + } + + @SuppressWarnings("unchecked") + private ConstraintViolation createMockConstraintViolation(String message) { + ConstraintViolation violation = mock(ConstraintViolation.class); + when(violation.getMessage()).thenReturn(message); + return violation; + } + } + + @Nested + @DisplayName("HTTP Message Exception Handling") + class HttpMessageExceptionTests { + + /// Provides test data for [HttpMessageNotReadableException] scenarios. Each + /// argument contains: input message and expected error description. + static Stream httpMessageNotReadableExceptionTestData() { + return Stream.of( + Arguments.of("Required request body is missing: public ResponseEntity", + "Request body is required"), + Arguments.of("JSON parse error: Unexpected character", + "Invalid JSON format in request body"), + Arguments.of( + "Cannot deserialize value of type `PropertyType` from String \"INVALID_TYPE\": not one of the values accepted for Enum class", + "Invalid value 'INVALID_TYPE' for property 'type'"), + Arguments.of( + "Cannot deserialize value of type `PropertyFormat` from String \"INVALID_FORMAT\": not one of the values accepted for Enum class", + "Invalid value 'INVALID_FORMAT' for property 'format'"), + Arguments.of( + "Cannot deserialize value of type `UnknownEnum` from String \"VALUE\": not one of the values accepted for Enum class", + "Invalid enum value in request body"), + Arguments.of("Cannot deserialize value of type `com.example.SomeType`: some other error", + "Invalid type: expected SomeType"), + Arguments.of("Something completely unexpected happened", "Invalid request body format"), + Arguments.of( + "Cannot deserialize value of type `PropertyType`: not one of the values accepted for Enum class", + "Invalid value for property 'type'")); + } + + /// Tests the handling of [HttpMessageNotReadableException] when exception + /// message is null. + /// + /// **This test verifies that:** + /// - HttpMessageNotReadableException with null message is properly handled + /// - HTTP 400 Bad Request status is returned + /// - Default error message is provided when original message is null + /// - Graceful handling of edge case scenarios + @Test + @DisplayName("Should handle HttpMessageNotReadableException with null message") + void shouldHandleHttpMessageNotReadableExceptionWithNullMessage() { + // Given + HttpMessageNotReadableException exception = mock(HttpMessageNotReadableException.class); + when(exception.getMessage()).thenReturn(null); + + // When + ResponseEntity response = exceptionHandler + .handleHttpMessageNotReadableException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + assertEquals("Invalid request body format", body.getErrorDescription()); + } + + /// Parameterized test for handling [HttpMessageNotReadableException] with + /// various error scenarios. + /// + /// **This test verifies that different types of HttpMessageNotReadableException + /// are properly parsed and converted to user-friendly error messages:** + /// - Missing request body errors → "Request body is required" + /// - JSON parse errors → "Invalid JSON format in request body" + /// - PropertyType enum deserialization errors → Specific property and value + /// information + /// - Unknown enum deserialization errors → Generic enum error message + /// + /// **Each test case validates that:** + /// - HTTP 400 Bad Request status is returned + /// - Original complex error message is parsed and simplified + /// - User-friendly error description is provided + /// - Error response structure is consistent + /// + /// @param originalMessage the original exception message to be + /// processed + /// @param expectedErrorDescription the expected user-friendly error description + @ParameterizedTest + @MethodSource("httpMessageNotReadableExceptionTestData") + @DisplayName("Should handle HttpMessageNotReadableException with various error types") + void shouldHandleHttpMessageNotReadableExceptionWithVariousErrorTypes(String originalMessage, + String expectedErrorDescription) { + // Given + HttpMessageNotReadableException exception = mock(HttpMessageNotReadableException.class); + when(exception.getMessage()).thenReturn(originalMessage); + + // When + ResponseEntity response = exceptionHandler + .handleHttpMessageNotReadableException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.BAD_REQUEST.name(), body.getError()); + assertEquals(expectedErrorDescription, body.getErrorDescription()); + } + } + + @Nested + @DisplayName("Generic Exception Handling") + class GenericExceptionTests { + + /// Tests the handling of generic Exception as a fallback mechanism. + /// + /// **This test verifies that:** + /// - Unexpected exceptions are caught by the generic handler + /// - HTTP 500 Internal Server Error status is returned + /// - Generic error message is provided to avoid exposing internal details + /// - Exception is properly logged for debugging purposes + @Test + @DisplayName("Should handle generic Exception with 500 status") + void shouldHandleGenericException() { + // Given + Exception exception = new RuntimeException("Unexpected error"); + + // When + ResponseEntity response = exceptionHandler.handleGenericException(exception); + + // Then + assertNotNull(response); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + ErrorResponse body = response.getBody(); + assertNotNull(body); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.name(), body.getError()); + assertEquals("An unexpected error occurred. Please try again later.", + body.getErrorDescription()); + } + } + + @Nested + @DisplayName("ErrorResponse Class Tests") + class ErrorResponseTests { + + /// Tests the creation of [ErrorResponse] using the all-arguments constructor. + /// + /// **This test verifies that:** + /// - ErrorResponse can be instantiated with HttpStatus and description + /// - All fields are properly initialized with provided values + /// - Getter methods return the expected values + /// - Object is successfully created and accessible + @Test + @DisplayName("Should create ErrorResponse with all args constructor") + void shouldCreateErrorResponseWithAllArgsConstructor() { + // Given + HttpStatus status = HttpStatus.BAD_REQUEST; + String description = "Test error message"; + + // When + ErrorResponse errorResponse = new ErrorResponse(status.name(), description); + + // Then + assertNotNull(errorResponse); + assertEquals(status.name(), errorResponse.getError()); + assertEquals(description, errorResponse.getErrorDescription()); } - @Nested - @DisplayName("ErrorResponse Class Tests") - class ErrorResponseTests { - - /// Tests the creation of [ErrorResponse] using the all-arguments constructor. - /// - /// **This test verifies that:** - /// - ErrorResponse can be instantiated with HttpStatus and description - /// - All fields are properly initialized with provided values - /// - Getter methods return the expected values - /// - Object is successfully created and accessible - @Test - @DisplayName("Should create ErrorResponse with all args constructor") - void shouldCreateErrorResponseWithAllArgsConstructor() { - // Given - HttpStatus status = HttpStatus.BAD_REQUEST; - String description = "Test error message"; - - // When - ErrorResponse errorResponse = new ErrorResponse(status.name(), description); - - // Then - assertNotNull(errorResponse); - assertEquals(status.name(), errorResponse.getError()); - assertEquals(description, errorResponse.getErrorDescription()); - } - - /// Tests the creation of [ErrorResponse] using the no-arguments constructor. - /// - /// **This test verifies that:** - /// - ErrorResponse can be instantiated without parameters - /// - Object is successfully created with default/null field values - /// - Constructor works with `@NoArgsConstructor(force = true)` annotation - /// - Provides flexibility for frameworks requiring default constructors - @Test - @DisplayName("Should create ErrorResponse with no args constructor") - void shouldCreateErrorResponseWithNoArgsConstructor() { - ErrorResponse errorResponse = new ErrorResponse(); - assertNotNull(errorResponse); - } + /// Tests the creation of [ErrorResponse] using the no-arguments constructor. + /// + /// **This test verifies that:** + /// - ErrorResponse can be instantiated without parameters + /// - Object is successfully created with default/null field values + /// - Constructor works with `@NoArgsConstructor(force = true)` annotation + /// - Provides flexibility for frameworks requiring default constructors + @Test + @DisplayName("Should create ErrorResponse with no args constructor") + void shouldCreateErrorResponseWithNoArgsConstructor() { + ErrorResponse errorResponse = new ErrorResponse(); + assertNotNull(errorResponse); } + } } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapperTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapperTest.java index d5fb8003..af67c176 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapperTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoInMapperTest.java @@ -17,82 +17,70 @@ @DisplayName("EntityDtoInMapper Tests") class EntityDtoInMapperTest { - private EntityDtoInMapper mapper; - - @BeforeEach - void setUp() { - mapper = new EntityDtoInMapper(); - } - - @Test - @DisplayName("Should map create DTO to Entity with populated properties and relations") - void shouldMapCreateDtoToEntity() { - // Given - var properties = new LinkedHashMap(); - properties.put("environment", "prod"); - properties.put("port", "8080"); - - var relation = EntityDtoInCommonFields.RelationDtoIn.builder() - .name("depends-on") - .targetEntityIdentifiers(List.of("gateway", "database")) - .build(); - - var commonFields = EntityDtoInCommonFields.builder() - .name("payment-service") - .properties(properties) - .relations(List.of(relation)) - .build(); - - var createDto = EntityCreateDtoIn.builder() - .identifier("payment-service-1") - .entityDtoInCommonFields(commonFields) - .build(); - - // When - Entity result = mapper.fromPostEntityDtoInToEntity(createDto, "service-template"); - - // Then - assertThat(result.id()).isNull(); - assertThat(result.templateIdentifier()).isEqualTo("service-template"); - assertThat(result.name()).isEqualTo("payment-service"); - assertThat(result.identifier()).isEqualTo("payment-service-1"); - - assertThat(result.properties()) - .hasSize(2) - .extracting(property -> property.name() + "=" + property.value()) - .containsExactly("environment=prod", "port=8080"); - - assertThat(result.relations()).hasSize(1); - var mappedRelation = result.relations().getFirst(); - assertThat(mappedRelation.id()).isNull(); - assertThat(mappedRelation.name()).isEqualTo("depends-on"); - assertThat(mappedRelation.targetTemplateIdentifier()).isNull(); - assertThat(mappedRelation.targetEntityIdentifiers()).containsExactly("gateway", "database"); - } - - @Test - @DisplayName("Should map update DTO using path identifier and handle null collections") - void shouldMapUpdateDtoToEntityWithNullCollections() { - // Given - var commonFields = EntityDtoInCommonFields.builder() - .name("catalog-service") - .properties(null) - .relations(null) - .build(); - - var updateDto = EntityUpdateDtoIn.builder() - .entityDtoInCommonFields(commonFields) - .build(); - - // When - Entity result = mapper.fromPutEntityDtoInToEntity(updateDto, "service-template", "catalog-service-42"); - - // Then - assertThat(result.id()).isNull(); - assertThat(result.templateIdentifier()).isEqualTo("service-template"); - assertThat(result.name()).isEqualTo("catalog-service"); - assertThat(result.identifier()).isEqualTo("catalog-service-42"); - assertThat(result.properties()).isEmpty(); - assertThat(result.relations()).isEmpty(); - } + private EntityDtoInMapper mapper; + + @BeforeEach + void setUp() { + mapper = new EntityDtoInMapper(); + } + + @Test + @DisplayName("Should map create DTO to Entity with populated properties and relations") + void shouldMapCreateDtoToEntity() { + // Given + var properties = new LinkedHashMap(); + properties.put("environment", "prod"); + properties.put("port", "8080"); + + var relation = EntityDtoInCommonFields.RelationDtoIn.builder().name("depends-on") + .targetEntityIdentifiers(List.of("gateway", "database")).build(); + + var commonFields = EntityDtoInCommonFields.builder().name("payment-service") + .properties(properties).relations(List.of(relation)).build(); + + var createDto = EntityCreateDtoIn.builder().identifier("payment-service-1") + .entityDtoInCommonFields(commonFields).build(); + + // When + Entity result = mapper.fromPostEntityDtoInToEntity(createDto, "service-template"); + + // Then + assertThat(result.id()).isNull(); + assertThat(result.templateIdentifier()).isEqualTo("service-template"); + assertThat(result.name()).isEqualTo("payment-service"); + assertThat(result.identifier()).isEqualTo("payment-service-1"); + + assertThat(result.properties()).hasSize(2) + .extracting(property -> property.name() + "=" + property.value()) + .containsExactly("environment=prod", "port=8080"); + + assertThat(result.relations()).hasSize(1); + var mappedRelation = result.relations().getFirst(); + assertThat(mappedRelation.id()).isNull(); + assertThat(mappedRelation.name()).isEqualTo("depends-on"); + assertThat(mappedRelation.targetTemplateIdentifier()).isNull(); + assertThat(mappedRelation.targetEntityIdentifiers()).containsExactly("gateway", "database"); + } + + @Test + @DisplayName("Should map update DTO using path identifier and handle null collections") + void shouldMapUpdateDtoToEntityWithNullCollections() { + // Given + var commonFields = EntityDtoInCommonFields.builder().name("catalog-service").properties(null) + .relations(null).build(); + + var updateDto = EntityUpdateDtoIn.builder().entityDtoInCommonFields(commonFields).build(); + + // When + Entity result = mapper.fromPutEntityDtoInToEntity(updateDto, "service-template", + "catalog-service-42"); + + // Then + assertThat(result.id()).isNull(); + assertThat(result.templateIdentifier()).isEqualTo("service-template"); + assertThat(result.name()).isEqualTo("catalog-service"); + assertThat(result.identifier()).isEqualTo("catalog-service-42"); + assertThat(result.properties()).isEmpty(); + assertThat(result.relations()).isEmpty(); + } } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySpecificationTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySpecificationTest.java index a72505dd..68a2fa9c 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/EntitySpecificationTest.java @@ -19,56 +19,51 @@ @SuppressWarnings("java:S2187") class EntitySpecificationTest { - @Nested - @DisplayName("escapeLikeWildcards") - class EscapeLikeWildcardsTests { + @Nested + @DisplayName("escapeLikeWildcards") + class EscapeLikeWildcardsTests { - @Test - @DisplayName("escapes percent sign") - void escapes_percent() { - assertThat(EntitySpecification.escapeLikeWildcards("100%")) - .isEqualTo("100\\%"); - } + @Test + @DisplayName("escapes percent sign") + void escapes_percent() { + assertThat(EntitySpecification.escapeLikeWildcards("100%")).isEqualTo("100\\%"); + } - @Test - @DisplayName("escapes underscore") - void escapes_underscore() { - assertThat(EntitySpecification.escapeLikeWildcards("my_value")) - .isEqualTo("my\\_value"); - } + @Test + @DisplayName("escapes underscore") + void escapes_underscore() { + assertThat(EntitySpecification.escapeLikeWildcards("my_value")).isEqualTo("my\\_value"); + } - @Test - @DisplayName("escapes backslash before other wildcards") - void escapes_backslash() { - assertThat(EntitySpecification.escapeLikeWildcards("path\\to%file")) - .isEqualTo("path\\\\to\\%file"); - } + @Test + @DisplayName("escapes backslash before other wildcards") + void escapes_backslash() { + assertThat(EntitySpecification.escapeLikeWildcards("path\\to%file")) + .isEqualTo("path\\\\to\\%file"); + } - @Test - @DisplayName("escapes multiple wildcards") - void escapes_multipleWildcards() { - assertThat(EntitySpecification.escapeLikeWildcards("100%_success")) - .isEqualTo("100\\%\\_success"); - } + @Test + @DisplayName("escapes multiple wildcards") + void escapes_multipleWildcards() { + assertThat(EntitySpecification.escapeLikeWildcards("100%_success")) + .isEqualTo("100\\%\\_success"); + } - @Test - @DisplayName("returns plain string unchanged") - void leaves_plainString_unchanged() { - assertThat(EntitySpecification.escapeLikeWildcards("hello")) - .isEqualTo("hello"); - } + @Test + @DisplayName("returns plain string unchanged") + void leaves_plainString_unchanged() { + assertThat(EntitySpecification.escapeLikeWildcards("hello")).isEqualTo("hello"); + } - @ParameterizedTest(name = "escapes ''{0}'' correctly") - @ValueSource(strings = {"%", "_", "%%", "__", "%_", "_%"}) - @DisplayName("escapes various wildcard combinations") - void escapes_wildcardCombinations(String input) { - String escaped = EntitySpecification.escapeLikeWildcards(input); - // Strip all valid escape sequences, then verify no bare wildcards remain - String stripped = escaped.replace("\\%", "").replace("\\_", "").replace("\\\\", ""); - assertThat(stripped) - .doesNotContain("%") - .doesNotContain("_"); - assertThat(escaped).contains("\\"); - } + @ParameterizedTest(name = "escapes ''{0}'' correctly") + @ValueSource(strings = {"%", "_", "%%", "__", "%_", "_%"}) + @DisplayName("escapes various wildcard combinations") + void escapes_wildcardCombinations(String input) { + String escaped = EntitySpecification.escapeLikeWildcards(input); + // Strip all valid escape sequences, then verify no bare wildcards remain + String stripped = escaped.replace("\\%", "").replace("\\_", "").replace("\\\\", ""); + assertThat(stripped).doesNotContain("%").doesNotContain("_"); + assertThat(escaped).contains("\\"); } + } } diff --git a/src/test/resources/db/test/R__1_Insert_test_data.sql b/src/test/resources/db/test/R__1_Insert_test_data.sql index 32255896..1d954be4 100644 --- a/src/test/resources/db/test/R__1_Insert_test_data.sql +++ b/src/test/resources/db/test/R__1_Insert_test_data.sql @@ -286,102 +286,3 @@ INSERT INTO entity_template_relations_definitions (entity_template_id, relations ('550e8400-e29b-41d4-a716-446655440079', '550e8400-e29b-41d4-a716-446655440057'), -- networks ('550e8400-e29b-41d4-a716-446655440079', '550e8400-e29b-41d4-a716-446655440064'); -- external_apis --- ----------------------------------------------------------------------- --- Sample entity instances --- ----------------------------------------------------------------------- - -INSERT INTO entity (id, identifier, name, template_identifier) -VALUES - ('550e8400-e29b-41d4-a716-446655440100', 'web-api-1', 'Web API 1', 'web-service'), - ('550e8400-e29b-41d4-a716-446655440101', 'web-api-2', 'Web API 2', 'web-service'), - ('550e8400-e29b-41d4-a716-446655440102', 'microservice-1', 'Microservice 1', 'microservice'), - ('550e8400-e29b-41d4-a716-446655440103', 'batch-job-1', 'Batch Job 1', 'batch-job'), - ('550e8400-e29b-41d4-a716-446655440104', 'frontend-app-1', 'Frontend App 1', 'frontend-app'), - ('550e8400-e29b-41d4-a716-446655440105', 'worker-service-1', 'Worker Service 1', 'worker-service'), - ('550e8400-e29b-41d4-a716-446655440106', 'api-gateway-1', 'API Gateway 1', 'api-gateway'), - ('550e8400-e29b-41d4-a716-446655440107', 'database-service-1', 'Database Service 1', 'database-service'), - ('550e8400-e29b-41d4-a716-446655440108', 'cache-service-1', 'Cache Service 1', 'cache-service'), - ('550e8400-e29b-41d4-a716-446655440109', 'monitoring-service-1', 'Monitoring Service 1', 'monitoring-service'), - ('550e8400-e29b-41d4-a716-446655440110', 'monitoring-service-2', 'Monitoring Service 2', 'monitoring-service'), - ('550e8400-e29b-41d4-a716-446655440111', 'monitoring-service-3', 'Monitoring Service 3', 'monitoring-service'), - ('550e8400-e29b-41d4-a716-446655440112', 'monitoring-service-4', 'Monitoring Service 4', 'monitoring-service'), - ('550e8400-e29b-41d4-a716-446655440113', 'monitoring-service-5', 'Monitoring Service 5', 'monitoring-service'), - ('550e8400-e29b-41d4-a716-446655440114', 'monitoring-service-6', 'Monitoring Service 6', 'monitoring-service'); - --- ----------------------------------------------------------------------- --- Graph test data: 3-level chain of entities connected via two relation --- types ("uses" and "monitors") for integration testing of the graph API. --- --- Graph topology (depth-3 chain): --- graph-svc-a --[uses]--> graph-svc-b --[uses]--> graph-svc-c --- graph-svc-a --[monitors]--> graph-svc-b --- --- This setup allows us to verify: --- 1. Graph traversal works at all depths (not just root level) --- 2. Relation name filtering excludes the correct edges/nodes at every depth --- 3. "uses" filter returns: a → b → c (2 edges, 3 nodes) --- 4. "monitors" filter returns: a → b (1 edge, 2 nodes; c not reachable) --- ----------------------------------------------------------------------- - -INSERT INTO entity (id, identifier, name, template_identifier) -VALUES - ('aa000001-0000-0000-0000-000000000001', 'graph-svc-a', 'Graph Service A', 'web-service'), - ('aa000001-0000-0000-0000-000000000002', 'graph-svc-b', 'Graph Service B', 'web-service'), - ('aa000001-0000-0000-0000-000000000003', 'graph-svc-c', 'Graph Service C', 'web-service'); - --- Relations owned by graph-svc-a: "uses" → b, "monitors" → b -INSERT INTO relation (id, name, target_template_identifier) -VALUES - ('bb000001-0000-0000-0000-000000000001', 'uses', 'web-service'), - ('bb000001-0000-0000-0000-000000000002', 'monitors', 'web-service'); - --- Relation owned by graph-svc-b: "uses" → c -INSERT INTO relation (id, name, target_template_identifier) -VALUES - ('bb000002-0000-0000-0000-000000000001', 'uses', 'web-service'); - --- Target entity identifiers for each relation -INSERT INTO relation_target_entities (relation_id, target_entity_identifier) -VALUES - ('bb000001-0000-0000-0000-000000000001', 'graph-svc-b'), -- a -[uses]-> b - ('bb000001-0000-0000-0000-000000000002', 'graph-svc-b'), -- a -[monitors]-> b - ('bb000002-0000-0000-0000-000000000001', 'graph-svc-c'); -- b -[uses]-> c - --- Link relations to their owner entities -INSERT INTO entity_relations (entity_id, relation_id) -VALUES - ('aa000001-0000-0000-0000-000000000001', 'bb000001-0000-0000-0000-000000000001'), -- a owns "uses" relation - ('aa000001-0000-0000-0000-000000000001', 'bb000001-0000-0000-0000-000000000002'), -- a owns "monitors" relation - ('aa000001-0000-0000-0000-000000000002', 'bb000002-0000-0000-0000-000000000001'); -- b owns "uses" relation - --- ----------------------------------------------------------------------- --- Property data for graph test entities (used by the property-filter tests). --- --- Each graph entity gets two properties: "tier" and "version". --- This lets us verify: --- 1. No filter → both properties appear in node data --- 2. Filter "tier" → only tier present, version absent --- 3. Filter "tier"+"version" → both present --- 4. Filter "non-existent" → data field omitted entirely (NON_EMPTY) --- ----------------------------------------------------------------------- - -INSERT INTO property (id, name, value) -VALUES - -- graph-svc-a - ('cc000001-0000-0000-0000-000000000001', 'tier', 'gold'), - ('cc000001-0000-0000-0000-000000000002', 'version', '1.0.0'), - -- graph-svc-b - ('cc000001-0000-0000-0000-000000000003', 'tier', 'silver'), - ('cc000001-0000-0000-0000-000000000004', 'version', '2.0.0'), - -- graph-svc-c - ('cc000001-0000-0000-0000-000000000005', 'tier', 'bronze'), - ('cc000001-0000-0000-0000-000000000006', 'version', '3.0.0'); - -INSERT INTO entity_properties (entity_id, property_id) -VALUES - ('aa000001-0000-0000-0000-000000000001', 'cc000001-0000-0000-0000-000000000001'), -- a.tier - ('aa000001-0000-0000-0000-000000000001', 'cc000001-0000-0000-0000-000000000002'), -- a.version - ('aa000001-0000-0000-0000-000000000002', 'cc000001-0000-0000-0000-000000000003'), -- b.tier - ('aa000001-0000-0000-0000-000000000002', 'cc000001-0000-0000-0000-000000000004'), -- b.version - ('aa000001-0000-0000-0000-000000000003', 'cc000001-0000-0000-0000-000000000005'), -- c.tier - ('aa000001-0000-0000-0000-000000000003', 'cc000001-0000-0000-0000-000000000006'); -- c.version \ No newline at end of file diff --git a/src/test/resources/db/test/R__2_Insert_entities_test_data.sql b/src/test/resources/db/test/R__2_Insert_entities_test_data.sql index 4147732e..8b334834 100644 --- a/src/test/resources/db/test/R__2_Insert_entities_test_data.sql +++ b/src/test/resources/db/test/R__2_Insert_entities_test_data.sql @@ -1,5 +1,8 @@ --- Insert sample entities into idp_core.entity -INSERT INTO idp_core.entity (id, identifier, name, template_identifier) +-- ----------------------------------------------------------------------- +-- Sample entity instances +-- ----------------------------------------------------------------------- + +INSERT INTO entity (id, identifier, name, template_identifier) VALUES ('550e8400-e29b-41d4-a716-446655440100', 'web-api-1', 'Web API 1', 'web-service'), ('550e8400-e29b-41d4-a716-446655440101', 'web-api-2', 'Web API 2', 'web-service'), @@ -17,59 +20,146 @@ VALUES ('550e8400-e29b-41d4-a716-446655440113', 'monitoring-service-5', 'Monitoring Service 5', 'monitoring-service'), ('550e8400-e29b-41d4-a716-446655440114', 'monitoring-service-6', 'Monitoring Service 6', 'monitoring-service'); --- Properties for web-api-1 (language=JAVA, environment=PROD) -INSERT INTO idp_core.property (id, name, value) + +-- Add to end of R__1_Insert_test_data.sql + +-- ----------------------------------------------------------------------- +-- Properties for query filter tests (web-api-1 and web-api-2) +-- ----------------------------------------------------------------------- + +-- Properties for web-api-1 (programmingLanguage=JAVA, environment=PROD, port=8080) +INSERT INTO property (id, name, value) VALUES ('aa000000-0000-0000-0000-000000000001', 'programmingLanguage', 'JAVA'), ('aa000000-0000-0000-0000-000000000002', 'environment', 'PROD'), ('aa000000-0000-0000-0000-000000000005', 'port', '8080'); -INSERT INTO idp_core.entity_properties (entity_id, property_id) + +INSERT INTO entity_properties (entity_id, property_id) VALUES ('550e8400-e29b-41d4-a716-446655440100', 'aa000000-0000-0000-0000-000000000001'), ('550e8400-e29b-41d4-a716-446655440100', 'aa000000-0000-0000-0000-000000000002'), ('550e8400-e29b-41d4-a716-446655440100', 'aa000000-0000-0000-0000-000000000005'); --- Properties for web-api-2 (language=PYTHON, environment=DEV) -INSERT INTO idp_core.property (id, name, value) +-- Properties for web-api-2 (programmingLanguage=PYTHON, environment=DEV, port=9090) +INSERT INTO property (id, name, value) VALUES ('aa000000-0000-0000-0000-000000000003', 'programmingLanguage', 'PYTHON'), ('aa000000-0000-0000-0000-000000000004', 'environment', 'DEV'), ('aa000000-0000-0000-0000-000000000006', 'port', '9090'); -INSERT INTO idp_core.entity_properties (entity_id, property_id) + +INSERT INTO entity_properties (entity_id, property_id) VALUES ('550e8400-e29b-41d4-a716-446655440101', 'aa000000-0000-0000-0000-000000000003'), ('550e8400-e29b-41d4-a716-446655440101', 'aa000000-0000-0000-0000-000000000004'), ('550e8400-e29b-41d4-a716-446655440101', 'aa000000-0000-0000-0000-000000000006'); --- Relations for web-api-1 (database -> database-service, targetTemplateIdentifier = database-service) -INSERT INTO idp_core.relation (id, name, target_template_identifier) -VALUES - ('bb000000-0000-0000-0000-000000000001', 'database', 'database-service'); -INSERT INTO idp_core.relation_target_entities (relation_id, target_entity_identifier) -VALUES - ('bb000000-0000-0000-0000-000000000001', 'database-service-1'); -INSERT INTO idp_core.entity_relations (entity_id, relation_id) +-- ----------------------------------------------------------------------- +-- Relations for query filter tests (web-api-1 and web-api-2) +-- ----------------------------------------------------------------------- + +-- database relation for web-api-1 → database-service-1 +INSERT INTO relation (id, name, target_template_identifier) +VALUES ('bb000000-0000-0000-0000-000000000001', 'database', 'database-service'); + +INSERT INTO relation_target_entities (relation_id, target_entity_identifier) +VALUES ('bb000000-0000-0000-0000-000000000001', 'database-service-1'); + +INSERT INTO entity_relations (entity_id, relation_id) +VALUES ('550e8400-e29b-41d4-a716-446655440100', 'bb000000-0000-0000-0000-000000000001'); + +-- database relation for web-api-2 → cache-service-1 +INSERT INTO relation (id, name, target_template_identifier) +VALUES ('bb000000-0000-0000-0000-000000000002', 'database', 'cache-service'); + +INSERT INTO relation_target_entities (relation_id, target_entity_identifier) +VALUES ('bb000000-0000-0000-0000-000000000002', 'cache-service-1'); + +INSERT INTO entity_relations (entity_id, relation_id) +VALUES ('550e8400-e29b-41d4-a716-446655440101', 'bb000000-0000-0000-0000-000000000002'); + +-- api-link relation for web-api-1 → microservice-1 +INSERT INTO relation (id, name, target_template_identifier) +VALUES ('bb000000-0000-0000-0000-000000000003', 'api-link', 'microservice'); + +INSERT INTO relation_target_entities (relation_id, target_entity_identifier) +VALUES ('bb000000-0000-0000-0000-000000000003', 'microservice-1'); + +INSERT INTO entity_relations (entity_id, relation_id) +VALUES ('550e8400-e29b-41d4-a716-446655440100', 'bb000000-0000-0000-0000-000000000003'); +-- ----------------------------------------------------------------------- +-- Graph test data: 3-level chain of entities connected via two relation +-- types ("uses" and "monitors") for integration testing of the graph API. +-- +-- Graph topology (depth-3 chain): +-- graph-svc-a --[uses]--> graph-svc-b --[uses]--> graph-svc-c +-- graph-svc-a --[monitors]--> graph-svc-b +-- +-- This setup allows us to verify: +-- 1. Graph traversal works at all depths (not just root level) +-- 2. Relation name filtering excludes the correct edges/nodes at every depth +-- 3. "uses" filter returns: a → b → c (2 edges, 3 nodes) +-- 4. "monitors" filter returns: a → b (1 edge, 2 nodes; c not reachable) +-- ----------------------------------------------------------------------- + +INSERT INTO entity (id, identifier, name, template_identifier) VALUES - ('550e8400-e29b-41d4-a716-446655440100', 'bb000000-0000-0000-0000-000000000001'); + ('aa000001-0000-0000-0000-000000000001', 'graph-svc-a', 'Graph Service A', 'web-service'), + ('aa000001-0000-0000-0000-000000000002', 'graph-svc-b', 'Graph Service B', 'web-service'), + ('aa000001-0000-0000-0000-000000000003', 'graph-svc-c', 'Graph Service C', 'web-service'); --- Relations for web-api-2 (database -> cache-service, targetTemplateIdentifier = cache-service) -INSERT INTO idp_core.relation (id, name, target_template_identifier) +-- Relations owned by graph-svc-a: "uses" → b, "monitors" → b +INSERT INTO relation (id, name, target_template_identifier) VALUES - ('bb000000-0000-0000-0000-000000000002', 'database', 'cache-service'); -INSERT INTO idp_core.relation_target_entities (relation_id, target_entity_identifier) + ('bb000001-0000-0000-0000-000000000001', 'uses', 'web-service'), + ('bb000001-0000-0000-0000-000000000002', 'monitors', 'web-service'); + +-- Relation owned by graph-svc-b: "uses" → c +INSERT INTO relation (id, name, target_template_identifier) VALUES - ('bb000000-0000-0000-0000-000000000002', 'cache-service-1'); -INSERT INTO idp_core.entity_relations (entity_id, relation_id) + ('bb000002-0000-0000-0000-000000000001', 'uses', 'web-service'); + +-- Target entity identifiers for each relation +INSERT INTO relation_target_entities (relation_id, target_entity_identifier) VALUES - ('550e8400-e29b-41d4-a716-446655440101', 'bb000000-0000-0000-0000-000000000002'); + ('bb000001-0000-0000-0000-000000000001', 'graph-svc-b'), -- a -[uses]-> b + ('bb000001-0000-0000-0000-000000000002', 'graph-svc-b'), -- a -[monitors]-> b + ('bb000002-0000-0000-0000-000000000001', 'graph-svc-c'); -- b -[uses]-> c --- api-link relation for web-api-1 targeting microservice-1 (supports q=relation=api-link;relation.api-link.name:microservice) -INSERT INTO idp_core.relation (id, name, target_template_identifier) +-- Link relations to their owner entities +INSERT INTO entity_relations (entity_id, relation_id) VALUES - ('bb000000-0000-0000-0000-000000000003', 'api-link', 'microservice'); -INSERT INTO idp_core.relation_target_entities (relation_id, target_entity_identifier) + ('aa000001-0000-0000-0000-000000000001', 'bb000001-0000-0000-0000-000000000001'), -- a owns "uses" relation + ('aa000001-0000-0000-0000-000000000001', 'bb000001-0000-0000-0000-000000000002'), -- a owns "monitors" relation + ('aa000001-0000-0000-0000-000000000002', 'bb000002-0000-0000-0000-000000000001'); -- b owns "uses" relation + +-- ----------------------------------------------------------------------- +-- Property data for graph test entities (used by the property-filter tests). +-- +-- Each graph entity gets two properties: "tier" and "version". +-- This lets us verify: +-- 1. No filter → both properties appear in node data +-- 2. Filter "tier" → only tier present, version absent +-- 3. Filter "tier"+"version" → both present +-- 4. Filter "non-existent" → data field omitted entirely (NON_EMPTY) +-- ----------------------------------------------------------------------- + +INSERT INTO property (id, name, value) VALUES - ('bb000000-0000-0000-0000-000000000003', 'microservice-1'); -INSERT INTO idp_core.entity_relations (entity_id, relation_id) + -- graph-svc-a + ('cc000001-0000-0000-0000-000000000001', 'tier', 'gold'), + ('cc000001-0000-0000-0000-000000000002', 'version', '1.0.0'), + -- graph-svc-b + ('cc000001-0000-0000-0000-000000000003', 'tier', 'silver'), + ('cc000001-0000-0000-0000-000000000004', 'version', '2.0.0'), + -- graph-svc-c + ('cc000001-0000-0000-0000-000000000005', 'tier', 'bronze'), + ('cc000001-0000-0000-0000-000000000006', 'version', '3.0.0'); + +INSERT INTO entity_properties (entity_id, property_id) VALUES - ('550e8400-e29b-41d4-a716-446655440100', 'bb000000-0000-0000-0000-000000000003'); + ('aa000001-0000-0000-0000-000000000001', 'cc000001-0000-0000-0000-000000000001'), -- a.tier + ('aa000001-0000-0000-0000-000000000001', 'cc000001-0000-0000-0000-000000000002'), -- a.version + ('aa000001-0000-0000-0000-000000000002', 'cc000001-0000-0000-0000-000000000003'), -- b.tier + ('aa000001-0000-0000-0000-000000000002', 'cc000001-0000-0000-0000-000000000004'), -- b.version + ('aa000001-0000-0000-0000-000000000003', 'cc000001-0000-0000-0000-000000000005'), -- c.tier + ('aa000001-0000-0000-0000-000000000003', 'cc000001-0000-0000-0000-000000000006'); -- c.version \ No newline at end of file From cfc1c51304da0fc0253baeca990a0671235dd9f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Fri, 29 May 2026 11:47:32 +0200 Subject: [PATCH 24/53] feat(core): add a entity graph service and endpoint --- .github/instructions/domain.instructions.md | 140 +++++++- .gitignore | 6 - .mvn/wrapper/maven-wrapper.properties | 2 + .pre-commit-config.yaml | 18 +- .spotless/eclipse-formatter.xml | 2 +- mvnw | 316 ++++++++++++++++++ mvnw.cmd | 188 +++++++++++ .../entity/EntityValidationException.java | 2 +- .../entity_graph/EntityGraphService.java | 4 + .../mapper/EntityPersistenceMapper.java | 2 - .../db/test/R__1_Insert_test_data.sql | 1 - .../test/R__2_Insert_entities_test_data.sql | 2 +- 12 files changed, 660 insertions(+), 23 deletions(-) create mode 100644 .mvn/wrapper/maven-wrapper.properties create mode 100755 mvnw create mode 100644 mvnw.cmd diff --git a/.github/instructions/domain.instructions.md b/.github/instructions/domain.instructions.md index 78af57fc..3d979036 100644 --- a/.github/instructions/domain.instructions.md +++ b/.github/instructions/domain.instructions.md @@ -37,10 +37,68 @@ applyTo: '**/domain/**/*.java' ## Exceptions -- Create specific unchecked exceptions for business rule violations (for example, `EntityTemplateNotFoundException`, `EntityTemplateAlreadyExistsException`). +### General Rules + +- Create **specific unchecked exceptions** for each business rule violation (for example, `EntityTemplateNotFoundException`, `EntityAlreadyExistsException`). - Domain exceptions must **not** contain HTTP status codes or REST-specific information. - Map domain exceptions to HTTP status codes exclusively in the Infrastructure layer (`@ControllerAdvice`). +### Exception Clarity + +- **Always prefer specific exceptions over generic ones**. Never throw `IllegalArgumentException` or `IllegalStateException` for business rule violations. +- Exception names must describe **what went wrong** from a business perspective (for example, `EntityTemplateNotFoundException`, not `TemplateException`). +- Exception messages must include **context**: what entity, what identifier, what operation was attempted. + +### Validation Service Pattern + +When a service method needs to validate preconditions (for example, "entity template must exist before creating entity"): + +1. **Extract validation into a dedicated service** (for example, `EntityTemplateValidationService`) +2. **Use explicit method names** that describe the validation (for example, `validateTemplateExists`, `validateTemplateNotExists`) +3. **Throw specific exceptions** that carry business meaning (for example, `EntityTemplateNotFoundException`) +4. **Call validation first** (fail-fast) before executing the main operation + +**Benefits:** + +- **Clear error messages**: `EntityTemplateNotFoundException("web-service")` vs generic `IllegalArgumentException("Invalid template")` +- **Better HTTP mapping**: specific exceptions map to appropriate status codes (404 for not found, 409 for conflict) +- **Reusable validation**: multiple services can call `validateTemplateExists` without duplicating logic +- **Fail-fast**: validation happens before expensive operations (database queries, graph traversal) + +### Exception Naming Convention + +| Pattern | Example | When to Use | +| --------------------------------- | --------------------------------------- | ------------------------------ | +| `NotFoundException` | `EntityTemplateNotFoundException` | Resource doesn't exist (404) | +| `AlreadyExistsException` | `EntityTemplateAlreadyExistsException` | Duplicate key violation (409) | +| `ValidationException` | `PropertyValidationException` | Business rule violation (400) | +| `NotAllowedException` | `EntityDeletionNotAllowedException` | Operation forbidden (403/409) | + +### Exception Structure + +```java +public class EntityTemplateNotFoundException extends RuntimeException { + + private final String identifier; + + public EntityTemplateNotFoundException(String identifier) { + super(String.format("Entity template with identifier '%s' not found", identifier)); + this.identifier = identifier; + } + + public String getIdentifier() { + return identifier; + } +} +``` + +**Rules:** + +- Extend `RuntimeException` (unchecked) for business exceptions +- Include a formatted message with all relevant context +- Store identifiers/keys as fields if needed for logging or error responses +- Never include stack traces in exception messages + ## Constants - Use a dedicated constants class (for example, `ValidationMessages.java`) for all validation messages. @@ -58,6 +116,71 @@ applyTo: '**/domain/**/*.java' - **Adapter-Level vs. Domain-Level**: syntactic checks (nulls, empty strings) belong on DTOs in the Infrastructure layer. Semantic checks (uniqueness, cross-field rules) belong in Domain Services. - Throw a custom `DomainValidationException` (or similar unchecked exception) when rules are violated. +### Creating Validation Services + +When validation logic is reused across multiple domain services: + +1. **Create a dedicated validation service** (for example, `EntityTemplateValidationService`) +2. **Extract validation methods** with clear names: `validateTemplateExists`, `validateTemplateNotExists`, `validateTemplateNotReferenced` +3. **Always call validation first** before the main operation (fail-fast principle) + +**Example validation service:** + +```java +@Service +@RequiredArgsConstructor +public class EntityTemplateValidationService { + + private final EntityTemplateRepositoryPort repository; + + public void validateTemplateExists(String identifier) { + if (!repository.existsByIdentifier(identifier)) { + throw new EntityTemplateNotFoundException(identifier); + } + } + + public void validateTemplateNotExists(String identifier) { + if (repository.existsByIdentifier(identifier)) { + throw new EntityTemplateAlreadyExistsException(identifier); + } + } + + public void validateTemplateNotReferenced(String identifier) { + if (repository.hasEntities(identifier)) { + throw new EntityTemplateReferencedException(identifier, + "Cannot delete template that is referenced by entities"); + } + } +} +``` + +**Usage (fail-fast):** + +```java +@Service +@RequiredArgsConstructor +public class EntityService { + + private final EntityTemplateValidationService templateValidation; + private final EntityRepositoryPort entityRepository; + + @Transactional + public Entity createEntity(String templateIdentifier, String entityIdentifier, ...) { + // Validate template exists FIRST (fail-fast) + templateValidation.validateTemplateExists(templateIdentifier); + + // Validate entity doesn't already exist + if (entityRepository.existsByIdentifier(entityIdentifier)) { + throw new EntityAlreadyExistsException(entityIdentifier); + } + + // Main operation + Entity entity = new Entity(...); + return entityRepository.save(entity); + } +} +``` + ## Mapping - Never use `ObjectMapper` or reflection-based libraries for internal layer mapping. @@ -70,10 +193,23 @@ applyTo: '**/domain/**/*.java' domain/ ├── constant/ # Validation message constants ├── exception/ # Domain-specific exceptions +│ ├── entity/ # Entity-related exceptions +│ ├── entity_template/ # Template-related exceptions +│ ├── property/ # Property-related exceptions +│ └── webhook/ # Webhook-related exceptions ├── model/ │ ├── entity/ # Core business records │ ├── entity_template/ # Template records │ └── enums/ # Business enums -├── port/ # Port interfaces (contracts for driven adapters) +├── port/ # Port interfaces (contracts for driven adapters) └── service/ # Domain services (orchestration) + ├── entity/ # Entity services + ├── entity_template/ # Template validation services + └── entity_graph/ # Graph services ``` + +### Exception Package Organization + +- Organize exceptions by aggregate/subdomain (for example, `entity/`, `entity_template/`, `property/`) +- Each exception class should have a clear, descriptive name that follows the naming conventions above +- Keep exception hierarchy flat — avoid deep inheritance trees diff --git a/.gitignore b/.gitignore index 04557c36..16ef5f56 100644 --- a/.gitignore +++ b/.gitignore @@ -35,12 +35,6 @@ release.properties dependency-reduced-pom.xml buildNumber.properties .mvn/timing.properties -# https://github.com/takari/maven-wrapper#usage-without-binary-jar -.mvn/wrapper/maven-wrapper.jar -.mvn/wrapper/maven-wrapper.properties -mvnw -mvnw.cmd - # Eclipse m2e generated files # Eclipse Core diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 00000000..7b63accb --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,2 @@ +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip +wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.3.2/maven-wrapper-3.3.2.jar \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 815a5e21..dd14a011 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -37,15 +37,15 @@ repos: - --config - .markdownlint.yaml - . - - repo: https://github.com/errata-ai/vale - rev: v3.12.0 - hooks: - - id: vale - name: vale sync - pass_filenames: false - args: [sync] - - id: vale - args: [--output=line, --minAlertLevel=error] + # - repo: https://github.com/errata-ai/vale + # rev: v3.12.0 + # hooks: + # - id: vale + # name: vale sync + # pass_filenames: false + # args: [sync] + # - id: vale + # args: [--output=line, --minAlertLevel=error] - repo: local hooks: - id: spotless-check diff --git a/.spotless/eclipse-formatter.xml b/.spotless/eclipse-formatter.xml index 75b546e6..c4da870b 100644 --- a/.spotless/eclipse-formatter.xml +++ b/.spotless/eclipse-formatter.xml @@ -7,4 +7,4 @@ - \ No newline at end of file + diff --git a/mvnw b/mvnw new file mode 100755 index 00000000..0e8383cd --- /dev/null +++ b/mvnw @@ -0,0 +1,316 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Maven Start Up Batch script +# +# Required ENV vars: +# ------------------ +# JAVA_HOME - location of a JDK home dir +# +# Optional ENV vars +# ----------------- +# M2_HOME - location of maven2's installed home dir +# MAVEN_OPTS - parameters passed to the Java VM when running Maven +# e.g. to debug Maven itself, use +# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +# MAVEN_SKIP_RC - flag to disable loading of mavenrc files +# ---------------------------------------------------------------------------- + +if [ -z "$MAVEN_SKIP_RC" ] ; then + + if [ -f /usr/local/etc/mavenrc ] ; then + . /usr/local/etc/mavenrc + fi + + if [ -f /etc/mavenrc ] ; then + . /etc/mavenrc + fi + + if [ -f "$HOME/.mavenrc" ] ; then + . "$HOME/.mavenrc" + fi + +fi + +# OS specific support. $var _must_ be set to either true or false. +cygwin=false; +darwin=false; +mingw=false +case "`uname`" in + CYGWIN*) cygwin=true ;; + MINGW*) mingw=true;; + Darwin*) darwin=true + # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home + # See https://developer.apple.com/library/mac/qa/qa1170/_index.html + if [ -z "$JAVA_HOME" ]; then + if [ -x "/usr/libexec/java_home" ]; then + export JAVA_HOME="`/usr/libexec/java_home`" + else + export JAVA_HOME="/Library/Java/Home" + fi + fi + ;; +esac + +if [ -z "$JAVA_HOME" ] ; then + if [ -r /etc/gentoo-release ] ; then + JAVA_HOME=`java-config --jre-home` + fi +fi + +if [ -z "$M2_HOME" ] ; then + ## resolve links - $0 may be a link to maven's home + PRG="$0" + + # need this for relative symlinks + while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG="`dirname "$PRG"`/$link" + fi + done + + saveddir=`pwd` + + M2_HOME=`dirname "$PRG"`/.. + + # make it fully qualified + M2_HOME=`cd "$M2_HOME" && pwd` + + cd "$saveddir" + # echo Using m2 at $M2_HOME +fi + +# For Cygwin, ensure paths are in UNIX format before anything is touched +if $cygwin ; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --unix "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --unix "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --unix "$CLASSPATH"` +fi + +# For Mingw, ensure paths are in UNIX format before anything is touched +if $mingw ; then + [ -n "$M2_HOME" ] && + M2_HOME="`(cd "$M2_HOME"; pwd)`" + [ -n "$JAVA_HOME" ] && + JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" +fi + +if [ -z "$JAVA_HOME" ]; then + javaExecutable="`which javac`" + if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then + # readlink(1) is not available as standard on Solaris 10. + readLink=`which readlink` + if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then + if $darwin ; then + javaHome="`dirname \"$javaExecutable\"`" + javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" + else + javaExecutable="`readlink -f \"$javaExecutable\"`" + fi + javaHome="`dirname \"$javaExecutable\"`" + javaHome=`expr "$javaHome" : '\(.*\)/bin'` + JAVA_HOME="$javaHome" + export JAVA_HOME + fi + fi +fi + +if [ -z "$JAVACMD" ] ; then + if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + else + JAVACMD="`\\unset -f command; \\command -v java`" + fi +fi + +if [ ! -x "$JAVACMD" ] ; then + echo "Error: JAVA_HOME is not defined correctly." >&2 + echo " We cannot execute $JAVACMD" >&2 + exit 1 +fi + +if [ -z "$JAVA_HOME" ] ; then + echo "Warning: JAVA_HOME environment variable is not set." +fi + +CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher + +# traverses directory structure from process work directory to filesystem root +# first directory with .mvn subdirectory is considered project base directory +find_maven_basedir() { + + if [ -z "$1" ] + then + echo "Path not specified to find_maven_basedir" + return 1 + fi + + basedir="$1" + wdir="$1" + while [ "$wdir" != '/' ] ; do + if [ -d "$wdir"/.mvn ] ; then + basedir=$wdir + break + fi + # workaround for JBEAP-8937 (on Solaris 10/Sparc) + if [ -d "${wdir}" ]; then + wdir=`cd "$wdir/.."; pwd` + fi + # end of workaround + done + echo "${basedir}" +} + +# concatenates all lines of a file +concat_lines() { + if [ -f "$1" ]; then + echo "$(tr -s '\n' ' ' < "$1")" + fi +} + +BASE_DIR=`find_maven_basedir "$(pwd)"` +if [ -z "$BASE_DIR" ]; then + exit 1; +fi + +########################################################################################## +# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +# This allows using the maven wrapper in projects that prohibit checking in binary data. +########################################################################################## +if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found $BASE_DIR/.mvn/wrapper/maven-wrapper.jar" + fi +else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Couldn't find $BASE_DIR/.mvn/wrapper/maven-wrapper.jar, downloading it ..." + fi + if [ -n "$MVNW_REPOURL" ]; then + jarUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" + else + jarUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" + fi + while IFS="=" read key value; do + case "$key" in (wrapperUrl) jarUrl="$value"; break ;; + esac + done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties" + if [ "$MVNW_VERBOSE" = true ]; then + echo "Downloading from: $jarUrl" + fi + wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" + if $cygwin; then + wrapperJarPath=`cygpath --path --windows "$wrapperJarPath"` + fi + + if command -v wget > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found wget ... using wget" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + wget "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + else + wget --http-user=$MVNW_USERNAME --http-password=$MVNW_PASSWORD "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + fi + elif command -v curl > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found curl ... using curl" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + curl -o "$wrapperJarPath" "$jarUrl" -f + else + curl --user $MVNW_USERNAME:$MVNW_PASSWORD -o "$wrapperJarPath" "$jarUrl" -f + fi + + else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Falling back to using Java to download" + fi + javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java" + # For Cygwin, switch paths to Windows format before running javac + if $cygwin; then + javaClass=`cygpath --path --windows "$javaClass"` + fi + if [ -e "$javaClass" ]; then + if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Compiling MavenWrapperDownloader.java ..." + fi + # Compiling the Java class + ("$JAVACMD" "$javaClass") + fi + if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + # Running the downloader + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Running MavenWrapperDownloader.java ..." + fi + ("$JAVACMD" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR") + fi + fi + fi +fi +########################################################################################## +# End of extension +########################################################################################## + +export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"} +if [ "$MVNW_VERBOSE" = true ]; then + echo $MAVEN_PROJECTBASEDIR +fi +MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" + +# For Cygwin, switch paths to Windows format before running java +if $cygwin; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --path --windows "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --windows "$CLASSPATH"` + [ -n "$MAVEN_PROJECTBASEDIR" ] && + MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` +fi + +# Provide a "standardized" way to retrieve the CLI args that will +# work with both Windows and non-Windows executions. +MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@" +export MAVEN_CMD_LINE_ARGS + +WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +exec "$JAVACMD" \ + $MAVEN_OPTS \ + $MAVEN_DEBUG_OPTS \ + -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ + "-Dmaven.home=${M2_HOME}" \ + "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ + ${WRAPPER_LAUNCHER} $MAVEN_CMD_LINE_ARGS diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 00000000..db915c9a --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,188 @@ +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM https://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Maven Start Up Batch script +@REM +@REM Required ENV vars: +@REM JAVA_HOME - location of a JDK home dir +@REM +@REM Optional ENV vars +@REM M2_HOME - location of maven2's installed home dir +@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands +@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending +@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven +@REM e.g. to debug Maven itself, use +@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files +@REM ---------------------------------------------------------------------------- + +@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' +@echo off +@REM set title of command window +title %0 +@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' +@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% + +@REM set %HOME% to equivalent of $HOME +if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") + +@REM Execute a user defined script before this one +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre +@REM check for pre script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %* +if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %* +:skipRcPre + +@setlocal + +set ERROR_CODE=0 + +@REM To isolate internal variables from possible post scripts, we use another setlocal +@setlocal + +@REM ==== START VALIDATION ==== +if not "%JAVA_HOME%" == "" goto OkJHome + +echo. +echo Error: JAVA_HOME not found in your environment. >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +:OkJHome +if exist "%JAVA_HOME%\bin\java.exe" goto init + +echo. +echo Error: JAVA_HOME is set to an invalid directory. >&2 +echo JAVA_HOME = "%JAVA_HOME%" >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +@REM ==== END VALIDATION ==== + +:init + +@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". +@REM Fallback to current working directory if not found. + +set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% +IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir + +set EXEC_DIR=%CD% +set WDIR=%EXEC_DIR% +:findBaseDir +IF EXIST "%WDIR%"\.mvn goto baseDirFound +cd .. +IF "%WDIR%"=="%CD%" goto baseDirNotFound +set WDIR=%CD% +goto findBaseDir + +:baseDirFound +set MAVEN_PROJECTBASEDIR=%WDIR% +cd "%EXEC_DIR%" +goto endDetectBaseDir + +:baseDirNotFound +set MAVEN_PROJECTBASEDIR=%EXEC_DIR% +cd "%EXEC_DIR%" + +:endDetectBaseDir + +IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig + +@setlocal EnableExtensions EnableDelayedExpansion +for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a +@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% + +:endReadAdditionalConfig + +SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" +set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" +set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" + +FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( + IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B +) + +@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +@REM This allows using the maven wrapper in projects that prohibit checking in binary data. +if exist %WRAPPER_JAR% ( + if "%MVNW_VERBOSE%" == "true" ( + echo Found %WRAPPER_JAR% + ) +) else ( + if not "%MVNW_REPOURL%" == "" ( + SET DOWNLOAD_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" + ) + if "%MVNW_VERBOSE%" == "true" ( + echo Couldn't find %WRAPPER_JAR%, downloading it ... + echo Downloading from: %DOWNLOAD_URL% + ) + + powershell -Command "&{"^ + "$webclient = new-object System.Net.WebClient;"^ + "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ + "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ + "}"^ + "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"^ + "}" + if "%MVNW_VERBOSE%" == "true" ( + echo Finished downloading %WRAPPER_JAR% + ) +) +@REM End of extension + +@REM Provide a "standardized" way to retrieve the CLI args that will +@REM work with both Windows and non-Windows executions. +set MAVEN_CMD_LINE_ARGS=%* + +%MAVEN_JAVA_EXE% ^ + %JVM_CONFIG_MAVEN_PROPS% ^ + %MAVEN_OPTS% ^ + %MAVEN_DEBUG_OPTS% ^ + -classpath %WRAPPER_JAR% ^ + "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^ + %WRAPPER_LAUNCHER% %MAVEN_CMD_LINE_ARGS% +if ERRORLEVEL 1 goto error +goto end + +:error +set ERROR_CODE=1 + +:end +@endlocal & set ERROR_CODE=%ERROR_CODE% + +if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost +@REM check for post script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat" +if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd" +:skipRcPost + +@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' +if "%MAVEN_BATCH_PAUSE%"=="on" pause + +if "%MAVEN_ERROR_CODE%"=="0" exit /b %ERROR_CODE% + +exit /b %ERROR_CODE% diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityValidationException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityValidationException.java index 00381203..26859a50 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityValidationException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityValidationException.java @@ -26,7 +26,7 @@ public class EntityValidationException extends RuntimeException { /** * -- GETTER -- Returns the list of individual validation violation messages. * /// /// - * + * * @return immutable list of violation messages */ private final List violations; diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java index 121145eb..a260b6ff 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java @@ -19,6 +19,7 @@ import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphRelation; import com.decathlon.idp_core.domain.port.EntityGraphRepositoryPort; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; import lombok.RequiredArgsConstructor; @@ -49,6 +50,7 @@ public class EntityGraphService { private final EntityRepositoryPort entityRepositoryPort; private final EntityGraphRepositoryPort entityGraphRepositoryPort; + private final EntityTemplateValidationService entityTemplateValidationService; /// Builds the relationship graph for an entity starting from its composite key. /// @@ -64,6 +66,8 @@ public EntityGraphNode getEntityGraph(String templateIdentifier, String entityId int depth, boolean includeProperties) { int effectiveDepth = Math.clamp(depth, 1, MAX_DEPTH); + entityTemplateValidationService.validateTemplateExists(templateIdentifier); + Entity rootEntity = entityRepositoryPort .findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java index ba0e8ebb..40dbea1f 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java @@ -1,9 +1,7 @@ package com.decathlon.idp_core.infrastructure.adapters.persistence.mapper; import org.mapstruct.Mapper; -import org.mapstruct.Mapping; import org.mapstruct.MappingConstants; -import org.mapstruct.Named; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.Property; diff --git a/src/test/resources/db/test/R__1_Insert_test_data.sql b/src/test/resources/db/test/R__1_Insert_test_data.sql index 1d954be4..2bc54599 100644 --- a/src/test/resources/db/test/R__1_Insert_test_data.sql +++ b/src/test/resources/db/test/R__1_Insert_test_data.sql @@ -285,4 +285,3 @@ INSERT INTO entity_template_relations_definitions (entity_template_id, relations ('550e8400-e29b-41d4-a716-446655440079', '550e8400-e29b-41d4-a716-446655440053'), -- database ('550e8400-e29b-41d4-a716-446655440079', '550e8400-e29b-41d4-a716-446655440057'), -- networks ('550e8400-e29b-41d4-a716-446655440079', '550e8400-e29b-41d4-a716-446655440064'); -- external_apis - diff --git a/src/test/resources/db/test/R__2_Insert_entities_test_data.sql b/src/test/resources/db/test/R__2_Insert_entities_test_data.sql index 8b334834..01dbafd5 100644 --- a/src/test/resources/db/test/R__2_Insert_entities_test_data.sql +++ b/src/test/resources/db/test/R__2_Insert_entities_test_data.sql @@ -162,4 +162,4 @@ VALUES ('aa000001-0000-0000-0000-000000000002', 'cc000001-0000-0000-0000-000000000003'), -- b.tier ('aa000001-0000-0000-0000-000000000002', 'cc000001-0000-0000-0000-000000000004'), -- b.version ('aa000001-0000-0000-0000-000000000003', 'cc000001-0000-0000-0000-000000000005'), -- c.tier - ('aa000001-0000-0000-0000-000000000003', 'cc000001-0000-0000-0000-000000000006'); -- c.version \ No newline at end of file + ('aa000001-0000-0000-0000-000000000003', 'cc000001-0000-0000-0000-000000000006'); -- c.version From 984db208752424768665995ba3db8a79a8aefca7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Fri, 29 May 2026 14:48:29 +0200 Subject: [PATCH 25/53] feat(core): add a entity graph service and endpoint --- .github/instructions/domain.instructions.md | 3 +- .mvn/wrapper/maven-wrapper.properties | 2 +- .pre-commit-config.yaml | 18 +- .../entity_graph/EntityGraphService.java | 72 ++++++-- .../api/controller/EntityGraphController.java | 4 +- .../entity/EntityGraphFlatDtoOutMapper.java | 88 +++------ .../entity_graph/EntityGraphServiceTest.java | 168 +++++++++++++++--- 7 files changed, 229 insertions(+), 126 deletions(-) diff --git a/.github/instructions/domain.instructions.md b/.github/instructions/domain.instructions.md index 3d979036..e1f34a6a 100644 --- a/.github/instructions/domain.instructions.md +++ b/.github/instructions/domain.instructions.md @@ -195,8 +195,7 @@ domain/ ├── exception/ # Domain-specific exceptions │ ├── entity/ # Entity-related exceptions │ ├── entity_template/ # Template-related exceptions -│ ├── property/ # Property-related exceptions -│ └── webhook/ # Webhook-related exceptions +│ └── property/ # Property-related exceptions│ ├── model/ │ ├── entity/ # Core business records │ ├── entity_template/ # Template records diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties index 7b63accb..308007b5 100644 --- a/.mvn/wrapper/maven-wrapper.properties +++ b/.mvn/wrapper/maven-wrapper.properties @@ -1,2 +1,2 @@ distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip -wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.3.2/maven-wrapper-3.3.2.jar \ No newline at end of file +wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.3.2/maven-wrapper-3.3.2.jar diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index dd14a011..815a5e21 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -37,15 +37,15 @@ repos: - --config - .markdownlint.yaml - . - # - repo: https://github.com/errata-ai/vale - # rev: v3.12.0 - # hooks: - # - id: vale - # name: vale sync - # pass_filenames: false - # args: [sync] - # - id: vale - # args: [--output=line, --minAlertLevel=error] + - repo: https://github.com/errata-ai/vale + rev: v3.12.0 + hooks: + - id: vale + name: vale sync + pass_filenames: false + args: [sync] + - id: vale + args: [--output=line, --minAlertLevel=error] - repo: local hooks: - id: spotless-check diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java index a260b6ff..b04d1efe 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java @@ -40,8 +40,9 @@ /// - A per-request `visitedNodeIds` set prevents exponential recursion: without it, /// inbound relation scanning would re-expand already-visited nodes at every depth /// level, producing O(2^depth) calls even for small graphs (OOM at depth ≥ 10). -/// - The service always returns the full unfiltered graph tree. Relation name filtering -/// is a presentation concern applied by the mapper layer. +/// - Relation and property filtering are domain concerns applied during graph construction, +/// so that callers (e.g. the REST controller) receive a graph that already respects +/// the requested scope instead of carrying unnecessary data to the Infrastructure layer. @Service @RequiredArgsConstructor public class EntityGraphService { @@ -54,16 +55,28 @@ public class EntityGraphService { /// Builds the relationship graph for an entity starting from its composite key. /// + /// Relation and property filtering are applied here in the domain layer so that + /// callers receive a correctly scoped graph without needing to know about + /// filtering + /// logic. + /// /// @param templateIdentifier the template identifier of the root entity /// @param entityIdentifier the business identifier of the root entity /// @param depth the maximum traversal depth (clamped to [1, MAX_DEPTH]) /// @param includeProperties when true, each graph node carries the entity's - /// full property list - /// @return the root graph node with all resolved relations + /// full property list (subject to propertyFilter) + /// @param relationFilter when non-empty, only relations whose name is in this + /// set are included in the graph; an empty set means no filter — all relations + /// are included + /// @param propertyFilter when non-empty, each node's property list is + /// restricted to properties whose name is in this set; an empty set means no + /// filter — all properties are included + /// @return the root graph node with all resolved (and filtered) relations /// @throws EntityNotFoundException when no entity matches the given identifiers @Transactional(readOnly = true) public EntityGraphNode getEntityGraph(String templateIdentifier, String entityIdentifier, - int depth, boolean includeProperties) { + int depth, boolean includeProperties, Set relationFilter, + Set propertyFilter) { int effectiveDepth = Math.clamp(depth, 1, MAX_DEPTH); entityTemplateValidationService.validateTemplateExists(templateIdentifier); @@ -83,7 +96,8 @@ public EntityGraphNode getEntityGraph(String templateIdentifier, String entityId // preventing O(2^depth) recursion from mutual outbound/inbound re-expansion. Set visitedNodeIds = new HashSet<>(); - return buildGraphNode(rootKey, entityMap, effectiveDepth, includeProperties, visitedNodeIds); + return buildGraphNode(rootKey, entityMap, effectiveDepth, includeProperties, relationFilter, + propertyFilter, visitedNodeIds); } /// Builds a graph node from a pre-loaded entity map (no database calls). @@ -97,7 +111,7 @@ public EntityGraphNode getEntityGraph(String templateIdentifier, String entityId /// inbound scanning re-expanding the same nodes at every depth level. private EntityGraphNode buildGraphNode(EntityCompositeKey key, Map entityMap, int remainingDepth, boolean includeProperties, - Set visitedNodeIds) { + Set relationFilter, Set propertyFilter, Set visitedNodeIds) { Entity entity = entityMap.get(key); if (entity == null) { return new EntityGraphNode(key.templateIdentifier(), key.identifier(), key.identifier(), @@ -111,7 +125,7 @@ private EntityGraphNode buildGraphNode(EntityCompositeKey key, // nodes. var nodeId = entity.templateIdentifier() + ":" + entity.identifier(); if (!visitedNodeIds.add(nodeId)) { - List stubProperties = includeProperties ? entity.properties() : List.of(); + List stubProperties = resolveProperties(entity, includeProperties, propertyFilter); return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), stubProperties, List.of(), List.of()); } @@ -119,22 +133,26 @@ private EntityGraphNode buildGraphNode(EntityCompositeKey key, // Depth exhausted — return a leaf with no relations but still carry properties // so the deepest reachable entities expose their data when include_data=true. if (remainingDepth <= 0) { - List leafProperties = includeProperties ? entity.properties() : List.of(); + List leafProperties = resolveProperties(entity, includeProperties, propertyFilter); return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), leafProperties, List.of(), List.of()); } List outboundRelations = entity.relations().stream() - .map(relation -> new EntityGraphRelation(relation.name(), relation.targetEntityIdentifiers() - .stream().map(targetId -> buildGraphNode(findKeyByIdentifier(targetId, entityMap), - entityMap, remainingDepth - 1, includeProperties, visitedNodeIds)) - .toList())) + .filter(relation -> relationFilter.isEmpty() || relationFilter.contains(relation.name())) + .map(relation -> new EntityGraphRelation(relation.name(), + relation.targetEntityIdentifiers().stream() + .map(targetId -> buildGraphNode(findKeyByIdentifier(targetId, entityMap), entityMap, + remainingDepth - 1, includeProperties, relationFilter, propertyFilter, + visitedNodeIds)) + .toList())) .toList(); List inboundRelations = buildRelationsAsTargetFromMap(entity.identifier(), - entityMap, remainingDepth - 1, includeProperties, visitedNodeIds); + entityMap, remainingDepth - 1, includeProperties, relationFilter, propertyFilter, + visitedNodeIds); - List properties = includeProperties ? entity.properties() : List.of(); + List properties = resolveProperties(entity, includeProperties, propertyFilter); return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), properties, outboundRelations, inboundRelations); } @@ -155,13 +173,14 @@ private EntityCompositeKey findKeyByIdentifier(String identifier, /// depths. private List buildRelationsAsTargetFromMap(String targetIdentifier, Map entityMap, int remainingDepth, boolean includeProperties, - Set visitedNodeIds) { + Set relationFilter, Set propertyFilter, Set visitedNodeIds) { Map> sourcesByRelationName = new HashMap<>(); for (Map.Entry entry : entityMap.entrySet()) { Entity sourceEntity = entry.getValue(); for (Relation relation : sourceEntity.relations()) { - if (relation.targetEntityIdentifiers().contains(targetIdentifier)) { + if (relation.targetEntityIdentifiers().contains(targetIdentifier) + && (relationFilter.isEmpty() || relationFilter.contains(relation.name()))) { sourcesByRelationName.computeIfAbsent(relation.name(), k -> new ArrayList<>()) .add(entry.getKey()); } @@ -170,8 +189,23 @@ private List buildRelationsAsTargetFromMap(String targetIde return sourcesByRelationName.entrySet().stream() .map(e -> new EntityGraphRelation(e.getKey(), - e.getValue().stream().map(sourceKey -> buildGraphNode(sourceKey, entityMap, - remainingDepth, includeProperties, visitedNodeIds)).toList())) + e.getValue().stream() + .map(sourceKey -> buildGraphNode(sourceKey, entityMap, remainingDepth, + includeProperties, relationFilter, propertyFilter, visitedNodeIds)) + .toList())) .toList(); } + + /// Returns the entity's properties filtered by [propertyFilter] when active, + /// or an empty list when [includeProperties] is false. + private List resolveProperties(Entity entity, boolean includeProperties, + Set propertyFilter) { + if (!includeProperties) { + return List.of(); + } + if (propertyFilter.isEmpty()) { + return entity.properties(); + } + return entity.properties().stream().filter(p -> propertyFilter.contains(p.name())).toList(); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java index a90639b9..5aa6a68e 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java @@ -85,8 +85,8 @@ public EntityGraphFlatDtoOut getEntityGraph(@PathVariable @NotBlank String templ Set propertyFilter = properties != null ? Set.copyOf(properties) : Set.of(); EntityGraphNode graphNode = entityGraphService.getEntityGraph(templateIdentifier, - entityIdentifier, depth, includeData); + entityIdentifier, depth, includeData, relationFilter, propertyFilter); - return EntityGraphFlatDtoOutMapper.toFlatDto(graphNode, relationFilter, propertyFilter); + return EntityGraphFlatDtoOutMapper.toFlatDto(graphNode); } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java index fd96646e..83d57854 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java @@ -12,6 +12,7 @@ import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphRelation; +import com.decathlon.idp_core.domain.service.entity_graph.EntityGraphService; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityGraphEdgeDtoOut; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityGraphFlatDtoOut; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityGraphNodeFlatDtoOut; @@ -30,6 +31,8 @@ /// - A `SequencedSet` of visited node IDs prevents infinite loops in cyclic graphs. /// - A `Set` of edge signatures (`source|target|label`) deduplicates edges that would /// otherwise be emitted twice when both sides of a relation are traversed. +/// - Filtering (relation names, property names) is a domain concern handled upstream by +/// [EntityGraphService]; this mapper only flattens the tree it receives. public final class EntityGraphFlatDtoOutMapper { private EntityGraphFlatDtoOutMapper() { @@ -46,18 +49,12 @@ private record TraversalState(SequencedSet nodes, /// Maps a domain graph node tree to a flat [EntityGraphFlatDtoOut]. /// + /// The domain graph passed here is already filtered by the service layer; + /// this method only performs structural flattening. + /// /// @param root the root [EntityGraphNode] returned by the domain service - /// @param relationFilter when non-empty, only edges whose type is in this set - /// are emitted, - /// and nodes not referenced by any remaining edge are pruned; - /// an empty set means no filter — all edge types and nodes are emitted - /// @param propertyFilter when non-empty, only properties whose name is in this - /// set appear - /// in each node's `data` field; - /// an empty set means no filter — all properties are included /// @return flat DTO with deduplicated nodes and directed edges - public static EntityGraphFlatDtoOut toFlatDto(EntityGraphNode root, Set relationFilter, - Set propertyFilter) { + public static EntityGraphFlatDtoOut toFlatDto(EntityGraphNode root) { if (root == null) { return new EntityGraphFlatDtoOut(List.of(), List.of()); } @@ -68,32 +65,12 @@ public static EntityGraphFlatDtoOut toFlatDto(EntityGraphNode root, Set new HashSet<>(), // emittedEdgeSignatures — prevents duplicate edges new AtomicInteger(0)); // edgeCounter - traverse(root, state, relationFilter, propertyFilter); - - // When a relation filter is active, prune nodes that are not connected to any - // remaining edge. Without this step, nodes reachable via non-filtered edges - // would - // appear in the node list despite having no visible edges. - List finalNodes; - if (relationFilter.isEmpty()) { - finalNodes = List.copyOf(state.nodes()); - } else { - // Collect all node IDs referenced by the filtered edges only. - // The root receives no special treatment: if it has no matching edges - // it is pruned just like any other disconnected node. - Set referencedNodeIds = new HashSet<>(); - for (var edge : state.edges()) { - referencedNodeIds.add(edge.source()); - referencedNodeIds.add(edge.target()); - } - finalNodes = state.nodes().stream().filter(n -> referencedNodeIds.contains(n.id())).toList(); - } + traverse(root, state); - return new EntityGraphFlatDtoOut(finalNodes, List.copyOf(state.edges())); + return new EntityGraphFlatDtoOut(List.copyOf(state.nodes()), List.copyOf(state.edges())); } - private static void traverse(EntityGraphNode node, TraversalState state, - Set relationFilter, Set propertyFilter) { + private static void traverse(EntityGraphNode node, TraversalState state) { var nodeId = nodeId(node.templateIdentifier(), node.identifier()); @@ -103,20 +80,14 @@ private static void traverse(EntityGraphNode node, TraversalState state, } state.nodes().add(new EntityGraphNodeFlatDtoOut(nodeId, node.name(), node.templateIdentifier(), - node.identifier(), toDataMap(node, propertyFilter))); + node.identifier(), toDataMap(node))); - // Traverse outbound relations: emit edge from currentNode → target only when - // the - // relation type matches the filter (or no filter is active). Nodes are always - // traversed so that deeper nodes remain reachable regardless of edge - // visibility. + // Traverse outbound relations: emit edge from currentNode → target. for (EntityGraphRelation relation : node.relations()) { for (EntityGraphNode target : relation.targets()) { var targetId = nodeId(target.templateIdentifier(), target.identifier()); - if (relationFilter.isEmpty() || relationFilter.contains(relation.name())) { - addEdge(state, nodeId, targetId, relation.name()); - } - traverse(target, state, relationFilter, propertyFilter); + addEdge(state, nodeId, targetId, relation.name()); + traverse(target, state); } } @@ -127,10 +98,8 @@ private static void traverse(EntityGraphNode node, TraversalState state, for (EntityGraphRelation relation : node.relationsAsTarget()) { for (EntityGraphNode source : relation.targets()) { var sourceId = nodeId(source.templateIdentifier(), source.identifier()); - if (relationFilter.isEmpty() || relationFilter.contains(relation.name())) { - addEdge(state, sourceId, nodeId, relation.name()); - } - traverse(source, state, relationFilter, propertyFilter); + addEdge(state, sourceId, nodeId, relation.name()); + traverse(source, state); } } } @@ -159,23 +128,14 @@ private static String nodeId(String templateIdentifier, String identifier) { /// Converts a node's property list to a name→value map for the `data` field. /// - /// When [propertyFilter] is non-empty, only entries whose name is contained in - /// the - /// filter are included. Returns an empty map when there are no matching - /// properties; - /// the DTO's @JsonInclude(NON_EMPTY) annotation ensures an empty map is omitted - /// from - /// the JSON output. + /// The domain service has already applied any property filter; this method + /// simply converts whatever properties the node carries into the map format + /// expected by the DTO. /// - /// @param node the graph node whose properties are converted - /// @param propertyFilter when non-empty, restricts which properties appear in - /// the map; - /// an empty set means all properties are included - private static Map toDataMap(EntityGraphNode node, Set propertyFilter) { - var stream = node.properties().stream(); - if (!propertyFilter.isEmpty()) { - stream = stream.filter(p -> propertyFilter.contains(p.name())); - } - return stream.collect(Collectors.toMap(p -> p.name(), p -> p.value())); + /// Returns an empty map when there are no properties; the DTO's + /// @JsonInclude(NON_EMPTY) annotation ensures an empty map is omitted from the + /// JSON output. + private static Map toDataMap(EntityGraphNode node) { + return node.properties().stream().collect(Collectors.toMap(p -> p.name(), p -> p.value())); } } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java index 768efc8b..66419a5a 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java @@ -12,6 +12,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.UUID; import org.junit.jupiter.api.DisplayName; @@ -25,11 +26,13 @@ import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntityCompositeKey; +import com.decathlon.idp_core.domain.model.entity.Property; import com.decathlon.idp_core.domain.model.entity.Relation; import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphRelation; import com.decathlon.idp_core.domain.port.EntityGraphRepositoryPort; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; @ExtendWith(MockitoExtension.class) @DisplayName("EntityGraphService Tests") @@ -41,6 +44,9 @@ class EntityGraphServiceTest { @Mock private EntityGraphRepositoryPort entityGraphRepositoryPort; + @Mock + private EntityTemplateValidationService entityTemplateValidationService; + @InjectMocks private EntityGraphService entityGraphService; @@ -86,8 +92,8 @@ void shouldThrowWhenRootEntityNotFound() { when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "missing")) .thenReturn(Optional.empty()); - assertThatThrownBy(() -> entityGraphService.getEntityGraph(TEMPLATE, "missing", 1, false)) - .isInstanceOf(EntityNotFoundException.class); + assertThatThrownBy(() -> entityGraphService.getEntityGraph(TEMPLATE, "missing", 1, false, + Set.of(), Set.of())).isInstanceOf(EntityNotFoundException.class); verify(entityGraphRepositoryPort, never()).findEntityGraph(anyString(), anyString(), anyInt(), anyBoolean()); @@ -107,7 +113,8 @@ void shouldReturnLeafNodeWhenNoRelations() { .thenReturn(Optional.of(api)); stubGraph(Map.of(key(TEMPLATE, "api"), api)); - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, + Set.of(), Set.of()); assertThat(result.identifier()).isEqualTo("api"); assertThat(result.name()).isEqualTo("API Service"); @@ -132,7 +139,8 @@ void shouldResolveOutboundRelations() { .thenReturn(Optional.of(api)); stubGraph(Map.of(key(TEMPLATE, "api"), api, key("database", "postgres"), postgres)); - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, + Set.of(), Set.of()); assertThat(result.relations()).hasSize(1); assertThat(result.relations().get(0).name()).isEqualTo("uses-db"); @@ -150,7 +158,8 @@ void shouldReturnFallbackNodeWhenTargetNotInMap() { .thenReturn(Optional.of(api)); stubGraph(Map.of(key(TEMPLATE, "api"), api)); - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, + Set.of(), Set.of()); assertThat(result.relations()).hasSize(1); EntityGraphNode fallback = result.relations().get(0).targets().get(0); @@ -174,7 +183,8 @@ void shouldResolveInboundRelations() { .thenReturn(Optional.of(api)); stubGraph(Map.of(key(TEMPLATE, "api"), api, key(TEMPLATE, "consumer"), consumer)); - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, + Set.of(), Set.of()); assertThat(result.relationsAsTarget()).hasSize(1); assertThat(result.relationsAsTarget().get(0).name()).isEqualTo("depends-on"); @@ -196,7 +206,7 @@ void shouldClampDepthBelowOne() { .thenReturn(Optional.of(api)); stubGraph(Map.of(key(TEMPLATE, "api"), api)); - entityGraphService.getEntityGraph(TEMPLATE, "api", 0, false); + entityGraphService.getEntityGraph(TEMPLATE, "api", 0, false, Set.of(), Set.of()); verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "api", 1, false); } @@ -209,7 +219,7 @@ void shouldClampDepthAboveTen() { .thenReturn(Optional.of(api)); stubGraph(Map.of(key(TEMPLATE, "api"), api)); - entityGraphService.getEntityGraph(TEMPLATE, "api", 99, false); + entityGraphService.getEntityGraph(TEMPLATE, "api", 99, false, Set.of(), Set.of()); verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "api", 10, false); } @@ -234,7 +244,8 @@ void shouldReturnLeafNodeAtDepthBoundary() { stubGraph(Map.of(key(TEMPLATE, "api"), api, key("database", "postgres"), postgres, key("infra", "server-1"), server)); - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, + Set.of(), Set.of()); EntityGraphNode postgresNode = result.relations().get(0).targets().get(0); assertThat(postgresNode.identifier()).isEqualTo("postgres"); @@ -262,7 +273,8 @@ void shouldResolveMultipleNamedRelations() { stubGraph(Map.of(key(TEMPLATE, "api"), api, key("database", "postgres"), postgres, key(TEMPLATE, "auth"), auth)); - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, + Set.of(), Set.of()); assertThat(result.relations()).hasSize(2); assertThat(result.relations().stream().map(EntityGraphRelation::name)) @@ -272,37 +284,133 @@ void shouldResolveMultipleNamedRelations() { // ======================== @Nested - @DisplayName("Full Graph Returned — Filtering Is a Mapper Concern") - class FullGraphReturned { + @DisplayName("Relation Filtering") + class RelationFiltering { @Test - @DisplayName("Should return all edges regardless of relation type (no filtering in service)") - void shouldReturnAllEdgesWithoutFiltering() { - // A --(depends-on)--> B --(owns)--> C - // The service must return both edges — the mapper will filter them. + @DisplayName("Should include only relations matching the relation filter") + void shouldFilterRelationsByName() { + // A --(depends-on)--> B, A --(owns)--> C; filter keeps only 'depends-on' Entity a = entityWithRelations(TEMPLATE, "a", "A", - List.of(relation("depends-on", TEMPLATE, "b"))); - Entity b = entityWithRelations(TEMPLATE, "b", "B", List.of(relation("owns", TEMPLATE, "c"))); + List.of(relation("depends-on", TEMPLATE, "b"), relation("owns", TEMPLATE, "c"))); + Entity b = entity(TEMPLATE, "b", "B"); Entity c = entity(TEMPLATE, "c", "C"); when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "a")) .thenReturn(Optional.of(a)); stubGraph(Map.of(key(TEMPLATE, "a"), a, key(TEMPLATE, "b"), b, key(TEMPLATE, "c"), c)); - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 2, false); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 2, false, + Set.of("depends-on"), Set.of()); - // Root A has one outbound "depends-on" edge → B assertThat(result.relations()).hasSize(1); assertThat(result.relations().get(0).name()).isEqualTo("depends-on"); + } - // B (at depth 1) has one outbound "owns" edge → C - EntityGraphNode nodeB = result.relations().get(0).targets().get(0); - assertThat(nodeB.identifier()).isEqualTo("b"); - assertThat(nodeB.relations()).hasSize(1); - assertThat(nodeB.relations().get(0).name()).isEqualTo("owns"); - assertThat(nodeB.relations().get(0).targets().get(0).identifier()).isEqualTo("c"); + @Test + @DisplayName("Should return all relations when relation filter is empty") + void shouldReturnAllRelationsWhenFilterIsEmpty() { + Entity a = entityWithRelations(TEMPLATE, "a", "A", + List.of(relation("depends-on", TEMPLATE, "b"), relation("owns", TEMPLATE, "c"))); + Entity b = entity(TEMPLATE, "b", "B"); + Entity c = entity(TEMPLATE, "c", "C"); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "a")) + .thenReturn(Optional.of(a)); + stubGraph(Map.of(key(TEMPLATE, "a"), a, key(TEMPLATE, "b"), b, key(TEMPLATE, "c"), c)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 2, false, Set.of(), + Set.of()); + + assertThat(result.relations()).hasSize(2); + assertThat(result.relations().stream().map(EntityGraphRelation::name)) + .containsExactlyInAnyOrder("depends-on", "owns"); + } + + @Test + @DisplayName("Should filter inbound relations by name") + void shouldFilterInboundRelationsByName() { + Entity api = entity(TEMPLATE, "api", "API Service"); + Entity consumer = entityWithRelations(TEMPLATE, "consumer", "Consumer", + List.of(relation("depends-on", TEMPLATE, "api"))); + Entity unrelated = entityWithRelations(TEMPLATE, "unrelated", "Unrelated", + List.of(relation("owns", TEMPLATE, "api"))); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api, key(TEMPLATE, "consumer"), consumer, + key(TEMPLATE, "unrelated"), unrelated)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, + Set.of("depends-on"), Set.of()); + + assertThat(result.relationsAsTarget()).hasSize(1); + assertThat(result.relationsAsTarget().get(0).name()).isEqualTo("depends-on"); + } + } + + // ======================== + @Nested + @DisplayName("Property Filtering") + class PropertyFiltering { + + private Entity entityWithProperties(String templateIdentifier, String identifier, String name, + List properties) { + return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, properties, + List.of()); + } + + @Test + @DisplayName("Should include only properties matching the property filter") + void shouldFilterPropertiesByName() { + var propEnv = new Property(UUID.randomUUID(), "env", "prod"); + var propOwner = new Property(UUID.randomUUID(), "owner", "team-a"); + Entity api = entityWithProperties(TEMPLATE, "api", "API Service", + List.of(propEnv, propOwner)); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, true, Set.of(), + Set.of("env")); + + assertThat(result.properties()).hasSize(1); + assertThat(result.properties().get(0).name()).isEqualTo("env"); + } + + @Test + @DisplayName("Should return all properties when property filter is empty") + void shouldReturnAllPropertiesWhenFilterIsEmpty() { + var propEnv = new Property(UUID.randomUUID(), "env", "prod"); + var propOwner = new Property(UUID.randomUUID(), "owner", "team-a"); + Entity api = entityWithProperties(TEMPLATE, "api", "API Service", + List.of(propEnv, propOwner)); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, true, Set.of(), + Set.of()); + + assertThat(result.properties()).hasSize(2); + } + + @Test + @DisplayName("Should return empty properties when includeProperties is false regardless of filter") + void shouldReturnEmptyPropertiesWhenIncludePropertiesIsFalse() { + var propEnv = new Property(UUID.randomUUID(), "env", "prod"); + Entity api = entityWithProperties(TEMPLATE, "api", "API Service", List.of(propEnv)); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, + Set.of(), Set.of("env")); - verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "a", 2, false); + assertThat(result.properties()).isEmpty(); } } @@ -327,7 +435,8 @@ void shouldNotExplodeAtMaxDepthWithSmallGraph() { // Must complete instantly — any OOM or StackOverflow here means the guard is // missing. - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 10, false); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 10, false, Set.of(), + Set.of()); assertThat(result.identifier()).isEqualTo("a"); assertThat(result.relations()).hasSize(1); @@ -344,7 +453,8 @@ void shouldReturnStubLeafForRevisitedNode() { .thenReturn(Optional.of(a)); stubGraph(Map.of(key(TEMPLATE, "a"), a, key(TEMPLATE, "b"), b)); - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 5, false); + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 5, false, Set.of(), + Set.of()); // A → B is resolved assertThat(result.relations()).hasSize(1); From 1929d865d1c6caaed568912a403e55385eb63145 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Fri, 29 May 2026 16:19:54 +0200 Subject: [PATCH 26/53] feat(core): add a entity graph service and endpoint --- ...EntityTemplateIdentifierCannotChangeException.java | 1 - .../idp_core/domain/model/entity/Property.java | 4 ++-- .../persistence/repository/JpaEntityRepository.java | 11 +++++------ 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateIdentifierCannotChangeException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateIdentifierCannotChangeException.java index cd31885b..b6bb0020 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateIdentifierCannotChangeException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateIdentifierCannotChangeException.java @@ -6,7 +6,6 @@ import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler; - /// Exception thrown when attempting to change an [EntityTemplate] identifier after creation. /// /// **Why this exception exists:** diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java index 0ae129d5..c71c02e9 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java @@ -4,12 +4,12 @@ import java.util.UUID; +import jakarta.validation.constraints.NotBlank; + import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; import com.decathlon.idp_core.domain.model.entity_template.PropertyRules; import com.decathlon.idp_core.domain.model.enums.PropertyType; -import jakarta.validation.constraints.NotBlank; - /// A concrete property instance belonging to an [Entity]. /// /// Represents actual business data values that conform to the constraints diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java index 762f327f..1ddc3bba 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java @@ -163,15 +163,14 @@ void deletePropertiesByTemplateIdentifierAndPropertyName( @Modifying(clearAutomatically = true, flushAutomatically = true) @Query(""" - DELETE FROM PropertyJpaEntity p - WHERE p IN ( - SELECT p2 FROM EntityJpaEntity e JOIN e.properties p2 + DELETE FROM RelationJpaEntity r + WHERE r IN ( + SELECT r2 FROM EntityJpaEntity e JOIN e.relations r2 WHERE e.templateIdentifier = :templateIdentifier - AND p2.name IN :propertyNames + AND r2.name IN :relationNames ) """) - - void deleteRelationsByTemplateIdentifierAndRelationName( + void deleteRelationsByTemplateIdentifierAndRelationName( @Param("templateIdentifier") String templateIdentifier, @Param("relationNames") Collection relationNames); } From ad8296b1661dc7f5330eb9b4d9d0590a1479a935 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Fri, 29 May 2026 16:28:50 +0200 Subject: [PATCH 27/53] feat(core): add a entity graph service and endpoint --- .github/instructions/domain.instructions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/instructions/domain.instructions.md b/.github/instructions/domain.instructions.md index e1f34a6a..8b5a8727 100644 --- a/.github/instructions/domain.instructions.md +++ b/.github/instructions/domain.instructions.md @@ -211,4 +211,4 @@ domain/ - Organize exceptions by aggregate/subdomain (for example, `entity/`, `entity_template/`, `property/`) - Each exception class should have a clear, descriptive name that follows the naming conventions above -- Keep exception hierarchy flat — avoid deep inheritance trees +- Keep exception hierarchy flat. Avoid deep inheritance trees From 5f7d99118a9b4dee81b7300bafa109ec0b4ced06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Tue, 2 Jun 2026 10:26:36 +0200 Subject: [PATCH 28/53] feat(core): add a entity graph service and endpoint --- .../InvalidEntityCompositeKeyException.java | 28 ++++++ ...mplateIdentifierCannotChangeException.java | 4 - .../model/entity/EntityCompositeKey.java | 4 +- .../entity_graph/EntityGraphRelation.java | 1 - .../port/EntityGraphRepositoryPort.java | 5 - .../api/controller/EntityGraphController.java | 2 + .../api/handler/ApiExceptionHandler.java | 94 +++++++++++-------- .../repository/JpaEntityRepository.java | 8 +- .../test/R__2_Insert_entities_test_data.sql | 5 +- 9 files changed, 92 insertions(+), 59 deletions(-) create mode 100644 src/main/java/com/decathlon/idp_core/domain/exception/entity/InvalidEntityCompositeKeyException.java diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity/InvalidEntityCompositeKeyException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity/InvalidEntityCompositeKeyException.java new file mode 100644 index 00000000..737db865 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity/InvalidEntityCompositeKeyException.java @@ -0,0 +1,28 @@ +package com.decathlon.idp_core.domain.exception.entity; + +/// Exception thrown when an entity composite key format is invalid. +/// +/// Composite keys must follow the format "templateIdentifier:identifier" +/// where both parts are non-empty strings separated by a single colon. +/// +/// **Business context:** +/// - Composite keys are used to uniquely identify entities across templates +/// - The same identifier can exist in multiple templates, so both parts are required +/// - This exception indicates a malformed key that cannot be parsed +/// +/// **HTTP mapping:** 400 Bad Request (client error — invalid input format) +public class InvalidEntityCompositeKeyException extends RuntimeException { + + private final String invalidKey; + + public InvalidEntityCompositeKeyException(String invalidKey) { + super(String.format( + "Invalid entity composite key format: '%s'. Expected format: 'templateIdentifier:identifier'", + invalidKey)); + this.invalidKey = invalidKey; + } + + public String getInvalidKey() { + return invalidKey; + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateIdentifierCannotChangeException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateIdentifierCannotChangeException.java index b6bb0020..c273dbe2 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateIdentifierCannotChangeException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateIdentifierCannotChangeException.java @@ -2,10 +2,6 @@ import static com.decathlon.idp_core.domain.constant.ValidationMessages.TEMPLATE_IDENTIFIER_CANNOT_CHANGE; -import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; -import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; -import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler; - /// Exception thrown when attempting to change an [EntityTemplate] identifier after creation. /// /// **Why this exception exists:** diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityCompositeKey.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityCompositeKey.java index db38bde6..9449d5c6 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityCompositeKey.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityCompositeKey.java @@ -2,6 +2,8 @@ import java.util.Objects; +import com.decathlon.idp_core.domain.exception.entity.InvalidEntityCompositeKeyException; + /** * Composite key for uniquely identifying an entity across templates. Since the * same identifier can exist in different templates, we need both fields. @@ -10,7 +12,7 @@ public record EntityCompositeKey(String templateIdentifier, String identifier) { public static EntityCompositeKey fromString(String compositeKey) { String[] parts = compositeKey.split(":", 2); if (parts.length != 2) { - throw new IllegalArgumentException("Invalid composite key format: " + compositeKey); + throw new InvalidEntityCompositeKeyException(compositeKey); } return new EntityCompositeKey(parts[0], parts[1]); } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphRelation.java b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphRelation.java index e9b25fee..4f3e978f 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphRelation.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphRelation.java @@ -9,7 +9,6 @@ /// - Enables graph traversal by relation type /// /// @param name the relation name as defined in the entity template -/// @param targetTemplateIdentifier the template identifier of the target entities /// @param targets the resolved target entity graph nodes (recursively populated up to depth) public record EntityGraphRelation(String name, List targets) { public EntityGraphRelation { diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java index b081a33c..d5b21cb4 100644 --- a/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java @@ -34,11 +34,6 @@ public interface EntityGraphRepositoryPort { /// its template /// @param depth the maximum traversal depth (1-10) /// @param includeProperties when true, entity properties are loaded along with - /// relations; - /// when false, only relations are fetched for a leaner query - /// @param relationNames when non-empty, only edges whose relation name is in - /// this set are - /// traversed; when empty, all relation types are followed /// @return map of [EntityCompositeKey] to [Entity] for O(1) lookup; empty if /// root not found /// Relation name filtering is intentionally NOT pushed into this port. diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java index 5aa6a68e..d5b340cd 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java @@ -17,6 +17,7 @@ import jakarta.validation.constraints.NotBlank; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; @@ -46,6 +47,7 @@ @RestController @RequestMapping("/api/v1/entities") @RequiredArgsConstructor +@Validated @Tag(name = "Entity Graph", description = "Entity relationship graph operations") public class EntityGraphController { diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java index 75929d6f..b58da72e 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 @@ -22,6 +22,7 @@ import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; +import com.decathlon.idp_core.domain.exception.entity.InvalidEntityCompositeKeyException; import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateAlreadyExistsException; import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateIdentifierCannotChangeException; import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNameAlreadyExistsException; @@ -39,11 +40,13 @@ import lombok.NoArgsConstructor; import lombok.extern.slf4j.Slf4j; -/// Global exception handler providing centralized error handling for all API endpoints. +/// Global exception handler providing centralized error handling for all API +/// endpoints. /// -/// **Infrastructure error handling strategy:** Intercepts domain and validation exceptions -/// and converts them to appropriate HTTP responses with consistent error formatting. -/// Ensures API consumers receive standardized error messages regardless of internal failures. +/// **Infrastructure error handling strategy:** Intercepts domain and validation +/// exceptions and converts them to appropriate HTTP responses with consistent +/// error formatting. Ensures API consumers receive standardized error messages +/// regardless of internal failures. /// /// **Exception mapping approach:** /// - Domain exceptions → HTTP 404/409 with business-meaningful messages @@ -51,8 +54,9 @@ /// - JSON parsing errors → HTTP 400 with user-friendly parsing messages /// - Generic exceptions → HTTP 500 with safe internal error responses /// -/// **Error response standardization:** All errors follow consistent [ErrorResponse] format -/// with appropriate HTTP status codes and logged for monitoring/debugging purposes. +/// **Error response standardization:** All errors follow consistent +/// [ErrorResponse] format with appropriate HTTP status codes and logged for +/// monitoring/debugging purposes. @Slf4j @ControllerAdvice public class ApiExceptionHandler { @@ -63,8 +67,7 @@ private ApiExceptionHandler() { /// Handles domain exception when entity templates are not found. /// /// **HTTP mapping:** Maps domain EntityTemplateNotFoundException to HTTP 404 - /// status - /// with business-meaningful error message for API consumers. + /// status with business-meaningful error message for API consumers. @ExceptionHandler(EntityTemplateNotFoundException.class) public ResponseEntity handleTemplateNotFoundException( EntityTemplateNotFoundException ex) { @@ -76,8 +79,8 @@ public ResponseEntity handleTemplateNotFoundException( /// Handles domain exception for malformed filter query strings. /// /// **HTTP mapping:** Maps domain [InvalidQueryDslException] to HTTP 400 Bad - /// Request - /// so API consumers receive clear feedback about invalid `q` parameter syntax. + /// Request so API consumers receive clear feedback about invalid `q` + /// parameter syntax. @ExceptionHandler(InvalidQueryDslException.class) public ResponseEntity handleInvalidQueryDslException(InvalidQueryDslException ex) { log.warn("Invalid filter query: {}", ex.getMessage()); @@ -87,8 +90,7 @@ public ResponseEntity handleInvalidQueryDslException(InvalidQuery /// Handles domain exception when entity templates already exist. /// /// **HTTP mapping:** Maps domain EntityTemplateAlreadyExistsException to HTTP - /// 409 - /// status indicating business rule conflict for duplicate identifiers. + /// 409 status indicating business rule conflict for duplicate identifiers. @ExceptionHandler(EntityTemplateAlreadyExistsException.class) public ResponseEntity handleEntityTemplateAlreadyExistsException( EntityTemplateAlreadyExistsException ex) { @@ -100,8 +102,8 @@ public ResponseEntity handleEntityTemplateAlreadyExistsException( /// Handles domain exception when entity template names already exist. /// /// **HTTP mapping:** Maps domain EntityTemplateNameAlreadyExistsException to - /// HTTP 409 - /// status indicating business rule conflict for duplicate template names. + /// HTTP 409 status indicating business rule conflict for duplicate + /// template names. @ExceptionHandler(EntityTemplateNameAlreadyExistsException.class) public ResponseEntity handleEntityTemplateNameAlreadyExistsException( EntityTemplateNameAlreadyExistsException ex) { @@ -114,8 +116,8 @@ public ResponseEntity handleEntityTemplateNameAlreadyExistsExcept /// identifier. /// /// **HTTP mapping:** Maps domain EntityTemplateIdentifierCannotChangeException - /// to HTTP 400 - /// status indicating validation error for immutable identifier field. + /// to HTTP 400 status indicating validation error for immutable + /// identifier field. @ExceptionHandler(EntityTemplateIdentifierCannotChangeException.class) public ResponseEntity handleEntityTemplateIdentifierCannotChangeException( EntityTemplateIdentifierCannotChangeException ex) { @@ -127,8 +129,7 @@ public ResponseEntity handleEntityTemplateIdentifierCannotChangeE /// Handles domain exception for wrong entity template property rules. /// /// **HTTP mapping:** Maps domain PropertyDefinitionRulesConflictException to - /// HTTP 400 - /// status indicating validation error for wrong property rules. + /// HTTP 400 status indicating validation error for wrong property rules. @ExceptionHandler(PropertyDefinitionRulesConflictException.class) public ResponseEntity handleWrongPropertyRulesException( PropertyDefinitionRulesConflictException ex) { @@ -175,8 +176,8 @@ public ResponseEntity handleTargetTemplateNotFoundException( /// Handles domain exception when type changes are attempted. /// - /// **HTTP mapping:** Maps domain PropertyTypeChangeException to HTTP 400 - /// status indicating validation error for type changes. + /// **HTTP mapping:** Maps domain PropertyTypeChangeException to HTTP 400 status + /// indicating validation error for type changes. @ExceptionHandler(PropertyTypeChangeException.class) public ResponseEntity handleTypeChangeException(PropertyTypeChangeException ex) { log.warn("Type change error: {}", ex.getMessage()); @@ -187,8 +188,7 @@ public ResponseEntity handleTypeChangeException(PropertyTypeChang /// attempted. /// /// **HTTP mapping:** Maps domain RelationTargetTemplateChangeException to HTTP - /// 400 - /// status indicating validation error for immutable target template field. + /// 400 status indicating validation error for immutable target template field. @ExceptionHandler(RelationTargetTemplateChangeException.class) public ResponseEntity handleRelationTargetTemplateChangeException( RelationTargetTemplateChangeException ex) { @@ -212,8 +212,7 @@ public ResponseEntity handleRelationCannotTargetItselfException( /// Handles validation exceptions from Spring MVC handler method parameters. /// /// **Error aggregation:** Combines multiple validation error messages into a - /// single - /// user-friendly response with HTTP 400 status for client correction. + /// single user-friendly response with HTTP 400 status for client correction. @ExceptionHandler(HandlerMethodValidationException.class) public ResponseEntity handleHandlerMethodValidationException( HandlerMethodValidationException ex) { @@ -227,7 +226,8 @@ public ResponseEntity handleHandlerMethodValidationException( /// Handles domain exception when entities already exist. /// /// **HTTP mapping:** Maps domain EntityAlreadyExistsException to HTTP 409 - /// status indicating business rule conflict for duplicate entities. + /// status + /// indicating business rule conflict for duplicate entities. @ExceptionHandler(EntityAlreadyExistsException.class) public ResponseEntity handleEntityAlreadyExistsException( EntityAlreadyExistsException ex) { @@ -239,8 +239,7 @@ public ResponseEntity handleEntityAlreadyExistsException( /// Handles domain exception when entity validation fails. /// /// **HTTP mapping:** Maps domain EntityValidationException to HTTP 400 status - /// with aggregated - /// validation error messages for client correction. + /// with aggregated validation error messages for client correction. @ExceptionHandler(EntityValidationException.class) public ResponseEntity handleEntityValidationException( EntityValidationException ex) { @@ -248,18 +247,15 @@ public ResponseEntity handleEntityValidationException( return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); } - /// Handles Bean Validation constraint violations from domain model validation. + /// Handles domain exception when entity composite key format is invalid. /// - /// **Error aggregation:** Combines multiple constraint violation messages into - /// single user-friendly response with HTTP 400 status for client correction. - @ExceptionHandler(ConstraintViolationException.class) - public ResponseEntity handleConstraintViolationException( - ConstraintViolationException ex) { - log.warn("Validation constraint violation: {}", ex.getMessage()); - - String errorMessage = ex.getConstraintViolations().stream().map(ConstraintViolation::getMessage) - .collect(Collectors.joining(", ")); - return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage); + /// **HTTP mapping:** Maps domain InvalidEntityCompositeKeyException to HTTP 400 + /// status indicating client provided a malformed composite key string. + @ExceptionHandler(InvalidEntityCompositeKeyException.class) + public ResponseEntity handleInvalidEntityCompositeKeyException( + InvalidEntityCompositeKeyException ex) { + log.warn("Invalid entity composite key format: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); } /// Handles Spring MVC request body validation failures. @@ -294,12 +290,28 @@ public ResponseEntity handleHttpMessageNotReadableException( /// Handles domain exception when entities are not found. /// /// **HTTP mapping:** Maps domain EntityNotFoundException to HTTP 404 status - /// with specific entity context for API consumers. + /// with + /// specific entity context for API consumers. @ExceptionHandler(EntityNotFoundException.class) public ResponseEntity handleEntityNotFoundException(EntityNotFoundException ex) { ErrorResponse errorResponse = new ErrorResponse(NOT_FOUND.name(), ex.getMessage()); return ResponseEntity.status(NOT_FOUND).body(errorResponse); } + + /// Handles Bean Validation constraint violations from domain model validation. + /// + /// **Error aggregation:** Combines multiple constraint violation messages into + /// single user-friendly response with HTTP 400 status for client correction. + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleConstraintViolationException( + ConstraintViolationException ex) { + log.warn("Validation constraint violation: {}", ex.getMessage()); + + String errorMessage = ex.getConstraintViolations().stream().map(ConstraintViolation::getMessage) + .collect(Collectors.joining(", ")); + return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage); + } + private String parseHttpMessageNotReadableError(String originalMessage) { if (originalMessage == null) { return "Invalid request body format"; @@ -384,8 +396,8 @@ private String getPropertyNameFromEnumType(String message) { /// Handles all unexpected exceptions as safety fallback. /// /// **Security consideration:** Returns generic error message to prevent - /// information - /// leakage while logging full exception details for internal debugging. + /// information leakage while logging full exception details for + /// internal debugging. @ExceptionHandler(Exception.class) public ResponseEntity handleGenericException(Exception ex) { log.error("Unexpected error occurred: {}", ex.getMessage(), ex); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java index 1ddc3bba..bc832182 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java @@ -67,7 +67,7 @@ List findAllByIdentifierInWithProperties( JOIN entity_relations er ON er.entity_id = e.id JOIN relation r ON r.id = er.relation_id JOIN relation_target_entities rte ON rte.relation_id = r.id - JOIN entity e2 ON e2.identifier = rte.target_entity_identifier + JOIN entity e2 ON e2.identifier = rte.target_entity_identifier AND e2.template_identifier = r.target_template_identifier WHERE og.depth < :depth ), -- Traverse inbound relations (sources -> this entity as target) @@ -83,7 +83,7 @@ List findAllByIdentifierInWithProperties( FROM inbound_graph ig JOIN entity e ON e.identifier = ig.identifier AND e.template_identifier = ig.template_identifier JOIN relation_target_entities rte ON rte.target_entity_identifier = e.identifier - JOIN relation r ON r.id = rte.relation_id + JOIN relation r ON r.id = rte.relation_id AND r.target_template_identifier = e.template_identifier JOIN entity_relations er ON er.relation_id = r.id JOIN entity e2 ON e2.id = er.entity_id WHERE ig.depth < :depth @@ -117,7 +117,7 @@ List findEntityGraphIdentifiers(@Param("templateIdentifier") String te JOIN entity_relations er ON er.entity_id = e.id JOIN relation r ON r.id = er.relation_id JOIN relation_target_entities rte ON rte.relation_id = r.id - JOIN entity e2 ON e2.identifier = rte.target_entity_identifier + JOIN entity e2 ON e2.identifier = rte.target_entity_identifier AND e2.template_identifier = r.target_template_identifier WHERE og.depth < :depth AND r.name IN :relationNames ), @@ -133,7 +133,7 @@ List findEntityGraphIdentifiers(@Param("templateIdentifier") String te FROM inbound_graph ig JOIN entity e ON e.identifier = ig.identifier AND e.template_identifier = ig.template_identifier JOIN relation_target_entities rte ON rte.target_entity_identifier = e.identifier - JOIN relation r ON r.id = rte.relation_id + JOIN relation r ON r.id = rte.relation_id AND r.target_template_identifier = e.template_identifier JOIN entity_relations er ON er.relation_id = r.id JOIN entity e2 ON e2.id = er.entity_id WHERE ig.depth < :depth diff --git a/src/test/resources/db/test/R__2_Insert_entities_test_data.sql b/src/test/resources/db/test/R__2_Insert_entities_test_data.sql index 01dbafd5..5e84b05d 100644 --- a/src/test/resources/db/test/R__2_Insert_entities_test_data.sql +++ b/src/test/resources/db/test/R__2_Insert_entities_test_data.sql @@ -20,9 +20,6 @@ VALUES ('550e8400-e29b-41d4-a716-446655440113', 'monitoring-service-5', 'Monitoring Service 5', 'monitoring-service'), ('550e8400-e29b-41d4-a716-446655440114', 'monitoring-service-6', 'Monitoring Service 6', 'monitoring-service'); - --- Add to end of R__1_Insert_test_data.sql - -- ----------------------------------------------------------------------- -- Properties for query filter tests (web-api-1 and web-api-2) -- ----------------------------------------------------------------------- @@ -86,6 +83,8 @@ VALUES ('bb000000-0000-0000-0000-000000000003', 'microservice-1'); INSERT INTO entity_relations (entity_id, relation_id) VALUES ('550e8400-e29b-41d4-a716-446655440100', 'bb000000-0000-0000-0000-000000000003'); + + -- ----------------------------------------------------------------------- -- Graph test data: 3-level chain of entities connected via two relation -- types ("uses" and "monitors") for integration testing of the graph API. From 447cc84c5f56bebb14fac8759b418251145e6ff1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Fri, 5 Jun 2026 15:24:18 +0200 Subject: [PATCH 29/53] feat(core): add a entity graph service and endpoint --- pom.xml | 1 - .../port/EntityGraphRepositoryPort.java | 5 +- .../domain/service/entity/EntityService.java | 65 ++- .../entity_graph/EntityGraphService.java | 422 +++++++++++++----- .../PostgresEntityGraphAdapter.java | 61 ++- .../mapper/EntityPersistenceMapper.java | 133 +++++- .../model/entity/RelationJpaEntity.java | 5 + .../repository/JpaEntityRepository.java | 197 ++++---- src/main/resources/application-local.yml | 8 + .../V4_1__add_template_identifeir_indexes.sql | 7 + .../migration/V4_2__ad_relation_indexes.sql | 13 + .../V4_3__add_target_entities_uuids.sql | 9 + .../migration/V4_3_add_entity_uuid_index.sql | 2 + ...make_target_entity_identifier_nullable.sql | 38 ++ .../test/R__2_Insert_entities_test_data.sql | 20 +- 15 files changed, 727 insertions(+), 259 deletions(-) create mode 100644 src/main/resources/db/migration/V4_1__add_template_identifeir_indexes.sql create mode 100644 src/main/resources/db/migration/V4_2__ad_relation_indexes.sql create mode 100644 src/main/resources/db/migration/V4_3__add_target_entities_uuids.sql create mode 100644 src/main/resources/db/migration/V4_3_add_entity_uuid_index.sql create mode 100644 src/main/resources/db/migration/V4_4__make_target_entity_identifier_nullable.sql diff --git a/pom.xml b/pom.xml index a5e4c3e7..c983ea82 100644 --- a/pom.xml +++ b/pom.xml @@ -259,7 +259,6 @@ org.flywaydb flyway-maven-plugin - 9.12.0 diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java index d5b21cb4..bd662502 100644 --- a/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java @@ -1,6 +1,8 @@ package com.decathlon.idp_core.domain.port; +import java.util.List; import java.util.Map; +import java.util.UUID; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntityCompositeKey; @@ -40,6 +42,5 @@ public interface EntityGraphRepositoryPort { /// The CTE always traverses all relation types so that nodes reachable via /// any path are loaded. Edge filtering is applied in the service layer so /// that "filter owns" still returns B and C when A→(depends-on)→B→(owns)→C. - Map findEntityGraph(String templateIdentifier, - String entityIdentifier, int depth, boolean includeProperties); + Map findEntityGraph(UUID entityId, int depth, boolean includeProperties); } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java index efb1de25..3a2cc815 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 @@ -17,7 +17,9 @@ import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntityFilter; import com.decathlon.idp_core.domain.model.entity.EntitySummary; +import com.decathlon.idp_core.domain.model.entity.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.port.EntityRepositoryPort; import com.decathlon.idp_core.domain.service.EntityQueryParserService; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; @@ -57,8 +59,8 @@ public class EntityService { /// /// @param pageable pagination configuration for large entity sets /// @param templateIdentifier business identifier of the entity template - /// @param entityFilter the parsed query filter; null or [EntityFilter#empty()] - /// for no filtering + /// @param entityFilter the parsed query filter; null or + /// [EntityFilter#empty()] for no filtering /// @return paginated entities matching the template and all filter criteria /// @throws EntityTemplateNotFoundException when template doesn't exist @Transactional @@ -125,16 +127,20 @@ public Entity getEntityByTemplateIdentifierAndIdentifier(String templateIdentifi public Entity createEntity(@Valid Entity entity) { EntityTemplate template = entityTemplateService .getEntityTemplateByIdentifier(entity.templateIdentifier()); - entityValidationService.validateForCreation(entity, template); - return entityRepository.save(entity); + + // Enrich relations with target template identifiers from template definition + Entity enrichedEntity = enrichRelationsWithTargetTemplates(entity, template); + + entityValidationService.validateForCreation(enrichedEntity, template); + + return entityRepository.save(enrichedEntity); } /// Updates an existing entity identified by template and entity identifiers. /// /// **Contract:** Validates template existence, then entity existence within the /// template scope. Validates updated entity data against the template - /// constraints - /// before persisting changes. + /// constraints before persisting changes. /// /// @param templateIdentifier template identifier from the request path /// @param entityIdentifier entity identifier from the request path @@ -142,7 +148,8 @@ public Entity createEntity(@Valid Entity entity) { /// @return persisted updated entity /// @throws EntityTemplateNotFoundException when template doesn't exist /// @throws EntityNotFoundException when target entity doesn't exist - /// @throws EntityValidationException when payload violates template constraints + /// @throws EntityValidationException when payload violates + /// template constraints @Transactional public Entity updateEntity(String templateIdentifier, String entityIdentifier, @Valid Entity entity) { @@ -155,8 +162,48 @@ public Entity updateEntity(String templateIdentifier, String entityIdentifier, Entity entityToSave = new Entity(existingEntity.id(), templateIdentifier, entity.name(), entityIdentifier, entity.properties(), entity.relations()); - entityValidationService.validateForUpdate(entityToSave, template); - return entityRepository.save(entityToSave); + // Enrich relations with target template identifiers from template definition + Entity enrichedEntity = enrichRelationsWithTargetTemplates(entityToSave, template); + + entityValidationService.validateForUpdate(enrichedEntity, template); + return entityRepository.save(enrichedEntity); + } + + /// Enriches entity relations with target template identifiers from template + /// definition. + /// + /// **Business purpose:** Resolves target template identifiers for each relation + /// based on the relation name defined in the entity template. This allows the + /// API layer to accept minimalistic relation payloads (relation name + target + /// entity identifiers) while maintaining referential integrity at the domain + /// level. + /// + /// **Contract:** For each relation in the entity, looks up the corresponding + /// relation definition in the template and replaces the target template + /// identifier with the one specified in the template. Relations without + /// matching + /// definitions are left unchanged (validation will catch these later). + /// + /// @param entity the entity with relations to enrich + /// @param template the template containing relation definitions + /// @return new entity with enriched relations containing correct target + /// template identifiers + private Entity enrichRelationsWithTargetTemplates(Entity entity, EntityTemplate template) { + List enrichedRelations = entity.relations().stream().map(relation -> { + // Look up relation definition in template + RelationDefinition definition = template.relationsDefinitions().stream() + .filter(def -> def.name().equals(relation.name())).findFirst().orElse(null); + if (definition == null) { + // Leave unchanged - validation will catch undefined relations + return relation; + } + // Replace target template identifier with the one from template definition + return new Relation(relation.id(), relation.name(), definition.targetTemplateIdentifier(), + relation.targetEntityIdentifiers()); + }).toList(); + + return new Entity(entity.id(), entity.templateIdentifier(), entity.name(), entity.identifier(), + entity.properties(), enrichedRelations); } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java index b04d1efe..7dd2cb27 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java @@ -5,14 +5,17 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; +import java.util.UUID; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; import com.decathlon.idp_core.domain.model.entity.Entity; -import com.decathlon.idp_core.domain.model.entity.EntityCompositeKey; import com.decathlon.idp_core.domain.model.entity.Property; import com.decathlon.idp_core.domain.model.entity.Relation; import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; @@ -25,187 +28,374 @@ /// Domain service for building entity relationship graphs. /// -/// Resolves an entity's outbound and inbound relations recursively up to a configurable depth, -/// returning a tree of [EntityGraphNode] records containing summary information -/// for each connected entity. +/// Resolves an entity's outbound and inbound relations recursively up to a +/// configurable depth, returning a tree of [EntityGraphNode] records containing +/// summary information for each connected entity. /// /// **Business purpose:** /// - Visualizing entity dependency graphs in the catalog UI -/// - Understanding relationship chains (e.g., service → database → infrastructure) +/// - Understanding relationship chains (e.g., service → database → +/// infrastructure) /// - Providing hierarchical views for impact analysis and change propagation /// /// **Design decisions:** /// - Uses depth-limited traversal to prevent unbounded recursion /// - Optimized with recursive CTE and batch loading to minimize database queries -/// - A per-request `visitedNodeIds` set prevents exponential recursion: without it, -/// inbound relation scanning would re-expand already-visited nodes at every depth -/// level, producing O(2^depth) calls even for small graphs (OOM at depth ≥ 10). -/// - Relation and property filtering are domain concerns applied during graph construction, -/// so that callers (e.g. the REST controller) receive a graph that already respects -/// the requested scope instead of carrying unnecessary data to the Infrastructure layer. +/// - A per-request `visitedNodeIds` set prevents exponential recursion: without +/// it, inbound relation scanning would re-expand already-visited nodes at +/// every depth level, producing O(2^depth) calls even for small graphs (OOM at +/// depth ≥ 10). +/// - Relation and property filtering are domain concerns applied during graph +/// construction, so that callers (e.g. the REST controller) receive a graph +/// that already respects the requested scope instead of carrying unnecessary +/// data to the Infrastructure layer. @Service @RequiredArgsConstructor public class EntityGraphService { - private static final int MAX_DEPTH = 10; + private static final Logger log = LoggerFactory.getLogger(EntityGraphService.class); + private static final int MAX_DEPTH = 20; private final EntityRepositoryPort entityRepositoryPort; private final EntityGraphRepositoryPort entityGraphRepositoryPort; private final EntityTemplateValidationService entityTemplateValidationService; - /// Builds the relationship graph for an entity starting from its composite key. - /// - /// Relation and property filtering are applied here in the domain layer so that - /// callers receive a correctly scoped graph without needing to know about - /// filtering - /// logic. - /// - /// @param templateIdentifier the template identifier of the root entity - /// @param entityIdentifier the business identifier of the root entity - /// @param depth the maximum traversal depth (clamped to [1, MAX_DEPTH]) - /// @param includeProperties when true, each graph node carries the entity's - /// full property list (subject to propertyFilter) - /// @param relationFilter when non-empty, only relations whose name is in this - /// set are included in the graph; an empty set means no filter — all relations - /// are included - /// @param propertyFilter when non-empty, each node's property list is - /// restricted to properties whose name is in this set; an empty set means no - /// filter — all properties are included - /// @return the root graph node with all resolved (and filtered) relations - /// @throws EntityNotFoundException when no entity matches the given identifiers @Transactional(readOnly = true) public EntityGraphNode getEntityGraph(String templateIdentifier, String entityIdentifier, int depth, boolean includeProperties, Set relationFilter, Set propertyFilter) { - int effectiveDepth = Math.clamp(depth, 1, MAX_DEPTH); + final long tStartTotal = System.nanoTime(); + int effectiveDepth = Math.clamp(depth, 1, MAX_DEPTH); entityTemplateValidationService.validateTemplateExists(templateIdentifier); + // Resolve root entity and measure time + final long tStartResolve = System.nanoTime(); Entity rootEntity = entityRepositoryPort .findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); + final long tAfterResolve = System.nanoTime(); + // log.debug("[EntityGraph] Resolved root entity: id='{}' identifier='{}' + // template='{}' (elapsed={}ms)", rootEntity.id(), rootEntity.identifier(), + // rootEntity.templateIdentifier(), (tAfterResolve - tStartResolve) / + // 1_000_000); + + // Load entire graph chunk via optimized DB calls + final long tStartRepo = System.nanoTime(); + Map entityMap = entityGraphRepositoryPort.findEntityGraph(rootEntity.id(), + effectiveDepth, includeProperties); + final long tAfterRepo = System.nanoTime(); + + if (entityMap == null || entityMap.isEmpty()) { + log.debug( + "[EntityGraph] No entities returned from repository for root id='{}'. Returning single-node graph. (repoElapsed={}ms)", + rootEntity.id(), (tAfterRepo - tStartRepo) / 1_000_000); + final long tEndTotalEmpty = System.nanoTime(); + log.debug("[EntityGraph] getEntityGraph end (single-node): totalElapsed={}ms", + (tEndTotalEmpty - tStartTotal) / 1_000_000); + return new EntityGraphNode(rootEntity.id().toString(), rootEntity.identifier(), + rootEntity.name(), List.of(), List.of(), List.of()); + } + + log.debug( + "[EntityGraph] Repository returned {} entities for root id='{}' (includeProperties={}) repoElapsed={}ms", + entityMap.size(), rootEntity.id(), includeProperties, + (tAfterRepo - tStartRepo) / 1_000_000); + + // ------------------------------------------------------------------------- + // BULK PRE-COMPUTATION LAYER (Normalized for absolute string resilience) + // ------------------------------------------------------------------------- + final long tStartIndex = System.nanoTime(); + IndexBundle indices = buildIndices(entityMap); + final long tAfterIndex = System.nanoTime(); + + Map textToUuidLookup = indices.textToUuidLookup(); + Map>> inboundIndex = indices.inboundIndex(); + int inboundEntries = inboundIndex.values().stream() + .mapToInt(m -> m.values().stream().mapToInt(List::size).sum()).sum(); + // log.debug("[EntityGraph][Index] Built textToUuidLookup size={} + // inboundIndexRelations={} totalInboundSources={} (processed={}) + // indexElapsed={}ms", + // textToUuidLookup.size(), inboundIndex.size(), inboundEntries, + // entityMap.size(), (tAfterIndex - tStartIndex) / 1_000_000); - Map entityMap = entityGraphRepositoryPort - .findEntityGraph(templateIdentifier, entityIdentifier, effectiveDepth, includeProperties); + // Depth-Aware Tracker to prevent premature branch starvation + Map visitedDepths = new HashMap<>(); - EntityCompositeKey rootKey = new EntityCompositeKey(rootEntity.templateIdentifier(), - rootEntity.identifier()); + // Create context object to avoid long parameter lists when traversing + GraphTraversalContext ctx = new GraphTraversalContext(entityMap, textToUuidLookup, inboundIndex, + includeProperties, propertyFilter, relationFilter, visitedDepths); - // One shared visited set per request — each node is fully expanded at most - // once, - // preventing O(2^depth) recursion from mutual outbound/inbound re-expansion. - Set visitedNodeIds = new HashSet<>(); + // Trigger recursion passing our resilient indices via context + // log.debug("[EntityGraph] Starting recursive graph build from root id='{}' + // with depthBudget={}'", rootEntity.id(), effectiveDepth); + final long tStartRecursion = System.nanoTime(); + EntityGraphNode rootNode = buildGraphNode(rootEntity.id(), ctx, effectiveDepth); + final long tAfterRecursion = System.nanoTime(); + // log.debug("[EntityGraph] Completed recursive graph build for root id='{}'. + // recursionElapsed={}ms Visited {} nodes.", rootEntity.id(), (tAfterRecursion - + // tStartRecursion) / 1_000_000, visitedDepths.size()); - return buildGraphNode(rootKey, entityMap, effectiveDepth, includeProperties, relationFilter, - propertyFilter, visitedNodeIds); + // Log unvisited entities for diagnostics + logUnvisitedEntities(entityMap, visitedDepths); + + final long tEndTotal = System.nanoTime(); + log.debug( + "[EntityGraph] getEntityGraph end: totalElapsed={}ms (resolve={}ms repo={}ms index={}ms recursion={}ms)", + (tEndTotal - tStartTotal) / 1_000_000, (tAfterResolve - tStartResolve) / 1_000_000, + (tAfterRepo - tStartRepo) / 1_000_000, (tAfterIndex - tStartIndex) / 1_000_000, + (tAfterRecursion - tStartRecursion) / 1_000_000); + + return rootNode; } - /// Builds a graph node from a pre-loaded entity map (no database calls). - /// - /// [visitedNodeIds] tracks nodes that have already been fully built in this - /// traversal. - /// When a node is encountered again (cycle or shared reference), a stub leaf is - /// returned - /// immediately to cut the recursion — preventing the exponential blowup that - /// arises from - /// inbound scanning re-expanding the same nodes at every depth level. - private EntityGraphNode buildGraphNode(EntityCompositeKey key, - Map entityMap, int remainingDepth, boolean includeProperties, - Set relationFilter, Set propertyFilter, Set visitedNodeIds) { - Entity entity = entityMap.get(key); - if (entity == null) { - return new EntityGraphNode(key.templateIdentifier(), key.identifier(), key.identifier(), - List.of(), List.of(), List.of()); + /// Logs entities that were loaded but never visited during graph traversal. + /// This helps diagnose why the number of loaded entities exceeds the number of + /// output nodes. + private void logUnvisitedEntities(Map entityMap, + Map visitedDepths) { + Set visitedUuids = new HashSet<>(); + for (String uuidStr : visitedDepths.keySet()) { + try { + visitedUuids.add(UUID.fromString(uuidStr)); + } catch (IllegalArgumentException _) { + // Invalid UUID format, skip + } } - // Guard: return a stub leaf if this node was already fully built in another - // branch. - // This breaks both directed cycles (A→B→A) and shared references (A→B, C→B). - // Properties are still included so data is not silently dropped for shared - // nodes. - var nodeId = entity.templateIdentifier() + ":" + entity.identifier(); - if (!visitedNodeIds.add(nodeId)) { - List stubProperties = resolveProperties(entity, includeProperties, propertyFilter); - return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), - stubProperties, List.of(), List.of()); + List unvisitedEntities = entityMap.entrySet().stream() + .filter(entry -> !visitedUuids.contains(entry.getKey())).map(Map.Entry::getValue) + .filter(Objects::nonNull).toList(); + + if (!unvisitedEntities.isEmpty()) { + // log.info("[EntityGraph] Loaded {} entities, visited {} entities, {} entities + // were unreachable", + // entityMap.size(), visitedUuids.size(), unvisitedEntities.size()); + + // Group by template for better readability + Map> unvisitedByTemplate = new HashMap<>(); + for (Entity entity : unvisitedEntities) { + unvisitedByTemplate.computeIfAbsent(entity.templateIdentifier(), k -> new ArrayList<>()) + .add(entity.identifier()); + } + + // unvisitedByTemplate.forEach((template, identifiers) -> + // log.info(" Template '{}': {} entities - {}", + // template, identifiers.size(), + // identifiers.size() <= 10 ? identifiers : identifiers.subList(0, 10) + "... (" + // + (identifiers.size() - 10) + " more)" ) + // ); + } else { + log.info("[EntityGraph] All {} loaded entities were visited (100% reachability)", + entityMap.size()); + } + } + + private EntityGraphNode buildGraphNode(UUID entityUuid, Map entityMap, + Map textToUuidLookup, + Map>> inboundIndex, int remainingDepth, + boolean includeProperties, Set propertyFilter, Set relationFilter, + Map visitedDepths) { + // Note: This method signature is replaced by the Context-based overload below + // during refactor. + // Kept for backward-compatibility for the insert edit tool to apply minimal + // changes. + Entity entity = entityMap.get(entityUuid); + + var nodeIdDisplay = entityUuid != null ? entityUuid.toString() : "null-entity-"; + if (entity == null) { + log.trace("[EntityGraph][buildGraphNode] Missing entity for uuid='{}'. Returning empty node.", + nodeIdDisplay); + return new EntityGraphNode(nodeIdDisplay, nodeIdDisplay, nodeIdDisplay, List.of(), List.of(), + List.of()); } - // Depth exhausted — return a leaf with no relations but still carry properties - // so the deepest reachable entities expose their data when include_data=true. + log.trace("[EntityGraph][buildGraphNode] Enter node='{}' identifier='{}' remainingDepth={}", + entity.id(), entity.identifier(), remainingDepth); + + // Check depth budget exhaustion first if (remainingDepth <= 0) { + log.trace( + "[EntityGraph][buildGraphNode] Depth exhausted at node='{}'. Resolving leaf properties only.", + entity.identifier()); List leafProperties = resolveProperties(entity, includeProperties, propertyFilter); return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), leafProperties, List.of(), List.of()); } + // Depth-Aware Cycle Breaking Guard + var nodeId = entity.id().toString(); + Integer previousMaxDepthBudget = visitedDepths.get(nodeId); + + if (previousMaxDepthBudget != null && previousMaxDepthBudget >= remainingDepth) { + log.trace( + "[EntityGraph][buildGraphNode] Node '{}' already visited with equal or larger budget (prev={} curr={}). Returning stub.", + entity.identifier(), previousMaxDepthBudget, remainingDepth); + List stubProperties = resolveProperties(entity, includeProperties, propertyFilter); + return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), + stubProperties, List.of(), List.of()); + } + + visitedDepths.put(nodeId, remainingDepth); + + // Process outbound relationships List outboundRelations = entity.relations().stream() .filter(relation -> relationFilter.isEmpty() || relationFilter.contains(relation.name())) .map(relation -> new EntityGraphRelation(relation.name(), - relation.targetEntityIdentifiers().stream() - .map(targetId -> buildGraphNode(findKeyByIdentifier(targetId, entityMap), entityMap, - remainingDepth - 1, includeProperties, relationFilter, propertyFilter, - visitedNodeIds)) - .toList())) - .toList(); + relation.targetEntityIdentifiers().stream().map(targetId -> { + // Look up using normalized coordinates + UUID targetUuid = textToUuidLookup + .get(new EntityCompositeKey(relation.targetTemplateIdentifier(), targetId)); + if (targetUuid == null) + return null; + + return buildGraphNode(targetUuid, entityMap, textToUuidLookup, inboundIndex, + remainingDepth, includeProperties, propertyFilter, relationFilter, visitedDepths); + }).filter(Objects::nonNull).toList())) + .filter(rel -> !rel.targets().isEmpty()).toList(); + + log.trace("[EntityGraph][buildGraphNode] Node='{}' outboundRelations={} (after filtering)", + entity.identifier(), outboundRelations.size()); + + // Process inbound relationships + // Use the new context-based method to build inbound relations + GraphTraversalContext ctx = new GraphTraversalContext(entityMap, textToUuidLookup, inboundIndex, + includeProperties, propertyFilter, relationFilter, visitedDepths); + List inboundRelations = buildRelationsAsTargetFromIndex( + entity.identifier(), ctx, remainingDepth); - List inboundRelations = buildRelationsAsTargetFromMap(entity.identifier(), - entityMap, remainingDepth - 1, includeProperties, relationFilter, propertyFilter, - visitedNodeIds); + log.trace("[EntityGraph][buildGraphNode] Node='{}' inboundRelations={} (after index lookup)", + entity.identifier(), inboundRelations.size()); List properties = resolveProperties(entity, includeProperties, propertyFilter); + log.trace( + "[EntityGraph][buildGraphNode] Leaving node='{}' propertiesCount={} totalRelationsOut={} totalRelationsIn={}", + entity.identifier(), properties.size(), outboundRelations.size(), inboundRelations.size()); return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), properties, outboundRelations, inboundRelations); } - /// Looks up a composite key from the map by identifier alone. - /// Falls back to a synthetic key if no match is found (entity not in graph). - private EntityCompositeKey findKeyByIdentifier(String identifier, - Map entityMap) { - return entityMap.keySet().stream().filter(k -> k.identifier().equals(identifier)).findFirst() - .orElse(new EntityCompositeKey("", identifier)); - } + private List buildRelationsAsTargetFromIndex(String targetIdentifier, + GraphTraversalContext ctx, int remainingDepth) { + Map>> inboundIndex = ctx.inboundIndex(); + Set relationFilter = ctx.relationFilter(); - /// Builds incoming relations (where this entity is the target) from the - /// pre-loaded entity map. - /// Passes [visitedNodeIds] through so that source nodes already expanded - /// elsewhere are not - /// re-expanded here, preventing the mutual recursion that causes OOM at high - /// depths. - private List buildRelationsAsTargetFromMap(String targetIdentifier, - Map entityMap, int remainingDepth, boolean includeProperties, - Set relationFilter, Set propertyFilter, Set visitedNodeIds) { - Map> sourcesByRelationName = new HashMap<>(); - - for (Map.Entry entry : entityMap.entrySet()) { - Entity sourceEntity = entry.getValue(); - for (Relation relation : sourceEntity.relations()) { - if (relation.targetEntityIdentifiers().contains(targetIdentifier) - && (relationFilter.isEmpty() || relationFilter.contains(relation.name()))) { - sourcesByRelationName.computeIfAbsent(relation.name(), k -> new ArrayList<>()) - .add(entry.getKey()); - } - } + // Normalize the map query coordinate to guarantee matching across variations + String normalizedTargetIdentifier = targetIdentifier == null + ? "" + : targetIdentifier.trim().toLowerCase(); + Map> sourcesByRelationName = inboundIndex + .getOrDefault(normalizedTargetIdentifier, Map.of()); + + if (sourcesByRelationName.isEmpty()) { + log.trace( + "[EntityGraph][buildRelations] No inbound sources found for target='{}' (normalized='{}')", + targetIdentifier, normalizedTargetIdentifier); + return List.of(); } + log.trace("[EntityGraph][buildRelations] Found {} relation entries for target='{}'", + sourcesByRelationName.size(), targetIdentifier); + return sourcesByRelationName.entrySet().stream() - .map(e -> new EntityGraphRelation(e.getKey(), - e.getValue().stream() - .map(sourceKey -> buildGraphNode(sourceKey, entityMap, remainingDepth, - includeProperties, relationFilter, propertyFilter, visitedNodeIds)) - .toList())) - .toList(); + .filter(e -> relationFilter.isEmpty() || relationFilter.contains(e.getKey())).map(e -> { + log.trace( + "[EntityGraph][buildRelations] Processing inbound relation='{}' with {} sources for target='{}'", + e.getKey(), e.getValue().size(), targetIdentifier); + List targets = e.getValue().stream() + .map(sourceUuid -> buildGraphNode(sourceUuid, ctx, remainingDepth - 1)).toList(); + return new EntityGraphRelation(e.getKey(), targets); + }).toList(); } - /// Returns the entity's properties filtered by [propertyFilter] when active, - /// or an empty list when [includeProperties] is false. private List resolveProperties(Entity entity, boolean includeProperties, Set propertyFilter) { if (!includeProperties) { + log.trace( + "[EntityGraph][properties] Skipping property resolution for entity='{}' (includeProperties=false)", + entity == null ? "null" : entity.identifier()); return List.of(); } if (propertyFilter.isEmpty()) { + log.trace("[EntityGraph][properties] Including all properties for entity='{}' count={}", + entity.identifier(), entity.properties().size()); return entity.properties(); } - return entity.properties().stream().filter(p -> propertyFilter.contains(p.name())).toList(); + List resolved = entity.properties().stream() + .filter(p -> propertyFilter.contains(p.name())).toList(); + log.trace( + "[EntityGraph][properties] Resolved {} properties from filter size={} for entity='{}'", + resolved.size(), propertyFilter.size(), entity.identifier()); + return resolved; + } + + // New context and index bundle records to reduce parameter passing and cluster + // related state + private static record IndexBundle(Map textToUuidLookup, + Map>> inboundIndex) { + } + + private static record GraphTraversalContext(Map entityMap, + Map textToUuidLookup, + Map>> inboundIndex, boolean includeProperties, + Set propertyFilter, Set relationFilter, Map visitedDepths) { + } + + private IndexBundle buildIndices(Map entityMap) { + Map textToUuidLookup = new HashMap<>(); + Map>> inboundIndex = new HashMap<>(); + int processedEntities = 0; + for (Map.Entry entry : entityMap.entrySet()) { + UUID sourceUuid = entry.getKey(); + Entity entity = entry.getValue(); + processedEntities++; + if (entity == null) { + log.trace("[EntityGraph][Index] Skipping null entity for uuid='{}'", sourceUuid); + continue; + } + + // Build Index 1 (Automated normalization happens inside the record constructor + // below) + textToUuidLookup.put(new EntityCompositeKey(entity.templateIdentifier(), entity.identifier()), + sourceUuid); + + // Build Index 2 (Normalized to lowercase and trimmed to eliminate trailing + // space bugs) + for (Relation relation : entity.relations()) { + for (String targetId : relation.targetEntityIdentifiers()) { + if (targetId == null) + continue; + String normalizedTargetId = targetId.trim().toLowerCase(); + inboundIndex.computeIfAbsent(normalizedTargetId, k -> new HashMap<>()) + .computeIfAbsent(relation.name(), k -> new ArrayList<>()).add(sourceUuid); + } + log.trace("[EntityGraph][Index] Entity '{}' added relation '{}' with {} targets", + entity.identifier(), relation.name(), relation.targetEntityIdentifiers().size()); + } + + if (log.isTraceEnabled() && processedEntities % 500 == 0) { + log.trace("[EntityGraph][Index] Processed {} entities so far (current uuid='{}')", + processedEntities, sourceUuid); + } + } + + return new IndexBundle(textToUuidLookup, inboundIndex); + } + + // Context-based overload of buildGraphNode to reduce parameter count + private EntityGraphNode buildGraphNode(UUID entityUuid, GraphTraversalContext ctx, + int remainingDepth) { + return buildGraphNode(entityUuid, ctx.entityMap(), ctx.textToUuidLookup(), ctx.inboundIndex(), + remainingDepth, ctx.includeProperties(), ctx.propertyFilter(), ctx.relationFilter(), + ctx.visitedDepths()); + } +} + +// SOLUTION FIX: Enforced lowercase, trimmed normalization inside the +// constructor +record EntityCompositeKey(String templateIdentifier, String identifier) { + public EntityCompositeKey { + templateIdentifier = templateIdentifier == null ? "" : templateIdentifier.trim().toLowerCase(); + identifier = identifier == null ? "" : identifier.trim().toLowerCase(); } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java index c84c1ac7..f385e3ca 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java @@ -2,14 +2,16 @@ import java.util.List; import java.util.Map; +import java.util.UUID; import java.util.function.Function; import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import com.decathlon.idp_core.domain.model.entity.Entity; -import com.decathlon.idp_core.domain.model.entity.EntityCompositeKey; import com.decathlon.idp_core.domain.port.EntityGraphRepositoryPort; import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.EntityPersistenceMapper; import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.EntityJpaEntity; @@ -34,26 +36,40 @@ public class PostgresEntityGraphAdapter implements EntityGraphRepositoryPort { private final JpaEntityRepository jpaEntityRepository; private final EntityPersistenceMapper mapper; + private static final Logger log = LoggerFactory.getLogger(PostgresEntityGraphAdapter.class); + @Override @Transactional(readOnly = true) - public Map findEntityGraph(String templateIdentifier, - String entityIdentifier, int depth, boolean includeProperties) { + public Map findEntityGraph(UUID entityId, int depth, boolean includeProperties) { + log.debug( + "[EntityGraphAdapter] findEntityGraph start: entityId={}, depth={}, includeProperties={}", + entityId, depth, includeProperties); + final long tStartTotal = System.nanoTime(); + // Step 1: collect all (identifier, template_identifier) pairs via recursive // CTE. // The CTE always traverses ALL relation types to discover all reachable nodes. // Relation name filtering is applied at the service level when building edges, // so nodes reachable via any path are included even if the filter only matches // edges at deeper levels (e.g. filtering "owns" still returns B→C when A→B→C). - List graphPairs = jpaEntityRepository.findEntityGraphIdentifiers(templateIdentifier, - entityIdentifier, depth); + final long tStartCte = System.nanoTime(); + List graphPairs = jpaEntityRepository.findEntityGraphIdentifiers(entityId, depth); + final long tAfterCte = System.nanoTime(); + log.debug("[EntityGraphAdapter] CTE returned {} identifiers (elapsed={}ms)", + graphPairs == null ? 0 : graphPairs.size(), (tAfterCte - tStartCte) / 1_000_000); - if (graphPairs.isEmpty()) { + if (graphPairs == null || graphPairs.isEmpty()) { + log.debug( + "[EntityGraphAdapter] No graph identifiers found (null or empty), returning empty map"); return Map.of(); } // Step 2: extract unique identifiers for batch loading - List identifiers = graphPairs.stream().map(pair -> (String) pair[0]).distinct() - .toList(); + final long tStartIdExtract = System.nanoTime(); + List entitiesIds = graphPairs.stream().map(pair -> pair).distinct().toList(); + final long tAfterIdExtract = System.nanoTime(); + log.debug("[EntityGraphAdapter] Unique entity ids to load: {} (extraction elapsed={}ms)", + entitiesIds.size(), (tAfterIdExtract - tStartIdExtract) / 1_000_000); // Step 3: batch-load entities with relations, then optionally properties in a // separate @@ -61,14 +77,35 @@ public Map findEntityGraph(String templateIdentifier // round-trip and // keep payloads lean. The two-query split also avoids Hibernate's // MultipleBagFetchException. + log.debug("[EntityGraphAdapter] Loading JPA entities with relations..."); + final long tStartJpaLoad = System.nanoTime(); List jpaEntities = jpaEntityRepository - .findAllByIdentifierInWithRelations(identifiers); + .findAllByIdentifierInWithRelations(entitiesIds); + final long tAfterJpaLoad = System.nanoTime(); + log.debug("[EntityGraphAdapter] Loaded {} JPA entities with relations (elapsed={}ms)", + jpaEntities.size(), (tAfterJpaLoad - tStartJpaLoad) / 1_000_000); if (includeProperties) { - jpaEntityRepository.findAllByIdentifierInWithProperties(identifiers); + log.debug("[EntityGraphAdapter] Loading properties for {} entities", entitiesIds.size()); + final long tStartProps = System.nanoTime(); + List props = jpaEntityRepository.findAllByIdentifierInWithProperties(entitiesIds); + final long tAfterProps = System.nanoTime(); + log.debug( + "[EntityGraphAdapter] Properties load completed (result was {} entries, elapsed={}ms)", + props == null ? 0 : props.size(), (tAfterProps - tStartProps) / 1_000_000); } // Step 4: map to domain and key by composite key for O(1) lookup - return jpaEntities.stream().map(mapper::toDomain).collect(Collectors.toMap( - e -> new EntityCompositeKey(e.templateIdentifier(), e.identifier()), Function.identity())); + log.debug("[EntityGraphAdapter] Mapping JPA entities to domain models..."); + final long tStartMap = System.nanoTime(); + Map result = jpaEntities.stream().map(mapper::toDomain) + .collect(Collectors.toMap(Entity::id, Function.identity())); + final long tAfterMap = System.nanoTime(); + log.debug("[EntityGraphAdapter] Mapping completed, returning {} domain entities (elapsed={}ms)", + result.size(), (tAfterMap - tStartMap) / 1_000_000); + + final long tEndTotal = System.nanoTime(); + log.debug("[EntityGraphAdapter] findEntityGraph end: entityId={} totalElapsed={}ms", entityId, + (tEndTotal - tStartTotal) / 1_000_000); + return result; } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java index 40dbea1f..ee276d90 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java @@ -1,7 +1,10 @@ package com.decathlon.idp_core.infrastructure.adapters.persistence.mapper; -import org.mapstruct.Mapper; -import org.mapstruct.MappingConstants; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +import org.springframework.stereotype.Component; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.Property; @@ -9,19 +12,129 @@ import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.EntityJpaEntity; import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.PropertyJpaEntity; import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.RelationJpaEntity; +import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.JpaEntityRepository; + +import lombok.RequiredArgsConstructor; + +/// Custom mapper for Entity persistence layer. +/// +/// Handles conversion between domain models and JPA entities, including +/// identifier-to-UUID resolution for relations. Uses custom logic instead of +/// MapStruct because relation mapping requires repository lookups to convert +/// business identifiers to technical UUIDs and vice versa. +@Component +@RequiredArgsConstructor +public class EntityPersistenceMapper { + + private final JpaEntityRepository entityRepository; + + // ========================================================================= + // Entity Mapping + // ========================================================================= + + public Entity toDomain(EntityJpaEntity jpa) { + if (jpa == null) { + return null; + } + + List properties = jpa.getProperties() != null + ? jpa.getProperties().stream().map(this::toDomain).toList() + : List.of(); + + List relations = jpa.getRelations() != null + ? jpa.getRelations().stream().map(this::toDomain).toList() + : List.of(); + + return new Entity(jpa.getId(), jpa.getTemplateIdentifier(), jpa.getName(), jpa.getIdentifier(), + properties, relations); + } + + public EntityJpaEntity toJpa(Entity domain) { + if (domain == null) { + return null; + } + + List properties = domain.properties() != null + ? domain.properties().stream().map(this::toJpa).toList() + : List.of(); + + List relations = domain.relations() != null + ? domain.relations().stream().map(this::toJpa).toList() + : List.of(); + + return EntityJpaEntity.builder().id(domain.id()).templateIdentifier(domain.templateIdentifier()) + .name(domain.name()).identifier(domain.identifier()).properties(properties) + .relations(relations).build(); + } + + // ========================================================================= + // Property Mapping + // ========================================================================= + + public Property toDomain(PropertyJpaEntity jpa) { + if (jpa == null) { + return null; + } + + return new Property(jpa.getId(), jpa.getName(), jpa.getValue()); + } + + public PropertyJpaEntity toJpa(Property domain) { + if (domain == null) { + return null; + } + + return PropertyJpaEntity.builder().id(domain.id()).name(domain.name()).value(domain.value()) + .build(); + } -@Mapper(componentModel = MappingConstants.ComponentModel.SPRING) -public interface EntityPersistenceMapper { + // ========================================================================= + // Relation Mapping (with identifier ↔ UUID conversion) + // ========================================================================= - Entity toDomain(EntityJpaEntity jpa); + /// Converts JPA relation to domain model. + /// Resolves UUIDs back to business identifiers by querying the entity + /// repository. + public Relation toDomain(RelationJpaEntity jpa) { + if (jpa == null) { + return null; + } - EntityJpaEntity toJpa(Entity domain); + // Convert UUIDs back to business identifiers + List targetIdentifiers = jpa.getTargetEntityIds() != null + ? jpa.getTargetEntityIds().stream().map(uuidString -> { + try { + UUID uuid = uuidString; + return entityRepository.findById(uuid).map(EntityJpaEntity::getIdentifier).orElse(null); + } catch (IllegalArgumentException _) { + // Invalid UUID format, skip + return null; + } + }).filter(Objects::nonNull).toList() + : List.of(); - Property toDomain(PropertyJpaEntity jpa); + return new Relation(jpa.getId(), jpa.getName(), jpa.getTargetTemplateIdentifier(), + targetIdentifiers); + } - PropertyJpaEntity toJpa(Property domain); + /// Converts domain relation to JPA entity. + /// Resolves business identifiers to UUIDs by querying the entity repository. + /// The JPA entity stores only UUIDs; identifiers are not persisted in the + /// infrastructure layer. + public RelationJpaEntity toJpa(Relation domain) { + if (domain == null) { + return null; + } - Relation toDomain(RelationJpaEntity jpa); + // Convert business identifiers to UUIDs for storage + List targetUuids = domain.targetEntityIdentifiers() != null + ? domain.targetEntityIdentifiers().stream().map(identifier -> entityRepository + .findByTemplateIdentifierAndIdentifier(domain.targetTemplateIdentifier(), identifier) + .map(EntityJpaEntity::getId).orElse(null)).filter(Objects::nonNull).toList() + : List.of(); - RelationJpaEntity toJpa(Relation domain); + return RelationJpaEntity.builder().id(domain.id()).name(domain.name()) + .targetTemplateIdentifier(domain.targetTemplateIdentifier()).targetEntityIds(targetUuids) + .build(); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/RelationJpaEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/RelationJpaEntity.java index d135f1f2..087fecb0 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/RelationJpaEntity.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/RelationJpaEntity.java @@ -41,4 +41,9 @@ public class RelationJpaEntity { @CollectionTable(name = "relation_target_entities", joinColumns = @JoinColumn(name = "relation_id"), indexes = @Index(columnList = "relation_id")) @Column(name = "target_entity_identifier") private List targetEntityIdentifiers; + + @ElementCollection + @CollectionTable(name = "relation_target_entities", joinColumns = @JoinColumn(name = "relation_id"), indexes = @Index(columnList = "relation_id")) + @Column(name = "target_entity_uuid") + private List targetEntityIds; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java index bc832182..2ee7114f 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java @@ -40,113 +40,112 @@ Optional findByTemplateIdentifierAndIdentifier(String templateI /// properties. Uses two separate queries to avoid Hibernate's /// MultipleBagFetchException. First fetches entities with relations, then /// fetches properties separately. - @Query("SELECT DISTINCT e FROM EntityJpaEntity e LEFT JOIN FETCH e.relations WHERE e.identifier IN :identifiers") + @Query("SELECT DISTINCT e FROM EntityJpaEntity e LEFT JOIN FETCH e.relations WHERE e.id IN :identifiers") List findAllByIdentifierInWithRelations( - @Param("identifiers") Collection identifiers); + @Param("identifiers") Collection ids); /// Fetch properties for entities that were already loaded. This is called after /// findAllByIdentifierInWithRelations to complete the entity graph. - @Query("SELECT DISTINCT e FROM EntityJpaEntity e LEFT JOIN FETCH e.properties WHERE e.identifier IN :identifiers") + @Query("SELECT DISTINCT e FROM EntityJpaEntity e LEFT JOIN FETCH e.properties WHERE e.id IN :identifiers") List findAllByIdentifierInWithProperties( - @Param("identifiers") Collection identifiers); + @Param("identifiers") Collection ids); @Query(value = """ - WITH RECURSIVE - -- Traverse outbound relations (this entity -> targets) - outbound_graph(identifier, template_identifier, depth) AS ( - SELECT e.identifier, e.template_identifier, 0 - FROM entity e - WHERE e.identifier = :entityIdentifier - AND e.template_identifier = :templateIdentifier - - UNION ALL - - SELECT e2.identifier, e2.template_identifier, og.depth + 1 - FROM outbound_graph og - JOIN entity e ON e.identifier = og.identifier AND e.template_identifier = og.template_identifier - JOIN entity_relations er ON er.entity_id = e.id - JOIN relation r ON r.id = er.relation_id - JOIN relation_target_entities rte ON rte.relation_id = r.id - JOIN entity e2 ON e2.identifier = rte.target_entity_identifier AND e2.template_identifier = r.target_template_identifier - WHERE og.depth < :depth - ), - -- Traverse inbound relations (sources -> this entity as target) - inbound_graph(identifier, template_identifier, depth) AS ( - SELECT e.identifier, e.template_identifier, 0 - FROM entity e - WHERE e.identifier = :entityIdentifier - AND e.template_identifier = :templateIdentifier - - UNION ALL - - SELECT e2.identifier, e2.template_identifier, ig.depth + 1 - FROM inbound_graph ig - JOIN entity e ON e.identifier = ig.identifier AND e.template_identifier = ig.template_identifier - JOIN relation_target_entities rte ON rte.target_entity_identifier = e.identifier - JOIN relation r ON r.id = rte.relation_id AND r.target_template_identifier = e.template_identifier - JOIN entity_relations er ON er.relation_id = r.id - JOIN entity e2 ON e2.id = er.entity_id - WHERE ig.depth < :depth + WITH RECURSIVE entity_graph(id, depth) AS ( + -- 1. ANCHOR MEMBER: Start with your specific root entity UUID + SELECT CAST(:entityId AS UUID), 0 + + UNION -- Frontier propagation: automatically eliminates path duplicates at each step + + -- 2. RECURSIVE MEMBER: Scan indexed schema tables via direct binary matches + SELECT neighbor.id, eg.depth + 1 + FROM entity_graph eg + CROSS JOIN LATERAL ( + -- Track A: Outbound direction (this entity -> targets) + SELECT rte.target_entity_uuid AS id + FROM idp_core.entity_relations er + JOIN idp_core.relation_target_entities rte ON rte.relation_id = er.relation_id + WHERE er.entity_id = eg.id + AND rte.target_entity_uuid IS NOT NULL + + UNION ALL + + -- Track B: Inbound direction (sources -> this entity as target) + SELECT er.entity_id AS id + FROM idp_core.relation_target_entities rte + JOIN idp_core.entity_relations er ON er.relation_id = rte.relation_id + WHERE rte.target_entity_uuid = eg.id + ) neighbor + -- Keeps the depth bounded entirely at the database layer + WHERE eg.depth < :depth ) - SELECT DISTINCT identifier, template_identifier FROM outbound_graph - UNION - SELECT DISTINCT identifier, template_identifier FROM inbound_graph - """, nativeQuery = true) - List findEntityGraphIdentifiers(@Param("templateIdentifier") String templateIdentifier, - @Param("entityIdentifier") String entityIdentifier, @Param("depth") int depth); - - /// Variant of [findEntityGraphIdentifiers] that restricts traversal to the - /// given relation names. When the list is empty, all relation names are - /// followed - /// (no filter). The filter is applied inside both the outbound and inbound - /// recursive CTE steps so that only entities reachable through the specified - /// relations are returned, keeping the result set lean. - @Query(value = """ - WITH RECURSIVE - outbound_graph(identifier, template_identifier, depth) AS ( - SELECT e.identifier, e.template_identifier, 0 - FROM entity e - WHERE e.identifier = :entityIdentifier - AND e.template_identifier = :templateIdentifier - - UNION ALL - - SELECT e2.identifier, e2.template_identifier, og.depth + 1 - FROM outbound_graph og - JOIN entity e ON e.identifier = og.identifier AND e.template_identifier = og.template_identifier - JOIN entity_relations er ON er.entity_id = e.id - JOIN relation r ON r.id = er.relation_id - JOIN relation_target_entities rte ON rte.relation_id = r.id - JOIN entity e2 ON e2.identifier = rte.target_entity_identifier AND e2.template_identifier = r.target_template_identifier - WHERE og.depth < :depth - AND r.name IN :relationNames - ), - inbound_graph(identifier, template_identifier, depth) AS ( - SELECT e.identifier, e.template_identifier, 0 - FROM entity e - WHERE e.identifier = :entityIdentifier - AND e.template_identifier = :templateIdentifier - - UNION ALL - - SELECT e2.identifier, e2.template_identifier, ig.depth + 1 - FROM inbound_graph ig - JOIN entity e ON e.identifier = ig.identifier AND e.template_identifier = ig.template_identifier - JOIN relation_target_entities rte ON rte.target_entity_identifier = e.identifier - JOIN relation r ON r.id = rte.relation_id AND r.target_template_identifier = e.template_identifier - JOIN entity_relations er ON er.relation_id = r.id - JOIN entity e2 ON e2.id = er.entity_id - WHERE ig.depth < :depth - AND r.name IN :relationNames - ) - SELECT DISTINCT identifier, template_identifier FROM outbound_graph - UNION - SELECT DISTINCT identifier, template_identifier FROM inbound_graph - """, nativeQuery = true) - List findEntityGraphIdentifiersFilteredByRelations( - @Param("templateIdentifier") String templateIdentifier, - @Param("entityIdentifier") String entityIdentifier, @Param("depth") int depth, - @Param("relationNames") Collection relationNames); + -- 3. LEAN RETURN: Extract only the unique raw UUIDs discovered in the network skeleton + SELECT DISTINCT id FROM entity_graph; + """, nativeQuery = true) + // List findEntityGraphIdentifiers(@Param("templateIdentifier") String + // templateIdentifier, + // @Param("entityIdentifier") String entityIdentifier, @Param("depth") int + // depth + + List findEntityGraphIdentifiers(@Param("entityId") UUID entityId, + @Param("depth") int depth); + + // /// Variant of [findEntityGraphIdentifiers] that restricts traversal to the + // /// given relation names. When the list is empty, all relation names are + // /// followed + // /// (no filter). The filter is applied inside both the outbound and inbound + // /// recursive CTE steps so that only entities reachable through the specified + // /// relations are returned, keeping the result set lean. + // @Query(value = """ + // WITH RECURSIVE + // outbound_graph(identifier, template_identifier, depth) AS ( + // SELECT e.identifier, e.template_identifier, 0 + // FROM entity e + // WHERE e.identifier = :entityIdentifier + // AND e.template_identifier = :templateIdentifier + + // UNION ALL + + // SELECT e2.identifier, e2.template_identifier, og.depth + 1 + // FROM outbound_graph og + // JOIN entity e ON e.identifier = og.identifier AND e.template_identifier = + // og.template_identifier + // JOIN entity_relations er ON er.entity_id = e.id + // JOIN relation r ON r.id = er.relation_id + // JOIN relation_target_entities rte ON rte.relation_id = r.id + // JOIN entity e2 ON e2.identifier = rte.target_entity_identifier + // WHERE og.depth < :depth + // AND r.name IN :relationNames + // ), + // inbound_graph(identifier, template_identifier, depth) AS ( + // SELECT e.identifier, e.template_identifier, 0 + // FROM entity e + // WHERE e.identifier = :entityIdentifier + // AND e.template_identifier = :templateIdentifier + + // UNION ALL + + // SELECT e2.identifier, e2.template_identifier, ig.depth + 1 + // FROM inbound_graph ig + // JOIN entity e ON e.identifier = ig.identifier AND e.template_identifier = + // ig.template_identifier + // JOIN relation_target_entities rte ON rte.target_entity_identifier = + // e.identifier + // JOIN relation r ON r.id = rte.relation_id + // JOIN entity_relations er ON er.relation_id = r.id + // JOIN entity e2 ON e2.id = er.entity_id + // WHERE ig.depth < :depth + // AND r.name IN :relationNames + // ) + // SELECT DISTINCT identifier, template_identifier FROM outbound_graph + // UNION + // SELECT DISTINCT identifier, template_identifier FROM inbound_graph + // """, nativeQuery = true) + // List findEntityGraphIdentifiersFilteredByRelations( + // @Param("templateIdentifier") String templateIdentifier, + // @Param("entityIdentifier") String entityIdentifier, @Param("depth") int + // depth, + // @Param("relationNames") Collection relationNames); @Modifying(clearAutomatically = true, flushAutomatically = true) @Query(""" diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 5b32be0b..7235b23f 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -23,3 +23,11 @@ spring: app: full-refresh-at-startup: true idp-core-prefix-url: http://localhost:8084 +logging: + level: + # Application log level, defaults to INFO. Override per environment via LOG_LEVEL env var. + com.decathlon: ${LOG_LEVEL:DEBUG} + # Suppresses noisy Spring context startup logs at WARN level. + org.springframework.context.support: WARN + # Suppresses a specific repetitive bean registration delegate log line. + org.springframework.context.support.PostProcessorRegistrationDelegate: ERROR diff --git a/src/main/resources/db/migration/V4_1__add_template_identifeir_indexes.sql b/src/main/resources/db/migration/V4_1__add_template_identifeir_indexes.sql new file mode 100644 index 00000000..10ebdbc8 --- /dev/null +++ b/src/main/resources/db/migration/V4_1__add_template_identifeir_indexes.sql @@ -0,0 +1,7 @@ +CREATE INDEX idx_entity_graph_lookup +ON entity (template_identifier, identifier); + +-- 2. Fixes the Inbound bottleneck: +-- Speeds up reverse-searching who points to a specific target text string +CREATE INDEX idx_rte_inbound_lookup +ON relation_target_entities (target_entity_identifier); \ No newline at end of file diff --git a/src/main/resources/db/migration/V4_2__ad_relation_indexes.sql b/src/main/resources/db/migration/V4_2__ad_relation_indexes.sql new file mode 100644 index 00000000..50bb919d --- /dev/null +++ b/src/main/resources/db/migration/V4_2__ad_relation_indexes.sql @@ -0,0 +1,13 @@ +CREATE INDEX idx_rte_covering_outbound +ON relation_target_entities (relation_id) +INCLUDE (target_entity_identifier); + +-- 2. Optimized Covering Index for the Inbound Track +CREATE INDEX idx_rte_covering_inbound +ON relation_target_entities (target_entity_identifier) +INCLUDE (relation_id); + +-- 3. Composite covering index for the main Entity table lookups +CREATE INDEX idx_entity_covering_graph +ON entity (template_identifier, identifier) +INCLUDE (id, name); \ No newline at end of file diff --git a/src/main/resources/db/migration/V4_3__add_target_entities_uuids.sql b/src/main/resources/db/migration/V4_3__add_target_entities_uuids.sql new file mode 100644 index 00000000..1eca2565 --- /dev/null +++ b/src/main/resources/db/migration/V4_3__add_target_entities_uuids.sql @@ -0,0 +1,9 @@ +-- Step 1: Add new UUID column +ALTER TABLE relation_target_entities +ADD COLUMN target_entity_uuid UUID; + +-- Step 2: Populate UUIDs from existing identifiers +UPDATE relation_target_entities rte +SET target_entity_uuid = e.id +FROM entity e +WHERE e.identifier = rte.target_entity_identifier \ No newline at end of file diff --git a/src/main/resources/db/migration/V4_3_add_entity_uuid_index.sql b/src/main/resources/db/migration/V4_3_add_entity_uuid_index.sql new file mode 100644 index 00000000..d6174d9b --- /dev/null +++ b/src/main/resources/db/migration/V4_3_add_entity_uuid_index.sql @@ -0,0 +1,2 @@ +CREATE INDEX idx_rte_target_uuid_binary +ON relation_target_entities (target_entity_uuid); \ No newline at end of file diff --git a/src/main/resources/db/migration/V4_4__make_target_entity_identifier_nullable.sql b/src/main/resources/db/migration/V4_4__make_target_entity_identifier_nullable.sql new file mode 100644 index 00000000..c78737e2 --- /dev/null +++ b/src/main/resources/db/migration/V4_4__make_target_entity_identifier_nullable.sql @@ -0,0 +1,38 @@ +-- Transition to UUID-only storage for relation targets +-- Purpose: Remove dependency on composite (identifier + template) key and use UUID as primary storage + +-- Step 1: Drop the old composite primary key that includes identifier +ALTER TABLE relation_target_entities +DROP CONSTRAINT IF EXISTS relation_target_entities_pkey; + +-- Step 2: Ensure all existing rows have UUIDs populated (data migration) +-- UPDATE relation_target_entities rte +-- SET target_entity_uuid = e.id +-- FROM entity e +-- WHERE e.identifier = rte.target_entity_identifier +-- AND e.template_identifier = rte.target_template_identifier +-- AND rte.target_entity_uuid IS NULL; + +-- Step 3: Make target_entity_uuid NOT NULL (it's now the primary storage) +-- ALTER TABLE relation_target_entities +-- ALTER COLUMN target_entity_uuid SET NOT NULL; + +-- Step 4: Add new primary key using UUID only +ALTER TABLE relation_target_entities +ADD CONSTRAINT relation_target_entities_pkey +PRIMARY KEY (relation_id, target_entity_uuid); + +-- Step 5: Make target_entity_identifier nullable (preparing for future removal) +ALTER TABLE relation_target_entities +ALTER COLUMN target_entity_identifier DROP NOT NULL; + +-- Step 7: Add index on UUID for better query performance +CREATE INDEX IF NOT EXISTS idx_relation_target_uuid +ON relation_target_entities(target_entity_uuid); + +-- Step 8: Add foreign key constraint for referential integrity +ALTER TABLE relation_target_entities +ADD CONSTRAINT fk_relation_target_entity +FOREIGN KEY (target_entity_uuid) +REFERENCES entity(id) +ON DELETE CASCADE; diff --git a/src/test/resources/db/test/R__2_Insert_entities_test_data.sql b/src/test/resources/db/test/R__2_Insert_entities_test_data.sql index 5e84b05d..5ba0aa06 100644 --- a/src/test/resources/db/test/R__2_Insert_entities_test_data.sql +++ b/src/test/resources/db/test/R__2_Insert_entities_test_data.sql @@ -58,8 +58,8 @@ VALUES INSERT INTO relation (id, name, target_template_identifier) VALUES ('bb000000-0000-0000-0000-000000000001', 'database', 'database-service'); -INSERT INTO relation_target_entities (relation_id, target_entity_identifier) -VALUES ('bb000000-0000-0000-0000-000000000001', 'database-service-1'); +INSERT INTO relation_target_entities (relation_id, target_entity_uuid) +VALUES ('bb000000-0000-0000-0000-000000000001', '550e8400-e29b-41d4-a716-446655440107'); INSERT INTO entity_relations (entity_id, relation_id) VALUES ('550e8400-e29b-41d4-a716-446655440100', 'bb000000-0000-0000-0000-000000000001'); @@ -68,8 +68,8 @@ VALUES ('550e8400-e29b-41d4-a716-446655440100', 'bb000000-0000-0000-0000-0000000 INSERT INTO relation (id, name, target_template_identifier) VALUES ('bb000000-0000-0000-0000-000000000002', 'database', 'cache-service'); -INSERT INTO relation_target_entities (relation_id, target_entity_identifier) -VALUES ('bb000000-0000-0000-0000-000000000002', 'cache-service-1'); +INSERT INTO relation_target_entities (relation_id, target_entity_uuid) +VALUES ('bb000000-0000-0000-0000-000000000002', '550e8400-e29b-41d4-a716-446655440108'); INSERT INTO entity_relations (entity_id, relation_id) VALUES ('550e8400-e29b-41d4-a716-446655440101', 'bb000000-0000-0000-0000-000000000002'); @@ -78,8 +78,8 @@ VALUES ('550e8400-e29b-41d4-a716-446655440101', 'bb000000-0000-0000-0000-0000000 INSERT INTO relation (id, name, target_template_identifier) VALUES ('bb000000-0000-0000-0000-000000000003', 'api-link', 'microservice'); -INSERT INTO relation_target_entities (relation_id, target_entity_identifier) -VALUES ('bb000000-0000-0000-0000-000000000003', 'microservice-1'); +INSERT INTO relation_target_entities (relation_id, target_entity_uuid) +VALUES ('bb000000-0000-0000-0000-000000000003', '550e8400-e29b-41d4-a716-446655440102'); INSERT INTO entity_relations (entity_id, relation_id) VALUES ('550e8400-e29b-41d4-a716-446655440100', 'bb000000-0000-0000-0000-000000000003'); @@ -118,11 +118,11 @@ VALUES ('bb000002-0000-0000-0000-000000000001', 'uses', 'web-service'); -- Target entity identifiers for each relation -INSERT INTO relation_target_entities (relation_id, target_entity_identifier) +INSERT INTO relation_target_entities (relation_id, target_entity_uuid) VALUES - ('bb000001-0000-0000-0000-000000000001', 'graph-svc-b'), -- a -[uses]-> b - ('bb000001-0000-0000-0000-000000000002', 'graph-svc-b'), -- a -[monitors]-> b - ('bb000002-0000-0000-0000-000000000001', 'graph-svc-c'); -- b -[uses]-> c + ('bb000001-0000-0000-0000-000000000001', 'aa000001-0000-0000-0000-000000000002'), -- a -[uses]-> b + ('bb000001-0000-0000-0000-000000000002', 'aa000001-0000-0000-0000-000000000002'), -- a -[monitors]-> b + ('bb000002-0000-0000-0000-000000000001', 'aa000001-0000-0000-0000-000000000003'); -- b -[uses]-> c -- Link relations to their owner entities INSERT INTO entity_relations (entity_id, relation_id) From 7d5a3e579b09f5c8d8eb7bc0daf956eaa7e9eb45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Fri, 5 Jun 2026 18:46:05 +0200 Subject: [PATCH 30/53] feat(core): add a entity graph service and endpoint --- .../PostgresEntityGraphAdapter.java | 2 + .../mapper/EntityPersistenceMapper.java | 51 ++++++++++--------- .../model/entity/EntityJpaEntity.java | 4 ++ .../model/entity/RelationJpaEntity.java | 17 +++---- .../model/entity/RelationTargetJpaEntity.java | 40 +++++++++++++++ .../repository/JpaEntityRepository.java | 10 ++-- .../repository/JpaRelationRepository.java | 6 +-- src/main/resources/application-local.yml | 5 ++ .../V4_1__add_template_identifeir_indexes.sql | 7 --- .../migration/V4_2__ad_relation_indexes.sql | 13 ----- .../V4_3__add_target_entities_uuids.sql | 9 ---- ...make_target_entity_identifier_nullable.sql | 38 -------------- ...x.sql => V5_1__add_target_entity_uuid.sql} | 3 ++ 13 files changed, 99 insertions(+), 106 deletions(-) create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/RelationTargetJpaEntity.java delete mode 100644 src/main/resources/db/migration/V4_1__add_template_identifeir_indexes.sql delete mode 100644 src/main/resources/db/migration/V4_2__ad_relation_indexes.sql delete mode 100644 src/main/resources/db/migration/V4_3__add_target_entities_uuids.sql delete mode 100644 src/main/resources/db/migration/V4_4__make_target_entity_identifier_nullable.sql rename src/main/resources/db/migration/{V4_3_add_entity_uuid_index.sql => V5_1__add_target_entity_uuid.sql} (54%) diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java index f385e3ca..e8cabce9 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java @@ -97,9 +97,11 @@ public Map findEntityGraph(UUID entityId, int depth, boolean inclu // Step 4: map to domain and key by composite key for O(1) lookup log.debug("[EntityGraphAdapter] Mapping JPA entities to domain models..."); final long tStartMap = System.nanoTime(); + Map result = jpaEntities.stream().map(mapper::toDomain) .collect(Collectors.toMap(Entity::id, Function.identity())); final long tAfterMap = System.nanoTime(); + log.debug("[EntityGraphAdapter] Mapping completed, returning {} domain entities (elapsed={}ms)", result.size(), (tAfterMap - tStartMap) / 1_000_000); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java index ee276d90..c0441c8f 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java @@ -2,7 +2,6 @@ import java.util.List; import java.util.Objects; -import java.util.UUID; import org.springframework.stereotype.Component; @@ -12,6 +11,7 @@ import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.EntityJpaEntity; import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.PropertyJpaEntity; import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.RelationJpaEntity; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.RelationTargetJpaEntity; import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.JpaEntityRepository; import lombok.RequiredArgsConstructor; @@ -92,49 +92,52 @@ public PropertyJpaEntity toJpa(Property domain) { // Relation Mapping (with identifier ↔ UUID conversion) // ========================================================================= - /// Converts JPA relation to domain model. - /// Resolves UUIDs back to business identifiers by querying the entity - /// repository. public Relation toDomain(RelationJpaEntity jpa) { if (jpa == null) { return null; } - // Convert UUIDs back to business identifiers - List targetIdentifiers = jpa.getTargetEntityIds() != null - ? jpa.getTargetEntityIds().stream().map(uuidString -> { - try { - UUID uuid = uuidString; - return entityRepository.findById(uuid).map(EntityJpaEntity::getIdentifier).orElse(null); - } catch (IllegalArgumentException _) { - // Invalid UUID format, skip - return null; - } - }).filter(Objects::nonNull).toList() + // Pure in-memory transformation from the unified JPA row down to your string + // list + List targetIdentifiers = jpa.getTargetEntities() != null + ? jpa.getTargetEntities().stream().map(RelationTargetJpaEntity::getTargetEntityIdentifier) // Extract + // the + // cached + // text + // string + .filter(Objects::nonNull).toList() : List.of(); return new Relation(jpa.getId(), jpa.getName(), jpa.getTargetTemplateIdentifier(), - targetIdentifiers); + targetIdentifiers); // Matches your current domain model signature perfectly! } - /// Converts domain relation to JPA entity. - /// Resolves business identifiers to UUIDs by querying the entity repository. - /// The JPA entity stores only UUIDs; identifiers are not persisted in the - /// infrastructure layer. + /// Converts domain relation to JPA entity. Resolves business identifiers to + /// UUIDs by querying the entity repository. The JPA entity stores only UUIDs; + /// identifiers are not persisted in the infrastructure layer. public RelationJpaEntity toJpa(Relation domain) { if (domain == null) { return null; } - // Convert business identifiers to UUIDs for storage - List targetUuids = domain.targetEntityIdentifiers() != null + // Look up matching entities to bind both fields concurrently into single table + // rows + List targetEntities = domain.targetEntityIdentifiers() != null ? domain.targetEntityIdentifiers().stream().map(identifier -> entityRepository .findByTemplateIdentifierAndIdentifier(domain.targetTemplateIdentifier(), identifier) - .map(EntityJpaEntity::getId).orElse(null)).filter(Objects::nonNull).toList() + .map(entity -> new RelationTargetJpaEntity(entity.getId(), // The binary UUID used for + // Graph CTE crawls + entity.getIdentifier() // The immutable string cached for Java mapping + )).orElse(null)).filter(Objects::nonNull).toList() : List.of(); + // Return the unified entity mapping to prevent column nullability errors return RelationJpaEntity.builder().id(domain.id()).name(domain.name()) - .targetTemplateIdentifier(domain.targetTemplateIdentifier()).targetEntityIds(targetUuids) + .targetTemplateIdentifier(domain.targetTemplateIdentifier()).targetEntities(targetEntities) // The + // single + // unified + // collection + // table .build(); } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/EntityJpaEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/EntityJpaEntity.java index 72aea570..9e618ad7 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/EntityJpaEntity.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/EntityJpaEntity.java @@ -15,6 +15,8 @@ import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; +import org.hibernate.annotations.BatchSize; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -40,11 +42,13 @@ public class EntityJpaEntity { private String identifier; + @BatchSize(size = 50) @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) @JoinTable(name = "entity_properties", joinColumns = @JoinColumn(name = "entity_id"), inverseJoinColumns = @JoinColumn(name = "property_id"), uniqueConstraints = @UniqueConstraint(columnNames = { "entity_id", "property_id"}), indexes = @Index(columnList = "entity_id")) private List properties; + @BatchSize(size = 50) @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) @JoinTable(name = "entity_relations", joinColumns = @JoinColumn(name = "entity_id"), inverseJoinColumns = @JoinColumn(name = "relation_id"), uniqueConstraints = @UniqueConstraint(columnNames = { "entity_id", "relation_id"}), indexes = @Index(columnList = "entity_id")) diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/RelationJpaEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/RelationJpaEntity.java index 087fecb0..dcf352a4 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/RelationJpaEntity.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/RelationJpaEntity.java @@ -1,5 +1,6 @@ package com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity; +import java.util.ArrayList; import java.util.List; import java.util.UUID; @@ -7,6 +8,7 @@ import jakarta.persistence.Column; import jakarta.persistence.ElementCollection; import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; @@ -14,6 +16,8 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.Table; +import org.hibernate.annotations.BatchSize; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -37,13 +41,8 @@ public class RelationJpaEntity { @Column(name = "target_template_identifier", nullable = false) private String targetTemplateIdentifier; - @ElementCollection - @CollectionTable(name = "relation_target_entities", joinColumns = @JoinColumn(name = "relation_id"), indexes = @Index(columnList = "relation_id")) - @Column(name = "target_entity_identifier") - private List targetEntityIdentifiers; - - @ElementCollection - @CollectionTable(name = "relation_target_entities", joinColumns = @JoinColumn(name = "relation_id"), indexes = @Index(columnList = "relation_id")) - @Column(name = "target_entity_uuid") - private List targetEntityIds; + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable(name = "relation_target_entities", schema = "idp_core", joinColumns = @JoinColumn(name = "relation_id")) + @BatchSize(size = 50) + private List targetEntities; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/RelationTargetJpaEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/RelationTargetJpaEntity.java new file mode 100644 index 00000000..f9da026a --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/RelationTargetJpaEntity.java @@ -0,0 +1,40 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity; +import java.util.Objects; +import java.util.UUID; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Embeddable +@Data +@NoArgsConstructor +@AllArgsConstructor +public class RelationTargetJpaEntity { + + @Column(name = "target_entity_uuid", nullable = false) + private UUID targetEntityUuid; + + @Column(name = "target_entity_identifier", nullable = false) + private String targetEntityIdentifier; + + // Overriding equals and hashCode is best practice for ElementCollections + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + RelationTargetJpaEntity that = (RelationTargetJpaEntity) o; + return Objects.equals(targetEntityUuid, that.targetEntityUuid) + && Objects.equals(targetEntityIdentifier, that.targetEntityIdentifier); + } + + @Override + public int hashCode() { + return Objects.hash(targetEntityUuid, targetEntityIdentifier); + } +} \ No newline at end of file diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java index 2ee7114f..727ddf2f 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java @@ -40,9 +40,13 @@ Optional findByTemplateIdentifierAndIdentifier(String templateI /// properties. Uses two separate queries to avoid Hibernate's /// MultipleBagFetchException. First fetches entities with relations, then /// fetches properties separately. - @Query("SELECT DISTINCT e FROM EntityJpaEntity e LEFT JOIN FETCH e.relations WHERE e.id IN :identifiers") - List findAllByIdentifierInWithRelations( - @Param("identifiers") Collection ids); + @Query(""" + SELECT DISTINCT e + FROM EntityJpaEntity e + LEFT JOIN FETCH e.relations r + WHERE e.id IN :ids + """) + List findAllByIdentifierInWithRelations(@Param("ids") Collection ids); /// Fetch properties for entities that were already loaded. This is called after /// findAllByIdentifierInWithRelations to complete the entity graph. diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaRelationRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaRelationRepository.java index 4e642d6a..37802d94 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaRelationRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaRelationRepository.java @@ -16,12 +16,12 @@ public interface JpaRelationRepository extends JpaRepository findRelationsSummariesByTargetEntityIdentifiers( @Param("targetEntityIdentifiers") List targetEntityIdentifiers); diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 7235b23f..946318bb 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -19,6 +19,8 @@ spring: jpa: hibernate: ddl-auto: none # Disable JPA schema auto-generation, use Flyway instead + generate_statistics: true + format_sql: true show-sql: false app: full-refresh-at-startup: true @@ -31,3 +33,6 @@ logging: org.springframework.context.support: WARN # Suppresses a specific repetitive bean registration delegate log line. org.springframework.context.support.PostProcessorRegistrationDelegate: ERROR + org.hibernate.SQL: DEBUG + org.hibernate.stat: DEBUG + org.hibernate.type.descriptor.sql.BasicBinder: TRACE diff --git a/src/main/resources/db/migration/V4_1__add_template_identifeir_indexes.sql b/src/main/resources/db/migration/V4_1__add_template_identifeir_indexes.sql deleted file mode 100644 index 10ebdbc8..00000000 --- a/src/main/resources/db/migration/V4_1__add_template_identifeir_indexes.sql +++ /dev/null @@ -1,7 +0,0 @@ -CREATE INDEX idx_entity_graph_lookup -ON entity (template_identifier, identifier); - --- 2. Fixes the Inbound bottleneck: --- Speeds up reverse-searching who points to a specific target text string -CREATE INDEX idx_rte_inbound_lookup -ON relation_target_entities (target_entity_identifier); \ No newline at end of file diff --git a/src/main/resources/db/migration/V4_2__ad_relation_indexes.sql b/src/main/resources/db/migration/V4_2__ad_relation_indexes.sql deleted file mode 100644 index 50bb919d..00000000 --- a/src/main/resources/db/migration/V4_2__ad_relation_indexes.sql +++ /dev/null @@ -1,13 +0,0 @@ -CREATE INDEX idx_rte_covering_outbound -ON relation_target_entities (relation_id) -INCLUDE (target_entity_identifier); - --- 2. Optimized Covering Index for the Inbound Track -CREATE INDEX idx_rte_covering_inbound -ON relation_target_entities (target_entity_identifier) -INCLUDE (relation_id); - --- 3. Composite covering index for the main Entity table lookups -CREATE INDEX idx_entity_covering_graph -ON entity (template_identifier, identifier) -INCLUDE (id, name); \ No newline at end of file diff --git a/src/main/resources/db/migration/V4_3__add_target_entities_uuids.sql b/src/main/resources/db/migration/V4_3__add_target_entities_uuids.sql deleted file mode 100644 index 1eca2565..00000000 --- a/src/main/resources/db/migration/V4_3__add_target_entities_uuids.sql +++ /dev/null @@ -1,9 +0,0 @@ --- Step 1: Add new UUID column -ALTER TABLE relation_target_entities -ADD COLUMN target_entity_uuid UUID; - --- Step 2: Populate UUIDs from existing identifiers -UPDATE relation_target_entities rte -SET target_entity_uuid = e.id -FROM entity e -WHERE e.identifier = rte.target_entity_identifier \ No newline at end of file diff --git a/src/main/resources/db/migration/V4_4__make_target_entity_identifier_nullable.sql b/src/main/resources/db/migration/V4_4__make_target_entity_identifier_nullable.sql deleted file mode 100644 index c78737e2..00000000 --- a/src/main/resources/db/migration/V4_4__make_target_entity_identifier_nullable.sql +++ /dev/null @@ -1,38 +0,0 @@ --- Transition to UUID-only storage for relation targets --- Purpose: Remove dependency on composite (identifier + template) key and use UUID as primary storage - --- Step 1: Drop the old composite primary key that includes identifier -ALTER TABLE relation_target_entities -DROP CONSTRAINT IF EXISTS relation_target_entities_pkey; - --- Step 2: Ensure all existing rows have UUIDs populated (data migration) --- UPDATE relation_target_entities rte --- SET target_entity_uuid = e.id --- FROM entity e --- WHERE e.identifier = rte.target_entity_identifier --- AND e.template_identifier = rte.target_template_identifier --- AND rte.target_entity_uuid IS NULL; - --- Step 3: Make target_entity_uuid NOT NULL (it's now the primary storage) --- ALTER TABLE relation_target_entities --- ALTER COLUMN target_entity_uuid SET NOT NULL; - --- Step 4: Add new primary key using UUID only -ALTER TABLE relation_target_entities -ADD CONSTRAINT relation_target_entities_pkey -PRIMARY KEY (relation_id, target_entity_uuid); - --- Step 5: Make target_entity_identifier nullable (preparing for future removal) -ALTER TABLE relation_target_entities -ALTER COLUMN target_entity_identifier DROP NOT NULL; - --- Step 7: Add index on UUID for better query performance -CREATE INDEX IF NOT EXISTS idx_relation_target_uuid -ON relation_target_entities(target_entity_uuid); - --- Step 8: Add foreign key constraint for referential integrity -ALTER TABLE relation_target_entities -ADD CONSTRAINT fk_relation_target_entity -FOREIGN KEY (target_entity_uuid) -REFERENCES entity(id) -ON DELETE CASCADE; diff --git a/src/main/resources/db/migration/V4_3_add_entity_uuid_index.sql b/src/main/resources/db/migration/V5_1__add_target_entity_uuid.sql similarity index 54% rename from src/main/resources/db/migration/V4_3_add_entity_uuid_index.sql rename to src/main/resources/db/migration/V5_1__add_target_entity_uuid.sql index d6174d9b..b618a0e6 100644 --- a/src/main/resources/db/migration/V4_3_add_entity_uuid_index.sql +++ b/src/main/resources/db/migration/V5_1__add_target_entity_uuid.sql @@ -1,2 +1,5 @@ +ALTER TABLE relation_target_entities +ADD COLUMN target_entity_uuid UUID; + CREATE INDEX idx_rte_target_uuid_binary ON relation_target_entities (target_entity_uuid); \ No newline at end of file From 29e507257548b55735dfe4deb30590b7b75c9aa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Wed, 10 Jun 2026 10:35:58 +0200 Subject: [PATCH 31/53] feat(core): add a entity graph service and endpoint --- .../port/EntityGraphRepositoryPort.java | 2 + .../domain/port/EntityRepositoryPort.java | 4 + .../entity_graph/EntityGraphService.java | 354 +++++++----------- .../api/controller/EntityGraphController.java | 65 ++++ .../persistence/PostgresEntityAdapter.java | 7 + .../PostgresEntityGraphAdapter.java | 23 ++ .../model/entity/RelationTargetJpaEntity.java | 2 +- .../repository/JpaEntityRepository.java | 98 ++--- .../V5_1__add_target_entity_uuid.sql | 6 +- 9 files changed, 285 insertions(+), 276 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java index bd662502..c73bb508 100644 --- a/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java @@ -43,4 +43,6 @@ public interface EntityGraphRepositoryPort { /// any path are loaded. Edge filtering is applied in the service layer so /// that "filter owns" still returns B and C when A→(depends-on)→B→(owns)→C. Map findEntityGraph(UUID entityId, int depth, boolean includeProperties); + + Map findEntityGraphBatch(List rootIds, int depth, boolean includeProperties); } diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java index 0718ea94..f07daf1a 100644 --- a/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java @@ -11,6 +11,7 @@ import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntityFilter; import com.decathlon.idp_core.domain.model.entity.EntitySummary; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.EntityJpaEntity; /// Driven port defining the contract for [Entity] persistence operations. /// @@ -43,6 +44,9 @@ Optional findByTemplateIdentifierAndIdentifier(String templateIdentifier Page findByTemplateIdentifierWithFilter(String templateIdentifier, EntityFilter filter, Pageable pageable); + List findAllByTemplateIdentifierAndIdentifierIn(String templateIdentifier, + List identifiers); + List findByIdentifierIn(List identifiers); List findByRelationIdIn(List relationIds); diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java index 7dd2cb27..3a5d3413 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java @@ -8,6 +8,7 @@ import java.util.Objects; import java.util.Set; import java.util.UUID; +import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -23,6 +24,7 @@ import com.decathlon.idp_core.domain.port.EntityGraphRepositoryPort; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.EntityJpaEntity; import lombok.RequiredArgsConstructor; @@ -64,303 +66,224 @@ public class EntityGraphService { public EntityGraphNode getEntityGraph(String templateIdentifier, String entityIdentifier, int depth, boolean includeProperties, Set relationFilter, Set propertyFilter) { - final long tStartTotal = System.nanoTime(); + final long tStartTotal = System.nanoTime(); int effectiveDepth = Math.clamp(depth, 1, MAX_DEPTH); entityTemplateValidationService.validateTemplateExists(templateIdentifier); - // Resolve root entity and measure time + // 1. Resolve root entity final long tStartResolve = System.nanoTime(); Entity rootEntity = entityRepositoryPort .findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); final long tAfterResolve = System.nanoTime(); - // log.debug("[EntityGraph] Resolved root entity: id='{}' identifier='{}' - // template='{}' (elapsed={}ms)", rootEntity.id(), rootEntity.identifier(), - // rootEntity.templateIdentifier(), (tAfterResolve - tStartResolve) / - // 1_000_000); - // Load entire graph chunk via optimized DB calls + // 2. Load the graph footprint via optimized DB calls (Takes ~150ms) final long tStartRepo = System.nanoTime(); Map entityMap = entityGraphRepositoryPort.findEntityGraph(rootEntity.id(), effectiveDepth, includeProperties); final long tAfterRepo = System.nanoTime(); if (entityMap == null || entityMap.isEmpty()) { - log.debug( - "[EntityGraph] No entities returned from repository for root id='{}'. Returning single-node graph. (repoElapsed={}ms)", - rootEntity.id(), (tAfterRepo - tStartRepo) / 1_000_000); - final long tEndTotalEmpty = System.nanoTime(); - log.debug("[EntityGraph] getEntityGraph end (single-node): totalElapsed={}ms", - (tEndTotalEmpty - tStartTotal) / 1_000_000); return new EntityGraphNode(rootEntity.id().toString(), rootEntity.identifier(), rootEntity.name(), List.of(), List.of(), List.of()); } - log.debug( - "[EntityGraph] Repository returned {} entities for root id='{}' (includeProperties={}) repoElapsed={}ms", - entityMap.size(), rootEntity.id(), includeProperties, - (tAfterRepo - tStartRepo) / 1_000_000); + log.debug("[EntityGraph] Repository returned {} entities for root id='{}' repoElapsed={}ms", + entityMap.size(), rootEntity.id(), (tAfterRepo - tStartRepo) / 1_000_000); - // ------------------------------------------------------------------------- - // BULK PRE-COMPUTATION LAYER (Normalized for absolute string resilience) - // ------------------------------------------------------------------------- + // 3. Pre-computation Layer final long tStartIndex = System.nanoTime(); IndexBundle indices = buildIndices(entityMap); final long tAfterIndex = System.nanoTime(); - Map textToUuidLookup = indices.textToUuidLookup(); - Map>> inboundIndex = indices.inboundIndex(); - int inboundEntries = inboundIndex.values().stream() - .mapToInt(m -> m.values().stream().mapToInt(List::size).sum()).sum(); - // log.debug("[EntityGraph][Index] Built textToUuidLookup size={} - // inboundIndexRelations={} totalInboundSources={} (processed={}) - // indexElapsed={}ms", - // textToUuidLookup.size(), inboundIndex.size(), inboundEntries, - // entityMap.size(), (tAfterIndex - tStartIndex) / 1_000_000); - - // Depth-Aware Tracker to prevent premature branch starvation - Map visitedDepths = new HashMap<>(); - - // Create context object to avoid long parameter lists when traversing - GraphTraversalContext ctx = new GraphTraversalContext(entityMap, textToUuidLookup, inboundIndex, - includeProperties, propertyFilter, relationFilter, visitedDepths); - - // Trigger recursion passing our resilient indices via context - // log.debug("[EntityGraph] Starting recursive graph build from root id='{}' - // with depthBudget={}'", rootEntity.id(), effectiveDepth); + // Context tracking for this execution tree + Set activeStack = new HashSet<>(); + Map memoCache = new HashMap<>(); + + GraphTraversalContext ctx = new GraphTraversalContext(entityMap, indices.textToUuidLookup(), + indices.inboundIndex(), includeProperties, propertyFilter, relationFilter, activeStack, + memoCache); + + // 4. Trigger recursive tree mapping (O(N) performance, heap-safe) final long tStartRecursion = System.nanoTime(); - EntityGraphNode rootNode = buildGraphNode(rootEntity.id(), ctx, effectiveDepth); + EntityGraphNode rootNode = buildGraphNode(rootEntity.id(), ctx); final long tAfterRecursion = System.nanoTime(); - // log.debug("[EntityGraph] Completed recursive graph build for root id='{}'. - // recursionElapsed={}ms Visited {} nodes.", rootEntity.id(), (tAfterRecursion - - // tStartRecursion) / 1_000_000, visitedDepths.size()); - - // Log unvisited entities for diagnostics - logUnvisitedEntities(entityMap, visitedDepths); final long tEndTotal = System.nanoTime(); log.debug( - "[EntityGraph] getEntityGraph end: totalElapsed={}ms (resolve={}ms repo={}ms index={}ms recursion={}ms)", + "[EntityGraph] End: totalElapsed={}ms (resolve={}ms repo={}ms index={}ms recursion={}ms) CacheSize={}", (tEndTotal - tStartTotal) / 1_000_000, (tAfterResolve - tStartResolve) / 1_000_000, (tAfterRepo - tStartRepo) / 1_000_000, (tAfterIndex - tStartIndex) / 1_000_000, - (tAfterRecursion - tStartRecursion) / 1_000_000); + (tAfterRecursion - tStartRecursion) / 1_000_000, memoCache.size()); return rootNode; } - /// Logs entities that were loaded but never visited during graph traversal. - /// This helps diagnose why the number of loaded entities exceeds the number of - /// output nodes. - private void logUnvisitedEntities(Map entityMap, - Map visitedDepths) { - Set visitedUuids = new HashSet<>(); - for (String uuidStr : visitedDepths.keySet()) { - try { - visitedUuids.add(UUID.fromString(uuidStr)); - } catch (IllegalArgumentException _) { - // Invalid UUID format, skip - } + @Transactional(readOnly = true) + public Map getBatchEntityGraphsByIdentifiers(String templateIdentifier, + List identifiers, int depth, boolean includeProperties, Set relationFilter, + Set propertyFilter) { + + if (identifiers == null || identifiers.isEmpty()) { + return Map.of(); } - List unvisitedEntities = entityMap.entrySet().stream() - .filter(entry -> !visitedUuids.contains(entry.getKey())).map(Map.Entry::getValue) - .filter(Objects::nonNull).toList(); + int effectiveDepth = Math.clamp(depth, 1, MAX_DEPTH); + entityTemplateValidationService.validateTemplateExists(templateIdentifier); - if (!unvisitedEntities.isEmpty()) { - // log.info("[EntityGraph] Loaded {} entities, visited {} entities, {} entities - // were unreachable", - // entityMap.size(), visitedUuids.size(), unvisitedEntities.size()); + // 1. BULK INITIAL RESOLUTION: Fetch all matching root entities in 1 clean + // database query + // This resolves your text identifiers to their corresponding binary UUIDs + List rootJpaEntities = entityRepositoryPort + .findAllByTemplateIdentifierAndIdentifierIn(templateIdentifier, identifiers); - // Group by template for better readability - Map> unvisitedByTemplate = new HashMap<>(); - for (Entity entity : unvisitedEntities) { - unvisitedByTemplate.computeIfAbsent(entity.templateIdentifier(), k -> new ArrayList<>()) - .add(entity.identifier()); - } + if (rootJpaEntities.isEmpty()) { + return Map.of(); + } + + // Map UUIDs to their matching business identifier text for the final output + // step + Map uuidToIdentifierMap = rootJpaEntities.stream() + .collect(Collectors.toMap(EntityJpaEntity::getId, EntityJpaEntity::getIdentifier)); + + List rootIds = rootJpaEntities.stream().map(EntityJpaEntity::getId).toList(); + + // 2. BULK GRAPH FETCH: Execute your optimized set-based join CTE using the + // resolved UUIDs + Map entityMap = entityGraphRepositoryPort.findEntityGraphBatch(rootIds, + effectiveDepth, includeProperties); + + if (entityMap == null || entityMap.isEmpty()) { + return Map.of(); + } + + // 3. INDEX PRE-COMPUTATION: Build lookups once for the entire batch footprint + IndexBundle indices = buildIndices(entityMap); + Map batchResult = new HashMap<>(); + + // 4. SEPARATION LOOP: Construct independent memoized trees for each discovered + // root + for (UUID rootId : rootIds) { + Set activeStack = new HashSet<>(); + Map memoCache = new HashMap<>(); - // unvisitedByTemplate.forEach((template, identifiers) -> - // log.info(" Template '{}': {} entities - {}", - // template, identifiers.size(), - // identifiers.size() <= 10 ? identifiers : identifiers.subList(0, 10) + "... (" - // + (identifiers.size() - 10) + " more)" ) - // ); - } else { - log.info("[EntityGraph] All {} loaded entities were visited (100% reachability)", - entityMap.size()); + GraphTraversalContext ctx = new GraphTraversalContext(entityMap, indices.textToUuidLookup(), + indices.inboundIndex(), includeProperties, propertyFilter, relationFilter, activeStack, + memoCache); + + // Resolve the graph structure using your memory-safe recursive builder + EntityGraphNode tree = buildGraphNode(rootId, ctx); + + // Pull the matching business identifier string to key the final map entry + String businessIdentifier = uuidToIdentifierMap.get(rootId); + if (businessIdentifier != null) { + batchResult.put(businessIdentifier, tree); + } } + + return batchResult; } - private EntityGraphNode buildGraphNode(UUID entityUuid, Map entityMap, - Map textToUuidLookup, - Map>> inboundIndex, int remainingDepth, - boolean includeProperties, Set propertyFilter, Set relationFilter, - Map visitedDepths) { - // Note: This method signature is replaced by the Context-based overload below - // during refactor. - // Kept for backward-compatibility for the insert edit tool to apply minimal - // changes. - Entity entity = entityMap.get(entityUuid); + private EntityGraphNode buildGraphNode(UUID entityUuid, GraphTraversalContext ctx) { + Entity entity = ctx.entityMap().get(entityUuid); var nodeIdDisplay = entityUuid != null ? entityUuid.toString() : "null-entity-"; if (entity == null) { - log.trace("[EntityGraph][buildGraphNode] Missing entity for uuid='{}'. Returning empty node.", - nodeIdDisplay); return new EntityGraphNode(nodeIdDisplay, nodeIdDisplay, nodeIdDisplay, List.of(), List.of(), List.of()); } - log.trace("[EntityGraph][buildGraphNode] Enter node='{}' identifier='{}' remainingDepth={}", - entity.id(), entity.identifier(), remainingDepth); - - // Check depth budget exhaustion first - if (remainingDepth <= 0) { - log.trace( - "[EntityGraph][buildGraphNode] Depth exhausted at node='{}'. Resolving leaf properties only.", - entity.identifier()); - List leafProperties = resolveProperties(entity, includeProperties, propertyFilter); - return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), - leafProperties, List.of(), List.of()); - } - - // Depth-Aware Cycle Breaking Guard var nodeId = entity.id().toString(); - Integer previousMaxDepthBudget = visitedDepths.get(nodeId); - if (previousMaxDepthBudget != null && previousMaxDepthBudget >= remainingDepth) { - log.trace( - "[EntityGraph][buildGraphNode] Node '{}' already visited with equal or larger budget (prev={} curr={}). Returning stub.", - entity.identifier(), previousMaxDepthBudget, remainingDepth); - List stubProperties = resolveProperties(entity, includeProperties, propertyFilter); + // GUARD 1: If the node is currently in active processing up our current line, + // we hit a cyclic loop closure. Break instantly with a stub to prevent infinite + // stack overflow. + if (ctx.activeStack().contains(nodeId)) { + List stubProperties = resolveProperties(entity, ctx.includeProperties(), + ctx.propertyFilter()); return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), stubProperties, List.of(), List.of()); } - visitedDepths.put(nodeId, remainingDepth); + // GUARD 2: MEMOIZATION CHECK. If this node has already been built down an + // alternate path, + // return the pre-existing reference instantly. This prevents the exponential + // path explosion. + if (ctx.memoCache().containsKey(nodeId)) { + return ctx.memoCache().get(nodeId); + } + + // Push to active processing stack + ctx.activeStack().add(nodeId); // Process outbound relationships List outboundRelations = entity.relations().stream() - .filter(relation -> relationFilter.isEmpty() || relationFilter.contains(relation.name())) + .filter(relation -> ctx.relationFilter().isEmpty() + || ctx.relationFilter().contains(relation.name())) .map(relation -> new EntityGraphRelation(relation.name(), relation.targetEntityIdentifiers().stream().map(targetId -> { - // Look up using normalized coordinates - UUID targetUuid = textToUuidLookup + UUID targetUuid = ctx.textToUuidLookup() .get(new EntityCompositeKey(relation.targetTemplateIdentifier(), targetId)); if (targetUuid == null) return null; - return buildGraphNode(targetUuid, entityMap, textToUuidLookup, inboundIndex, - remainingDepth, includeProperties, propertyFilter, relationFilter, visitedDepths); + return buildGraphNode(targetUuid, ctx); }).filter(Objects::nonNull).toList())) .filter(rel -> !rel.targets().isEmpty()).toList(); - log.trace("[EntityGraph][buildGraphNode] Node='{}' outboundRelations={} (after filtering)", - entity.identifier(), outboundRelations.size()); - // Process inbound relationships - // Use the new context-based method to build inbound relations - GraphTraversalContext ctx = new GraphTraversalContext(entityMap, textToUuidLookup, inboundIndex, - includeProperties, propertyFilter, relationFilter, visitedDepths); List inboundRelations = buildRelationsAsTargetFromIndex( - entity.identifier(), ctx, remainingDepth); + entity.identifier(), ctx); + + List properties = resolveProperties(entity, ctx.includeProperties(), + ctx.propertyFilter()); + + // Assemble the complete node object + EntityGraphNode completedNode = new EntityGraphNode(entity.templateIdentifier(), + entity.identifier(), entity.name(), properties, outboundRelations, inboundRelations); - log.trace("[EntityGraph][buildGraphNode] Node='{}' inboundRelations={} (after index lookup)", - entity.identifier(), inboundRelations.size()); + // Save to Cache and pop from active stack before returning + ctx.memoCache().put(nodeId, completedNode); + ctx.activeStack().remove(nodeId); - List properties = resolveProperties(entity, includeProperties, propertyFilter); - log.trace( - "[EntityGraph][buildGraphNode] Leaving node='{}' propertiesCount={} totalRelationsOut={} totalRelationsIn={}", - entity.identifier(), properties.size(), outboundRelations.size(), inboundRelations.size()); - return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), - properties, outboundRelations, inboundRelations); + return completedNode; } private List buildRelationsAsTargetFromIndex(String targetIdentifier, - GraphTraversalContext ctx, int remainingDepth) { - Map>> inboundIndex = ctx.inboundIndex(); - Set relationFilter = ctx.relationFilter(); - - // Normalize the map query coordinate to guarantee matching across variations + GraphTraversalContext ctx) { String normalizedTargetIdentifier = targetIdentifier == null ? "" : targetIdentifier.trim().toLowerCase(); - Map> sourcesByRelationName = inboundIndex + Map> sourcesByRelationName = ctx.inboundIndex() .getOrDefault(normalizedTargetIdentifier, Map.of()); if (sourcesByRelationName.isEmpty()) { - log.trace( - "[EntityGraph][buildRelations] No inbound sources found for target='{}' (normalized='{}')", - targetIdentifier, normalizedTargetIdentifier); return List.of(); } - log.trace("[EntityGraph][buildRelations] Found {} relation entries for target='{}'", - sourcesByRelationName.size(), targetIdentifier); - return sourcesByRelationName.entrySet().stream() - .filter(e -> relationFilter.isEmpty() || relationFilter.contains(e.getKey())).map(e -> { - log.trace( - "[EntityGraph][buildRelations] Processing inbound relation='{}' with {} sources for target='{}'", - e.getKey(), e.getValue().size(), targetIdentifier); + .filter(e -> ctx.relationFilter().isEmpty() || ctx.relationFilter().contains(e.getKey())) + .map(e -> { List targets = e.getValue().stream() - .map(sourceUuid -> buildGraphNode(sourceUuid, ctx, remainingDepth - 1)).toList(); + .map(sourceUuid -> buildGraphNode(sourceUuid, ctx)).toList(); return new EntityGraphRelation(e.getKey(), targets); }).toList(); } - private List resolveProperties(Entity entity, boolean includeProperties, - Set propertyFilter) { - if (!includeProperties) { - log.trace( - "[EntityGraph][properties] Skipping property resolution for entity='{}' (includeProperties=false)", - entity == null ? "null" : entity.identifier()); - return List.of(); - } - if (propertyFilter.isEmpty()) { - log.trace("[EntityGraph][properties] Including all properties for entity='{}' count={}", - entity.identifier(), entity.properties().size()); - return entity.properties(); - } - List resolved = entity.properties().stream() - .filter(p -> propertyFilter.contains(p.name())).toList(); - log.trace( - "[EntityGraph][properties] Resolved {} properties from filter size={} for entity='{}'", - resolved.size(), propertyFilter.size(), entity.identifier()); - return resolved; - } - - // New context and index bundle records to reduce parameter passing and cluster - // related state - private static record IndexBundle(Map textToUuidLookup, - Map>> inboundIndex) { - } - - private static record GraphTraversalContext(Map entityMap, - Map textToUuidLookup, - Map>> inboundIndex, boolean includeProperties, - Set propertyFilter, Set relationFilter, Map visitedDepths) { - } - private IndexBundle buildIndices(Map entityMap) { Map textToUuidLookup = new HashMap<>(); Map>> inboundIndex = new HashMap<>(); - int processedEntities = 0; + for (Map.Entry entry : entityMap.entrySet()) { UUID sourceUuid = entry.getKey(); Entity entity = entry.getValue(); - processedEntities++; - if (entity == null) { - log.trace("[EntityGraph][Index] Skipping null entity for uuid='{}'", sourceUuid); + if (entity == null) continue; - } - // Build Index 1 (Automated normalization happens inside the record constructor - // below) textToUuidLookup.put(new EntityCompositeKey(entity.templateIdentifier(), entity.identifier()), sourceUuid); - // Build Index 2 (Normalized to lowercase and trimmed to eliminate trailing - // space bugs) for (Relation relation : entity.relations()) { for (String targetId : relation.targetEntityIdentifiers()) { if (targetId == null) @@ -369,30 +292,39 @@ private IndexBundle buildIndices(Map entityMap) { inboundIndex.computeIfAbsent(normalizedTargetId, k -> new HashMap<>()) .computeIfAbsent(relation.name(), k -> new ArrayList<>()).add(sourceUuid); } - log.trace("[EntityGraph][Index] Entity '{}' added relation '{}' with {} targets", - entity.identifier(), relation.name(), relation.targetEntityIdentifiers().size()); - } - - if (log.isTraceEnabled() && processedEntities % 500 == 0) { - log.trace("[EntityGraph][Index] Processed {} entities so far (current uuid='{}')", - processedEntities, sourceUuid); } } - return new IndexBundle(textToUuidLookup, inboundIndex); } - // Context-based overload of buildGraphNode to reduce parameter count - private EntityGraphNode buildGraphNode(UUID entityUuid, GraphTraversalContext ctx, - int remainingDepth) { - return buildGraphNode(entityUuid, ctx.entityMap(), ctx.textToUuidLookup(), ctx.inboundIndex(), - remainingDepth, ctx.includeProperties(), ctx.propertyFilter(), ctx.relationFilter(), - ctx.visitedDepths()); + private List resolveProperties(Entity entity, boolean includeProperties, + Set propertyFilter) { + if (!includeProperties) + return List.of(); + if (propertyFilter.isEmpty()) + return entity.properties(); + return entity.properties().stream().filter(p -> propertyFilter.contains(p.name())).toList(); + } + + private static record IndexBundle(Map textToUuidLookup, + Map>> inboundIndex) { + } + + private static record GraphTraversalContext(Map entityMap, + Map textToUuidLookup, + Map>> inboundIndex, boolean includeProperties, + Set propertyFilter, Set relationFilter, Set activeStack, // Tracks + // current + // parent + // line to + // block + // infinite + // loops + Map memoCache // High-speed in-memory reuse cache + ) { } } -// SOLUTION FIX: Enforced lowercase, trimmed normalization inside the -// constructor record EntityCompositeKey(String templateIdentifier, String identifier) { public EntityCompositeKey { templateIdentifier = templateIdentifier == null ? "" : templateIdentifier.trim().toLowerCase(); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java index d5b340cd..cd66ac9c 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java @@ -13,13 +13,19 @@ import static org.springframework.http.HttpStatus.OK; import java.util.List; +import java.util.Map; import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; @@ -91,4 +97,63 @@ public EntityGraphFlatDtoOut getEntityGraph(@PathVariable @NotBlank String templ return EntityGraphFlatDtoOutMapper.toFlatDto(graphNode); } + + @PostMapping("/{templateIdentifier}/graph/batch") + @ResponseStatus(OK) + @Operation(summary = "Get multiple entity graphs by business identifiers", description = "Pass a template identifier in the path and up to 20 unique business identifier strings in the body to receive independent graph trees.", responses = { + @ApiResponse(responseCode = OK_CODE, description = "Batch graph retrieval successful")}) + public Map getBatchEntityGraphsByIdentifiers( + @PathVariable @NotBlank String templateIdentifier, + @RequestBody @NotEmpty List identifiers, // Accept text business identifiers + @Parameter(description = PARAM_DEPTH_DESCRIPTION) @RequestParam(defaultValue = "1") int depth, + @Parameter(description = PARAM_INCLUDE_DATA_DESCRIPTION) @RequestParam(name = "include_data", defaultValue = "false") boolean includeData, + @Parameter(description = PARAM_RELATIONS_DESCRIPTION) @RequestParam(required = false) List relations, + @Parameter(description = PARAM_PROPERTIES_DESCRIPTION) @RequestParam(required = false) List properties) { + + // 1. Guard rails for user-friendly UX protection + if (identifiers.size() > 25) { + throw new IllegalArgumentException( + "Batch size exceeds maximum allowed limit of 20 elements."); + } + + Set relationFilter = relations != null ? Set.copyOf(relations) : Set.of(); + Set propertyFilter = properties != null ? Set.copyOf(properties) : Set.of(); + + // 2. Execute service layer using string-based identifiers + Map batchGraphs = entityGraphService.getBatchEntityGraphsByIdentifiers( + templateIdentifier, identifiers, depth, includeData, relationFilter, propertyFilter); + + // 3. Map the memory trees back to flat DTOs, keyed by the input identifier + // strings + return batchGraphs.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey, + entry -> EntityGraphFlatDtoOutMapper.toFlatDto(entry.getValue()))); + } + + @PostMapping("/{templateIdentifier}/graph/batch/raw") + @ResponseStatus(OK) + @Operation(summary = "Get multiple entity graphs by business identifiers", description = "Pass a template identifier in the path and up to 20 unique business identifier strings in the body to receive independent graph trees.", responses = { + @ApiResponse(responseCode = OK_CODE, description = "Batch graph retrieval successful")}) + public Map getBatchEntityGraphsRawByIdentifiers( + @PathVariable @NotBlank String templateIdentifier, + @RequestBody @NotEmpty List identifiers, // Accept text business identifiers + @Parameter(description = PARAM_DEPTH_DESCRIPTION) @RequestParam(defaultValue = "1") int depth, + @Parameter(description = PARAM_INCLUDE_DATA_DESCRIPTION) @RequestParam(name = "include_data", defaultValue = "false") boolean includeData, + @Parameter(description = PARAM_RELATIONS_DESCRIPTION) @RequestParam(required = false) List relations, + @Parameter(description = PARAM_PROPERTIES_DESCRIPTION) @RequestParam(required = false) List properties) { + + // 1. Guard rails for user-friendly UX protection + if (identifiers.size() > 25) { + throw new IllegalArgumentException( + "Batch size exceeds maximum allowed limit of 20 elements."); + } + + Set relationFilter = relations != null ? Set.copyOf(relations) : Set.of(); + Set propertyFilter = properties != null ? Set.copyOf(properties) : Set.of(); + + // 2. Execute service layer using string-based identifiers + return entityGraphService.getBatchEntityGraphsByIdentifiers(templateIdentifier, identifiers, + depth, includeData, relationFilter, propertyFilter); + + } + } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java index 4cc14a22..068a8c1e 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 @@ -88,4 +88,11 @@ public void deleteRelationsByTemplateIdentifierAndRelationName(String templateId jpaEntityRepository.deleteRelationsByTemplateIdentifierAndRelationName(templateIdentifier, relationNames); } + + @Override + public List findAllByTemplateIdentifierAndIdentifierIn(String templateIdentifier, + List identifiers) { + return jpaEntityRepository.findAllByTemplateIdentifierAndIdentifierIn(templateIdentifier, + identifiers); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java index e8cabce9..ae05a76a 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java @@ -110,4 +110,27 @@ public Map findEntityGraph(UUID entityId, int depth, boolean inclu (tEndTotal - tStartTotal) / 1_000_000); return result; } + + @Override + @Transactional(readOnly = true) + public Map findEntityGraphBatch(List rootIds, int depth, + boolean includeProperties) { + if (rootIds == null || rootIds.isEmpty()) + return Map.of(); + + // Step 1: Bulk-scout all reachable node IDs across all 20 roots in 1 query + List graphPairs = jpaEntityRepository.findEntityGraphIdentifiersBatch(rootIds, depth); + if (graphPairs == null || graphPairs.isEmpty()) + return Map.of(); + + List entitiesIds = graphPairs.stream().distinct().toList(); + + // Step 2: Bulk-hydrate all objects and their cached relation strings in 1 query + List jpaEntities = jpaEntityRepository + .findAllByIdentifierInWithRelations(entitiesIds); + + // Step 3: Pure O(N) in-memory domain mapping + return jpaEntities.stream().map(mapper::toDomain) + .collect(Collectors.toMap(Entity::id, Function.identity())); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/RelationTargetJpaEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/RelationTargetJpaEntity.java index f9da026a..61cfbb7e 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/RelationTargetJpaEntity.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/RelationTargetJpaEntity.java @@ -37,4 +37,4 @@ public boolean equals(Object o) { public int hashCode() { return Objects.hash(targetEntityUuid, targetEntityIdentifier); } -} \ No newline at end of file +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java index 727ddf2f..47d6ae11 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java @@ -86,70 +86,43 @@ CROSS JOIN LATERAL ( -- 3. LEAN RETURN: Extract only the unique raw UUIDs discovered in the network skeleton SELECT DISTINCT id FROM entity_graph; """, nativeQuery = true) - // List findEntityGraphIdentifiers(@Param("templateIdentifier") String - // templateIdentifier, - // @Param("entityIdentifier") String entityIdentifier, @Param("depth") int - // depth - List findEntityGraphIdentifiers(@Param("entityId") UUID entityId, @Param("depth") int depth); - // /// Variant of [findEntityGraphIdentifiers] that restricts traversal to the - // /// given relation names. When the list is empty, all relation names are - // /// followed - // /// (no filter). The filter is applied inside both the outbound and inbound - // /// recursive CTE steps so that only entities reachable through the specified - // /// relations are returned, keeping the result set lean. - // @Query(value = """ - // WITH RECURSIVE - // outbound_graph(identifier, template_identifier, depth) AS ( - // SELECT e.identifier, e.template_identifier, 0 - // FROM entity e - // WHERE e.identifier = :entityIdentifier - // AND e.template_identifier = :templateIdentifier - - // UNION ALL - - // SELECT e2.identifier, e2.template_identifier, og.depth + 1 - // FROM outbound_graph og - // JOIN entity e ON e.identifier = og.identifier AND e.template_identifier = - // og.template_identifier - // JOIN entity_relations er ON er.entity_id = e.id - // JOIN relation r ON r.id = er.relation_id - // JOIN relation_target_entities rte ON rte.relation_id = r.id - // JOIN entity e2 ON e2.identifier = rte.target_entity_identifier - // WHERE og.depth < :depth - // AND r.name IN :relationNames - // ), - // inbound_graph(identifier, template_identifier, depth) AS ( - // SELECT e.identifier, e.template_identifier, 0 - // FROM entity e - // WHERE e.identifier = :entityIdentifier - // AND e.template_identifier = :templateIdentifier - - // UNION ALL - - // SELECT e2.identifier, e2.template_identifier, ig.depth + 1 - // FROM inbound_graph ig - // JOIN entity e ON e.identifier = ig.identifier AND e.template_identifier = - // ig.template_identifier - // JOIN relation_target_entities rte ON rte.target_entity_identifier = - // e.identifier - // JOIN relation r ON r.id = rte.relation_id - // JOIN entity_relations er ON er.relation_id = r.id - // JOIN entity e2 ON e2.id = er.entity_id - // WHERE ig.depth < :depth - // AND r.name IN :relationNames - // ) - // SELECT DISTINCT identifier, template_identifier FROM outbound_graph - // UNION - // SELECT DISTINCT identifier, template_identifier FROM inbound_graph - // """, nativeQuery = true) - // List findEntityGraphIdentifiersFilteredByRelations( - // @Param("templateIdentifier") String templateIdentifier, - // @Param("entityIdentifier") String entityIdentifier, @Param("depth") int - // depth, - // @Param("relationNames") Collection relationNames); + @Query(value = """ + WITH RECURSIVE entity_graph(id, depth) AS ( + -- 1. BATCH ANCHOR MEMBER: Initialize the unique initial root IDs + SELECT DISTINCT e.id, 0 + FROM idp_core.entity e + WHERE e.id IN (:rootIds) + + UNION -- Handles distinct node/depth state filtering at each step + + -- 2. RECURSIVE MEMBER: Upgraded from LATERAL loops to a Bulk Set-Based Join + SELECT combined.id, eg.depth + 1 + FROM entity_graph eg + JOIN ( + -- Bulk Outbound Adjacency Map + SELECT er.entity_id AS source_id, rte.target_entity_uuid AS id + FROM idp_core.entity_relations er + JOIN idp_core.relation_target_entities rte ON rte.relation_id = er.relation_id + WHERE rte.target_entity_uuid IS NOT NULL + + UNION ALL + + -- Bulk Inbound Adjacency Map + SELECT rte.target_entity_uuid AS source_id, er.entity_id AS id + FROM idp_core.relation_target_entities rte + JOIN idp_core.entity_relations er ON er.relation_id = rte.relation_id + ) combined ON combined.source_id = eg.id + -- Enforce the depth budget parameter cleanly + WHERE eg.depth < :depth + ) + -- 3. LEAN RETURN: Extract unique raw UUIDs discovered across all universes + SELECT DISTINCT id FROM entity_graph; + """, nativeQuery = true) + List findEntityGraphIdentifiersBatch(@Param("rootIds") List rootIds, + @Param("depth") int depth); @Modifying(clearAutomatically = true, flushAutomatically = true) @Query(""" @@ -176,4 +149,7 @@ WHERE r IN ( void deleteRelationsByTemplateIdentifierAndRelationName( @Param("templateIdentifier") String templateIdentifier, @Param("relationNames") Collection relationNames); + + List findAllByTemplateIdentifierAndIdentifierIn(String templateIdentifier, + List identifiers); } diff --git a/src/main/resources/db/migration/V5_1__add_target_entity_uuid.sql b/src/main/resources/db/migration/V5_1__add_target_entity_uuid.sql index b618a0e6..d2b84540 100644 --- a/src/main/resources/db/migration/V5_1__add_target_entity_uuid.sql +++ b/src/main/resources/db/migration/V5_1__add_target_entity_uuid.sql @@ -1,5 +1,5 @@ -ALTER TABLE relation_target_entities +ALTER TABLE relation_target_entities ADD COLUMN target_entity_uuid UUID; -CREATE INDEX idx_rte_target_uuid_binary -ON relation_target_entities (target_entity_uuid); \ No newline at end of file +CREATE INDEX idx_rte_target_uuid_binary +ON relation_target_entities (target_entity_uuid); From 4969ef9996b2b8f841ee36764799025851a174cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Wed, 10 Jun 2026 11:50:01 +0200 Subject: [PATCH 32/53] feat(core): add a entity graph service and endpoint --- .../port/EntityGraphRepositoryPort.java | 1 - .../entity_graph/EntityGraphService.java | 65 ------------------- .../api/controller/EntityGraphController.java | 59 ----------------- .../PostgresEntityGraphAdapter.java | 30 ++------- .../repository/JpaEntityRepository.java | 40 +----------- 5 files changed, 7 insertions(+), 188 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java index c73bb508..844a0b15 100644 --- a/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java @@ -44,5 +44,4 @@ public interface EntityGraphRepositoryPort { /// that "filter owns" still returns B and C when A→(depends-on)→B→(owns)→C. Map findEntityGraph(UUID entityId, int depth, boolean includeProperties); - Map findEntityGraphBatch(List rootIds, int depth, boolean includeProperties); } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java index 3a5d3413..58b851f6 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java @@ -120,71 +120,6 @@ public EntityGraphNode getEntityGraph(String templateIdentifier, String entityId return rootNode; } - @Transactional(readOnly = true) - public Map getBatchEntityGraphsByIdentifiers(String templateIdentifier, - List identifiers, int depth, boolean includeProperties, Set relationFilter, - Set propertyFilter) { - - if (identifiers == null || identifiers.isEmpty()) { - return Map.of(); - } - - int effectiveDepth = Math.clamp(depth, 1, MAX_DEPTH); - entityTemplateValidationService.validateTemplateExists(templateIdentifier); - - // 1. BULK INITIAL RESOLUTION: Fetch all matching root entities in 1 clean - // database query - // This resolves your text identifiers to their corresponding binary UUIDs - List rootJpaEntities = entityRepositoryPort - .findAllByTemplateIdentifierAndIdentifierIn(templateIdentifier, identifiers); - - if (rootJpaEntities.isEmpty()) { - return Map.of(); - } - - // Map UUIDs to their matching business identifier text for the final output - // step - Map uuidToIdentifierMap = rootJpaEntities.stream() - .collect(Collectors.toMap(EntityJpaEntity::getId, EntityJpaEntity::getIdentifier)); - - List rootIds = rootJpaEntities.stream().map(EntityJpaEntity::getId).toList(); - - // 2. BULK GRAPH FETCH: Execute your optimized set-based join CTE using the - // resolved UUIDs - Map entityMap = entityGraphRepositoryPort.findEntityGraphBatch(rootIds, - effectiveDepth, includeProperties); - - if (entityMap == null || entityMap.isEmpty()) { - return Map.of(); - } - - // 3. INDEX PRE-COMPUTATION: Build lookups once for the entire batch footprint - IndexBundle indices = buildIndices(entityMap); - Map batchResult = new HashMap<>(); - - // 4. SEPARATION LOOP: Construct independent memoized trees for each discovered - // root - for (UUID rootId : rootIds) { - Set activeStack = new HashSet<>(); - Map memoCache = new HashMap<>(); - - GraphTraversalContext ctx = new GraphTraversalContext(entityMap, indices.textToUuidLookup(), - indices.inboundIndex(), includeProperties, propertyFilter, relationFilter, activeStack, - memoCache); - - // Resolve the graph structure using your memory-safe recursive builder - EntityGraphNode tree = buildGraphNode(rootId, ctx); - - // Pull the matching business identifier string to key the final map entry - String businessIdentifier = uuidToIdentifierMap.get(rootId); - if (businessIdentifier != null) { - batchResult.put(businessIdentifier, tree); - } - } - - return batchResult; - } - private EntityGraphNode buildGraphNode(UUID entityUuid, GraphTraversalContext ctx) { Entity entity = ctx.entityMap().get(entityUuid); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java index cd66ac9c..e6dbbe57 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java @@ -15,7 +15,6 @@ import java.util.List; import java.util.Map; import java.util.Set; -import java.util.UUID; import java.util.stream.Collectors; import jakarta.validation.constraints.NotBlank; @@ -98,62 +97,4 @@ public EntityGraphFlatDtoOut getEntityGraph(@PathVariable @NotBlank String templ return EntityGraphFlatDtoOutMapper.toFlatDto(graphNode); } - @PostMapping("/{templateIdentifier}/graph/batch") - @ResponseStatus(OK) - @Operation(summary = "Get multiple entity graphs by business identifiers", description = "Pass a template identifier in the path and up to 20 unique business identifier strings in the body to receive independent graph trees.", responses = { - @ApiResponse(responseCode = OK_CODE, description = "Batch graph retrieval successful")}) - public Map getBatchEntityGraphsByIdentifiers( - @PathVariable @NotBlank String templateIdentifier, - @RequestBody @NotEmpty List identifiers, // Accept text business identifiers - @Parameter(description = PARAM_DEPTH_DESCRIPTION) @RequestParam(defaultValue = "1") int depth, - @Parameter(description = PARAM_INCLUDE_DATA_DESCRIPTION) @RequestParam(name = "include_data", defaultValue = "false") boolean includeData, - @Parameter(description = PARAM_RELATIONS_DESCRIPTION) @RequestParam(required = false) List relations, - @Parameter(description = PARAM_PROPERTIES_DESCRIPTION) @RequestParam(required = false) List properties) { - - // 1. Guard rails for user-friendly UX protection - if (identifiers.size() > 25) { - throw new IllegalArgumentException( - "Batch size exceeds maximum allowed limit of 20 elements."); - } - - Set relationFilter = relations != null ? Set.copyOf(relations) : Set.of(); - Set propertyFilter = properties != null ? Set.copyOf(properties) : Set.of(); - - // 2. Execute service layer using string-based identifiers - Map batchGraphs = entityGraphService.getBatchEntityGraphsByIdentifiers( - templateIdentifier, identifiers, depth, includeData, relationFilter, propertyFilter); - - // 3. Map the memory trees back to flat DTOs, keyed by the input identifier - // strings - return batchGraphs.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey, - entry -> EntityGraphFlatDtoOutMapper.toFlatDto(entry.getValue()))); - } - - @PostMapping("/{templateIdentifier}/graph/batch/raw") - @ResponseStatus(OK) - @Operation(summary = "Get multiple entity graphs by business identifiers", description = "Pass a template identifier in the path and up to 20 unique business identifier strings in the body to receive independent graph trees.", responses = { - @ApiResponse(responseCode = OK_CODE, description = "Batch graph retrieval successful")}) - public Map getBatchEntityGraphsRawByIdentifiers( - @PathVariable @NotBlank String templateIdentifier, - @RequestBody @NotEmpty List identifiers, // Accept text business identifiers - @Parameter(description = PARAM_DEPTH_DESCRIPTION) @RequestParam(defaultValue = "1") int depth, - @Parameter(description = PARAM_INCLUDE_DATA_DESCRIPTION) @RequestParam(name = "include_data", defaultValue = "false") boolean includeData, - @Parameter(description = PARAM_RELATIONS_DESCRIPTION) @RequestParam(required = false) List relations, - @Parameter(description = PARAM_PROPERTIES_DESCRIPTION) @RequestParam(required = false) List properties) { - - // 1. Guard rails for user-friendly UX protection - if (identifiers.size() > 25) { - throw new IllegalArgumentException( - "Batch size exceeds maximum allowed limit of 20 elements."); - } - - Set relationFilter = relations != null ? Set.copyOf(relations) : Set.of(); - Set propertyFilter = properties != null ? Set.copyOf(properties) : Set.of(); - - // 2. Execute service layer using string-based identifiers - return entityGraphService.getBatchEntityGraphsByIdentifiers(templateIdentifier, identifiers, - depth, includeData, relationFilter, propertyFilter); - - } - } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java index ae05a76a..8abd8ca7 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java @@ -26,9 +26,11 @@ /// following the Interface Segregation Principle. /// /// **Query strategy:** -/// 1. One recursive CTE query to collect all (identifier, template_identifier) pairs in the graph. +/// 1. One recursive CTE query to collect all (identifier, template_identifier) +/// pairs in the graph. /// 2. One batch query to load entities with their relations (avoids N+1). -/// 3. One batch query to load properties separately (avoids MultipleBagFetchException). +/// 3. One batch query to load properties separately +/// (avoids MultipleBagFetchException). @Component @RequiredArgsConstructor public class PostgresEntityGraphAdapter implements EntityGraphRepositoryPort { @@ -53,7 +55,7 @@ public Map findEntityGraph(UUID entityId, int depth, boolean inclu // so nodes reachable via any path are included even if the filter only matches // edges at deeper levels (e.g. filtering "owns" still returns B→C when A→B→C). final long tStartCte = System.nanoTime(); - List graphPairs = jpaEntityRepository.findEntityGraphIdentifiers(entityId, depth); + List graphPairs = jpaEntityRepository.findEntityUuidsInGraph(entityId, depth); final long tAfterCte = System.nanoTime(); log.debug("[EntityGraphAdapter] CTE returned {} identifiers (elapsed={}ms)", graphPairs == null ? 0 : graphPairs.size(), (tAfterCte - tStartCte) / 1_000_000); @@ -111,26 +113,4 @@ public Map findEntityGraph(UUID entityId, int depth, boolean inclu return result; } - @Override - @Transactional(readOnly = true) - public Map findEntityGraphBatch(List rootIds, int depth, - boolean includeProperties) { - if (rootIds == null || rootIds.isEmpty()) - return Map.of(); - - // Step 1: Bulk-scout all reachable node IDs across all 20 roots in 1 query - List graphPairs = jpaEntityRepository.findEntityGraphIdentifiersBatch(rootIds, depth); - if (graphPairs == null || graphPairs.isEmpty()) - return Map.of(); - - List entitiesIds = graphPairs.stream().distinct().toList(); - - // Step 2: Bulk-hydrate all objects and their cached relation strings in 1 query - List jpaEntities = jpaEntityRepository - .findAllByIdentifierInWithRelations(entitiesIds); - - // Step 3: Pure O(N) in-memory domain mapping - return jpaEntities.stream().map(mapper::toDomain) - .collect(Collectors.toMap(Entity::id, Function.identity())); - } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java index 47d6ae11..068e2c18 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java @@ -56,7 +56,7 @@ List findAllByIdentifierInWithProperties( @Query(value = """ WITH RECURSIVE entity_graph(id, depth) AS ( - -- 1. ANCHOR MEMBER: Start with your specific root entity UUID + -- 1. ANCHOR MEMBER: Start with the specific root entity UUID SELECT CAST(:entityId AS UUID), 0 UNION -- Frontier propagation: automatically eliminates path duplicates at each step @@ -86,43 +86,7 @@ CROSS JOIN LATERAL ( -- 3. LEAN RETURN: Extract only the unique raw UUIDs discovered in the network skeleton SELECT DISTINCT id FROM entity_graph; """, nativeQuery = true) - List findEntityGraphIdentifiers(@Param("entityId") UUID entityId, - @Param("depth") int depth); - - @Query(value = """ - WITH RECURSIVE entity_graph(id, depth) AS ( - -- 1. BATCH ANCHOR MEMBER: Initialize the unique initial root IDs - SELECT DISTINCT e.id, 0 - FROM idp_core.entity e - WHERE e.id IN (:rootIds) - - UNION -- Handles distinct node/depth state filtering at each step - - -- 2. RECURSIVE MEMBER: Upgraded from LATERAL loops to a Bulk Set-Based Join - SELECT combined.id, eg.depth + 1 - FROM entity_graph eg - JOIN ( - -- Bulk Outbound Adjacency Map - SELECT er.entity_id AS source_id, rte.target_entity_uuid AS id - FROM idp_core.entity_relations er - JOIN idp_core.relation_target_entities rte ON rte.relation_id = er.relation_id - WHERE rte.target_entity_uuid IS NOT NULL - - UNION ALL - - -- Bulk Inbound Adjacency Map - SELECT rte.target_entity_uuid AS source_id, er.entity_id AS id - FROM idp_core.relation_target_entities rte - JOIN idp_core.entity_relations er ON er.relation_id = rte.relation_id - ) combined ON combined.source_id = eg.id - -- Enforce the depth budget parameter cleanly - WHERE eg.depth < :depth - ) - -- 3. LEAN RETURN: Extract unique raw UUIDs discovered across all universes - SELECT DISTINCT id FROM entity_graph; - """, nativeQuery = true) - List findEntityGraphIdentifiersBatch(@Param("rootIds") List rootIds, - @Param("depth") int depth); + List findEntityUuidsInGraph(@Param("entityId") UUID entityId, @Param("depth") int depth); @Modifying(clearAutomatically = true, flushAutomatically = true) @Query(""" From 158a82c7e57abffcd54dd4c9923025da17c60625 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Wed, 10 Jun 2026 15:01:42 +0200 Subject: [PATCH 33/53] feat(core): add a entity graph service and endpoint --- .../entity_graph/EntityGraphServiceTest.java | 81 +++++++++++-------- 1 file changed, 46 insertions(+), 35 deletions(-) diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java index 66419a5a..6033e810 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java @@ -4,8 +4,6 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -25,7 +23,6 @@ import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; import com.decathlon.idp_core.domain.model.entity.Entity; -import com.decathlon.idp_core.domain.model.entity.EntityCompositeKey; import com.decathlon.idp_core.domain.model.entity.Property; import com.decathlon.idp_core.domain.model.entity.Relation; import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; @@ -67,18 +64,34 @@ private Relation relation(String name, String targetTemplateIdentifier, String.. return new Relation(UUID.randomUUID(), name, targetTemplateIdentifier, List.of(targetIds)); } - private EntityCompositeKey key(String templateIdentifier, String identifier) { - return new EntityCompositeKey(templateIdentifier, identifier); - } - private static final String TEMPLATE = "web-service"; - // --- Helper to stub both ports --- + // --- Helper to stub the graph repository port --- + + private void stubGraph(Entity... entities) { + Map entityMap = Map.of(); + if (entities.length == 1) { + entityMap = Map.of(entities[0].id(), entities[0]); + } else if (entities.length == 2) { + entityMap = Map.of(entities[0].id(), entities[0], entities[1].id(), entities[1]); + } else if (entities.length == 3) { + entityMap = Map.of(entities[0].id(), entities[0], entities[1].id(), entities[1], + entities[2].id(), entities[2]); + } else if (entities.length > 3) { + // For more than 3 entities, build map manually + var builder = new java.util.HashMap(); + for (Entity e : entities) { + builder.put(e.id(), e); + } + entityMap = builder; + } + + when(entityGraphRepositoryPort.findEntityGraph(anyUUID(), anyInt(), anyBoolean())) + .thenReturn(entityMap); + } - private void stubGraph(Map entityMap) { - when( - entityGraphRepositoryPort.findEntityGraph(anyString(), anyString(), anyInt(), anyBoolean())) - .thenReturn(entityMap); + private UUID anyUUID() { + return org.mockito.ArgumentMatchers.any(UUID.class); } // ======================== @@ -95,8 +108,9 @@ void shouldThrowWhenRootEntityNotFound() { assertThatThrownBy(() -> entityGraphService.getEntityGraph(TEMPLATE, "missing", 1, false, Set.of(), Set.of())).isInstanceOf(EntityNotFoundException.class); - verify(entityGraphRepositoryPort, never()).findEntityGraph(anyString(), anyString(), anyInt(), - anyBoolean()); + // verify(entityGraphRepositoryPort, never()).findEntityGraph(anyUuid(), + // anyInt(), + // anyBoolean()); } } @@ -111,7 +125,7 @@ void shouldReturnLeafNodeWhenNoRelations() { Entity api = entity(TEMPLATE, "api", "API Service"); when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(api)); - stubGraph(Map.of(key(TEMPLATE, "api"), api)); + stubGraph(api); EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, Set.of(), Set.of()); @@ -137,7 +151,7 @@ void shouldResolveOutboundRelations() { when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(api)); - stubGraph(Map.of(key(TEMPLATE, "api"), api, key("database", "postgres"), postgres)); + stubGraph(api, postgres); EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, Set.of(), Set.of()); @@ -156,7 +170,7 @@ void shouldReturnFallbackNodeWhenTargetNotInMap() { when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(api)); - stubGraph(Map.of(key(TEMPLATE, "api"), api)); + stubGraph(api); EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, Set.of(), Set.of()); @@ -181,7 +195,7 @@ void shouldResolveInboundRelations() { when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(api)); - stubGraph(Map.of(key(TEMPLATE, "api"), api, key(TEMPLATE, "consumer"), consumer)); + stubGraph(api, consumer); EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, Set.of(), Set.of()); @@ -204,11 +218,11 @@ void shouldClampDepthBelowOne() { Entity api = entity(TEMPLATE, "api", "API Service"); when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(api)); - stubGraph(Map.of(key(TEMPLATE, "api"), api)); + stubGraph(api); entityGraphService.getEntityGraph(TEMPLATE, "api", 0, false, Set.of(), Set.of()); - verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "api", 1, false); + verify(entityGraphRepositoryPort).findEntityGraph(api.id(), 1, false); } @Test @@ -217,11 +231,11 @@ void shouldClampDepthAboveTen() { Entity api = entity(TEMPLATE, "api", "API Service"); when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(api)); - stubGraph(Map.of(key(TEMPLATE, "api"), api)); + stubGraph(api); entityGraphService.getEntityGraph(TEMPLATE, "api", 99, false, Set.of(), Set.of()); - verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "api", 10, false); + verify(entityGraphRepositoryPort).findEntityGraph(api.id(), 10, false); } } @@ -241,8 +255,7 @@ void shouldReturnLeafNodeAtDepthBoundary() { when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(api)); - stubGraph(Map.of(key(TEMPLATE, "api"), api, key("database", "postgres"), postgres, - key("infra", "server-1"), server)); + stubGraph(api, postgres, server); EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, Set.of(), Set.of()); @@ -270,8 +283,7 @@ void shouldResolveMultipleNamedRelations() { when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(api)); - stubGraph(Map.of(key(TEMPLATE, "api"), api, key("database", "postgres"), postgres, - key(TEMPLATE, "auth"), auth)); + stubGraph(api, postgres, auth); EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, Set.of(), Set.of()); @@ -298,7 +310,7 @@ void shouldFilterRelationsByName() { when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "a")) .thenReturn(Optional.of(a)); - stubGraph(Map.of(key(TEMPLATE, "a"), a, key(TEMPLATE, "b"), b, key(TEMPLATE, "c"), c)); + stubGraph(a, b, c); EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 2, false, Set.of("depends-on"), Set.of()); @@ -317,7 +329,7 @@ void shouldReturnAllRelationsWhenFilterIsEmpty() { when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "a")) .thenReturn(Optional.of(a)); - stubGraph(Map.of(key(TEMPLATE, "a"), a, key(TEMPLATE, "b"), b, key(TEMPLATE, "c"), c)); + stubGraph(a, b, c); EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 2, false, Set.of(), Set.of()); @@ -338,8 +350,7 @@ void shouldFilterInboundRelationsByName() { when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(api)); - stubGraph(Map.of(key(TEMPLATE, "api"), api, key(TEMPLATE, "consumer"), consumer, - key(TEMPLATE, "unrelated"), unrelated)); + stubGraph(api, consumer, unrelated); EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, Set.of("depends-on"), Set.of()); @@ -370,7 +381,7 @@ void shouldFilterPropertiesByName() { when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(api)); - stubGraph(Map.of(key(TEMPLATE, "api"), api)); + stubGraph(api); EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, true, Set.of(), Set.of("env")); @@ -389,7 +400,7 @@ void shouldReturnAllPropertiesWhenFilterIsEmpty() { when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(api)); - stubGraph(Map.of(key(TEMPLATE, "api"), api)); + stubGraph(api); EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, true, Set.of(), Set.of()); @@ -405,7 +416,7 @@ void shouldReturnEmptyPropertiesWhenIncludePropertiesIsFalse() { when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(api)); - stubGraph(Map.of(key(TEMPLATE, "api"), api)); + stubGraph(api); EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, Set.of(), Set.of("env")); @@ -431,7 +442,7 @@ void shouldNotExplodeAtMaxDepthWithSmallGraph() { when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "a")) .thenReturn(Optional.of(a)); - stubGraph(Map.of(key(TEMPLATE, "a"), a, key(TEMPLATE, "b"), b, key(TEMPLATE, "c"), c)); + stubGraph(a, b, c); // Must complete instantly — any OOM or StackOverflow here means the guard is // missing. @@ -451,7 +462,7 @@ void shouldReturnStubLeafForRevisitedNode() { when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "a")) .thenReturn(Optional.of(a)); - stubGraph(Map.of(key(TEMPLATE, "a"), a, key(TEMPLATE, "b"), b)); + stubGraph(a, b); EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 5, false, Set.of(), Set.of()); From 6439463630714b4e6777f70188ce953dc23f2829 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Wed, 10 Jun 2026 17:22:05 +0200 Subject: [PATCH 34/53] feat(core): add a entity graph service and endpoint --- pom.xml | 8 +- .../domain/port/EntityRepositoryPort.java | 3 - .../api/handler/ApiExceptionHandler.java | 2 +- .../persistence/PostgresEntityAdapter.java | 10 +- .../repository/JpaEntityRepository.java | 20 ++-- .../repository/JpaRelationRepository.java | 30 ++++-- .../test/R__2_Insert_entities_test_data.sql | 99 +++++++++---------- 7 files changed, 94 insertions(+), 78 deletions(-) diff --git a/pom.xml b/pom.xml index 68cb35a5..6926f667 100644 --- a/pom.xml +++ b/pom.xml @@ -289,7 +289,7 @@ - + - + com.github.spotbugs 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 d84b6e37..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 @@ -47,9 +47,6 @@ Optional findByTemplateIdentifierAndIdentifier(String templateIdentifier Page findByTemplateIdentifierWithFilter(String templateIdentifier, EntityFilter filter, Pageable pageable); -// List findAllByTemplateIdentifierAndIdentifierIn(String templateIdentifier, -// List identifiers); - List findByIdentifierIn(List identifiers); List findByRelationIdIn(List relationIds); 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 56f773fd..b8aba726 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 @@ -323,7 +323,7 @@ public ResponseEntity handleConstraintViolationException( String errorMessage = ex.getConstraintViolations().stream().map(ConstraintViolation::getMessage) .collect(Collectors.joining(", ")); return createErrorResponse(HttpStatus.BAD_REQUEST, errorMessage); - } + } /// Handles domain exception when entity deletion is blocked by required /// relations. /// 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 cad15b48..06d67ace 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 @@ -97,10 +97,12 @@ public void deleteRelationsByTemplateIdentifierAndRelationName(String templateId } // @Override - // public List findAllByTemplateIdentifierAndIdentifierIn(String templateIdentifier, - // List identifiers) { - // return jpaEntityRepository.findAllByTemplateIdentifierAndIdentifierIn(templateIdentifier, - // identifiers); + // public List + // findAllByTemplateIdentifierAndIdentifierIn(String templateIdentifier, + // List identifiers) { + // return + // jpaEntityRepository.findAllByTemplateIdentifierAndIdentifierIn(templateIdentifier, + // identifiers); // } public PaginatedResult search(SearchFilterNode filter, String query, diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java index dde686cd..2ed98494 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java @@ -116,13 +116,19 @@ void deleteRelationsByTemplateIdentifierAndRelationName( List findAllByTemplateIdentifierAndIdentifierIn(String templateIdentifier, List identifiers); - @Query(""" - SELECT entity - FROM EntityJpaEntity entity - JOIN entity.relations relation - JOIN relation.targetEntityIdentifiers targetEntityIdentifier - WHERE targetEntityIdentifier = :targetIdentifier - """) + + /** + * Find all entities that have relations pointing to the given target + * identifier. Uses a native query for better control over the join strategy. + */ + @Query(value = """ + SELECT DISTINCT e.* + FROM idp_core.entity e + JOIN idp_core.entity_relations er ON er.entity_id = e.id + JOIN idp_core.relation_target_entities rte ON rte.relation_id = er.relation_id + JOIN idp_core.entity target ON target.id = rte.target_entity_uuid + WHERE target.identifier = :targetIdentifier + """, nativeQuery = true) List findEntitiesRelated(@Param("targetIdentifier") String targetIdentifier); void deleteByTemplateIdentifierAndIdentifier( diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaRelationRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaRelationRepository.java index 37802d94..2e590a35 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaRelationRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaRelationRepository.java @@ -14,15 +14,27 @@ @Repository public interface JpaRelationRepository extends JpaRepository { - @Query(""" - SELECT new com.decathlon.idp_core.domain.model.entity.RelationAsTargetSummary( - te.targetEntityIdentifier, r.name, e.identifier, e.name - ) - FROM EntityJpaEntity e - JOIN e.relations r - JOIN r.targetEntities te - WHERE te.targetEntityIdentifier IN :targetEntityIdentifiers - """) + /** + * Find relation summaries where the given entity identifiers are targets. Uses + * a native query to efficiently join through the relation_target_entities + * table. + * + * @param targetEntityIdentifiers + * List of entity identifiers to search for + * @return List of relation summaries where these entities are targets + */ + @Query(value = """ + SELECT + rte.target_entity_identifier AS targetIdentifier, + r.name AS relationName, + e.identifier AS sourceIdentifier, + e.name AS sourceName + FROM idp_core.entity e + JOIN idp_core.entity_relations er ON er.entity_id = e.id + JOIN idp_core.relation r ON r.id = er.relation_id + JOIN idp_core.relation_target_entities rte ON rte.relation_id = r.id + WHERE rte.target_entity_identifier IN :targetEntityIdentifiers + """, nativeQuery = true) List findRelationsSummariesByTargetEntityIdentifiers( @Param("targetEntityIdentifiers") List targetEntityIdentifiers); } diff --git a/src/test/resources/db/test/R__2_Insert_entities_test_data.sql b/src/test/resources/db/test/R__2_Insert_entities_test_data.sql index 7bd0da45..99919848 100644 --- a/src/test/resources/db/test/R__2_Insert_entities_test_data.sql +++ b/src/test/resources/db/test/R__2_Insert_entities_test_data.sql @@ -23,12 +23,6 @@ VALUES ('550e8400-e29b-41d4-a716-446655440113', 'monitoring-service-5', 'Monitoring Service 5', 'monitoring-service'), ('550e8400-e29b-41d4-a716-446655440114', 'monitoring-service-6', 'Monitoring Service 6', 'monitoring-service'); --- ----------------------------------------------------------------------- --- Properties for query filter tests (web-api-1 and web-api-2) --- ----------------------------------------------------------------------- - --- Properties for web-api-1 (programmingLanguage=JAVA, environment=PROD, port=8080) -INSERT INTO property (id, name, value) -- Properties for default-team entity INSERT INTO idp_core.property (id, name, value) VALUES @@ -76,59 +70,76 @@ VALUES ('aa000000-0000-0000-0000-000000000002', 'environment', 'PROD'), ('aa000000-0000-0000-0000-000000000005', 'port', '8080'); -INSERT INTO entity_properties (entity_id, property_id) +INSERT INTO idp_core.entity_properties (entity_id, property_id) VALUES ('550e8400-e29b-41d4-a716-446655440100', 'aa000000-0000-0000-0000-000000000001'), ('550e8400-e29b-41d4-a716-446655440100', 'aa000000-0000-0000-0000-000000000002'), ('550e8400-e29b-41d4-a716-446655440100', 'aa000000-0000-0000-0000-000000000005'); --- Properties for web-api-2 (programmingLanguage=PYTHON, environment=DEV, port=9090) -INSERT INTO property (id, name, value) +-- Properties for web-api-2 (language=PYTHON, environment=DEV) +INSERT INTO idp_core.property (id, name, value) VALUES ('aa000000-0000-0000-0000-000000000003', 'programmingLanguage', 'PYTHON'), ('aa000000-0000-0000-0000-000000000004', 'environment', 'DEV'), ('aa000000-0000-0000-0000-000000000006', 'port', '9090'); -INSERT INTO entity_properties (entity_id, property_id) +INSERT INTO idp_core.entity_properties (entity_id, property_id) VALUES ('550e8400-e29b-41d4-a716-446655440101', 'aa000000-0000-0000-0000-000000000003'), ('550e8400-e29b-41d4-a716-446655440101', 'aa000000-0000-0000-0000-000000000004'), ('550e8400-e29b-41d4-a716-446655440101', 'aa000000-0000-0000-0000-000000000006'); --- ----------------------------------------------------------------------- --- Relations for query filter tests (web-api-1 and web-api-2) --- ----------------------------------------------------------------------- +-- Relations for web-api-1 (database -> database-service, targetTemplateIdentifier = database-service) +INSERT INTO idp_core.relation (id, name, target_template_identifier) +VALUES + ('bb000000-0000-0000-0000-000000000001', 'database', 'database-service'); --- database relation for web-api-1 → database-service-1 -INSERT INTO relation (id, name, target_template_identifier) -VALUES ('bb000000-0000-0000-0000-000000000001', 'database', 'database-service'); +INSERT INTO idp_core.relation_target_entities (relation_id, target_entity_identifier) +VALUES + ('bb000000-0000-0000-0000-000000000001', 'database-service-1'); -INSERT INTO relation_target_entities (relation_id, target_entity_uuid) -VALUES ('bb000000-0000-0000-0000-000000000001', '550e8400-e29b-41d4-a716-446655440107'); +INSERT INTO idp_core.entity_relations (entity_id, relation_id) +VALUES + ('550e8400-e29b-41d4-a716-446655440100', 'bb000000-0000-0000-0000-000000000001'); -INSERT INTO entity_relations (entity_id, relation_id) -VALUES ('550e8400-e29b-41d4-a716-446655440100', 'bb000000-0000-0000-0000-000000000001'); +-- Relations for web-api-2 (database -> cache-service, targetTemplateIdentifier = cache-service) +INSERT INTO idp_core.relation (id, name, target_template_identifier) +VALUES + ('bb000000-0000-0000-0000-000000000002', 'database', 'cache-service'); --- database relation for web-api-2 → cache-service-1 -INSERT INTO relation (id, name, target_template_identifier) -VALUES ('bb000000-0000-0000-0000-000000000002', 'database', 'cache-service'); +INSERT INTO idp_core.relation_target_entities (relation_id, target_entity_identifier) +VALUES + ('bb000000-0000-0000-0000-000000000002', 'cache-service-1'); -INSERT INTO relation_target_entities (relation_id, target_entity_uuid) -VALUES ('bb000000-0000-0000-0000-000000000002', '550e8400-e29b-41d4-a716-446655440108'); +INSERT INTO idp_core.entity_relations (entity_id, relation_id) +VALUES + ('550e8400-e29b-41d4-a716-446655440101', 'bb000000-0000-0000-0000-000000000002'); -INSERT INTO entity_relations (entity_id, relation_id) -VALUES ('550e8400-e29b-41d4-a716-446655440101', 'bb000000-0000-0000-0000-000000000002'); +-- api-link relation for web-api-1 targeting microservice-1 (supports q=relation=api-link;relation.api-link.name:microservice) +INSERT INTO idp_core.relation (id, name, target_template_identifier) +VALUES + ('bb000000-0000-0000-0000-000000000003', 'api-link', 'microservice'); --- api-link relation for web-api-1 → microservice-1 -INSERT INTO relation (id, name, target_template_identifier) -VALUES ('bb000000-0000-0000-0000-000000000003', 'api-link', 'microservice'); +INSERT INTO idp_core.relation_target_entities (relation_id, target_entity_identifier) +VALUES + ('bb000000-0000-0000-0000-000000000003', 'microservice-1'); -INSERT INTO relation_target_entities (relation_id, target_entity_uuid) -VALUES ('bb000000-0000-0000-0000-000000000003', '550e8400-e29b-41d4-a716-446655440102'); +INSERT INTO idp_core.entity_relations (entity_id, relation_id) +VALUES + ('550e8400-e29b-41d4-a716-446655440100', 'bb000000-0000-0000-0000-000000000003'); -INSERT INTO entity_relations (entity_id, relation_id) -VALUES ('550e8400-e29b-41d4-a716-446655440100', 'bb000000-0000-0000-0000-000000000003'); +-- required_team relation for test-support-with-required-team targeting test-team-required +INSERT INTO idp_core.relation (id, name, target_template_identifier) +VALUES + ('bb000000-0000-0000-0000-000000000006', 'required_team', 'team'); + +INSERT INTO idp_core.relation_target_entities (relation_id, target_entity_identifier) +VALUES + ('bb000000-0000-0000-0000-000000000006', 'test-team-required'); +INSERT INTO idp_core.entity_relations (entity_id, relation_id) +VALUES + ('550e8400-e29b-41d4-a716-446655440117', 'bb000000-0000-0000-0000-000000000006'); -- ----------------------------------------------------------------------- -- Graph test data: 3-level chain of entities connected via two relation @@ -163,11 +174,11 @@ VALUES ('bb000002-0000-0000-0000-000000000001', 'uses', 'web-service'); -- Target entity identifiers for each relation -INSERT INTO relation_target_entities (relation_id, target_entity_uuid) +INSERT INTO relation_target_entities (relation_id, target_entity_identifier, target_entity_uuid) VALUES - ('bb000001-0000-0000-0000-000000000001', 'aa000001-0000-0000-0000-000000000002'), -- a -[uses]-> b - ('bb000001-0000-0000-0000-000000000002', 'aa000001-0000-0000-0000-000000000002'), -- a -[monitors]-> b - ('bb000002-0000-0000-0000-000000000001', 'aa000001-0000-0000-0000-000000000003'); -- b -[uses]-> c + ('bb000001-0000-0000-0000-000000000001', 'graph-svc-b', 'aa000001-0000-0000-0000-000000000002'), -- a -[uses]-> b + ('bb000001-0000-0000-0000-000000000002', 'graph-svc-b', 'aa000001-0000-0000-0000-000000000002'), -- a -[monitors]-> b + ('bb000002-0000-0000-0000-000000000001', 'graph-svc-c', 'aa000001-0000-0000-0000-000000000003'); -- b -[uses]-> c -- Link relations to their owner entities INSERT INTO entity_relations (entity_id, relation_id) @@ -207,15 +218,3 @@ VALUES ('aa000001-0000-0000-0000-000000000002', 'cc000001-0000-0000-0000-000000000004'), -- b.version ('aa000001-0000-0000-0000-000000000003', 'cc000001-0000-0000-0000-000000000005'), -- c.tier ('aa000001-0000-0000-0000-000000000003', 'cc000001-0000-0000-0000-000000000006'); -- c.version - ('550e8400-e29b-41d4-a716-446655440100', 'bb000000-0000-0000-0000-000000000003'); - --- required_team relation for test-support-with-required-team targeting test-team-required -INSERT INTO idp_core.relation (id, name, target_template_identifier) -VALUES - ('bb000000-0000-0000-0000-000000000006', 'required_team', 'team'); -INSERT INTO idp_core.relation_target_entities (relation_id, target_entity_identifier) -VALUES - ('bb000000-0000-0000-0000-000000000006', 'test-team-required'); -INSERT INTO idp_core.entity_relations (entity_id, relation_id) -VALUES - ('550e8400-e29b-41d4-a716-446655440117', 'bb000000-0000-0000-0000-000000000006'); From b56ecfc3785f8ce17223b60aaac5baf571aeeb56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Wed, 10 Jun 2026 18:01:23 +0200 Subject: [PATCH 35/53] feat(core): add a entity graph service and endpoint --- .../port/EntityGraphRepositoryPort.java | 1 - .../entity_graph/EntityGraphService.java | 2 - .../api/controller/EntityGraphController.java | 6 +- .../persistence/PostgresEntityAdapter.java | 9 --- .../model/entity/RelationJpaEntity.java | 2 - .../EntityFilterSpecification.java | 76 +++++++++++-------- .../EntitySearchSpecification.java | 22 +++--- 7 files changed, 58 insertions(+), 60 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java index 844a0b15..6de09376 100644 --- a/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java @@ -1,6 +1,5 @@ package com.decathlon.idp_core.domain.port; -import java.util.List; import java.util.Map; import java.util.UUID; diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java index 58b851f6..e130262d 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java @@ -8,7 +8,6 @@ import java.util.Objects; import java.util.Set; import java.util.UUID; -import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -24,7 +23,6 @@ import com.decathlon.idp_core.domain.port.EntityGraphRepositoryPort; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; -import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.EntityJpaEntity; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java index e6dbbe57..65b6d59d 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java @@ -13,18 +13,14 @@ import static org.springframework.http.HttpStatus.OK; import java.util.List; -import java.util.Map; import java.util.Set; -import java.util.stream.Collectors; + import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotEmpty; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; 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 06d67ace..a5638d5b 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 @@ -96,15 +96,6 @@ public void deleteRelationsByTemplateIdentifierAndRelationName(String templateId relationNames); } - // @Override - // public List - // findAllByTemplateIdentifierAndIdentifierIn(String templateIdentifier, - // List identifiers) { - // return - // jpaEntityRepository.findAllByTemplateIdentifierAndIdentifierIn(templateIdentifier, - // identifiers); - // } - public PaginatedResult search(SearchFilterNode filter, String query, PaginationCriteria paginationCriteria) { Specification spec = EntitySearchSpecification.of(filter); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/RelationJpaEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/RelationJpaEntity.java index dcf352a4..6dd82c10 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/RelationJpaEntity.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/RelationJpaEntity.java @@ -1,6 +1,5 @@ package com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity; -import java.util.ArrayList; import java.util.List; import java.util.UUID; @@ -12,7 +11,6 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; -import jakarta.persistence.Index; import jakarta.persistence.JoinColumn; import jakarta.persistence.Table; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntityFilterSpecification.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntityFilterSpecification.java index 24b00341..f72a8ad9 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntityFilterSpecification.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntityFilterSpecification.java @@ -18,6 +18,7 @@ import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.EntityJpaEntity; import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.PropertyJpaEntity; import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.RelationJpaEntity; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.RelationTargetJpaEntity; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -27,14 +28,17 @@ /// **Query strategy:** /// - Attribute criteria use direct predicates on the entity root. /// - Property criteria use an INNER JOIN on the `properties` collection. -/// - Relation name criteria filter entities that have a relation with a specific name. -/// - Relation entity criteria use an INNER JOIN on the `relations` collection and -/// then on the `targetEntityIdentifiers` element collection. -/// - Relation property criteria use an INNER JOIN on the `relations` collection and -/// filter on the specified property (e.g., `name`, `identifier`). -/// - Relations as target name criteria find entities where they are targets of relations -/// with a specific name (requires joining relations and checking targetEntityIdentifiers). -/// - Join-based criteria call `query.distinct(true)` to prevent duplicate rows from +/// - Relation name criteria filter entities that have a relation with a specific +/// name. +/// - Relation entity criteria use an INNER JOIN on the `relations` collection +/// and then on the `targetEntityIdentifiers` element collection. +/// - Relation property criteria use an INNER JOIN on the `relations` collection +/// and filter on the specified property (e.g., `name`, `identifier`). +/// - Relations as target name criteria find entities where they are targets of +/// relations with a specific name (requires joining relations and checking +/// targetEntityIdentifiers). +/// - Join-based criteria call `query.distinct(true)` to prevent duplicate rows +/// from /// - All criteria are combined with AND logic via [Specification#allOf]. /// /// **Security:** The CONTAINS operator escapes SQL LIKE wildcards (`%`, `_`) in @@ -45,16 +49,18 @@ public final class EntityFilterSpecification { private static final String NAME = "name"; private static final String IDENTIFIER = "identifier"; private static final String RELATIONS = "relations"; - private static final String TARGET_ENTITY_IDENTIFIERS = "targetEntityIdentifiers"; + private static final String TARGET_ENTITIES = "targetEntities"; + private static final String TARGET_ENTITY_IDENTIFIER = "targetEntityIdentifier"; + private static final String TEMPLATE_IDENTIFIER = "templateIdentifier"; /// Builds a [Specification] that matches entities belonging to the given - /// template identifier - /// and satisfying all criteria in the given filter. + /// template identifier and satisfying all criteria in the given filter. /// /// @param templateIdentifier the template to scope the query to - /// @param filter the filter to apply; may be empty (no additional predicates) - /// @return a composed [Specification] combining template scope and all filter - /// criteria + /// @param filter the filter to apply; may be empty (no additional + /// predicates) + /// @return a composed [Specification] combining template scope and all + /// filter criteria public static Specification of(String templateIdentifier, EntityFilter filter) { var criteriaSpecs = filter.criteria().stream().map(EntityFilterSpecification::fromCriterion); @@ -63,7 +69,7 @@ public static Specification of(String templateIdentifier, Entit } private static Specification hasTemplateIdentifier(String templateIdentifier) { - return (root, query, cb) -> cb.equal(root.get("templateIdentifier"), templateIdentifier); + return (root, query, cb) -> cb.equal(root.get(TEMPLATE_IDENTIFIER), templateIdentifier); } private static Specification fromCriterion(FilterCriterion criterion) { @@ -96,9 +102,12 @@ private static Specification relationEntitySpec(FilterCriterion return (root, query, cb) -> { query.distinct(true); Join relJoin = root.join(RELATIONS); - Join targetJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); + Join targetJoin = relJoin.join(TARGET_ENTITIES); + return cb.and(cb.equal(relJoin.get(NAME), criterion.key()), - buildPredicate(cb, targetJoin, criterion.operator(), criterion.value())); + // Access the targetEntityIdentifier field within the embeddable + buildPredicate(cb, targetJoin.get(TARGET_ENTITY_IDENTIFIER), criterion.operator(), + criterion.value())); }; } @@ -117,8 +126,9 @@ private static Specification relationPropertySpec(FilterCriteri // Check if the property is a target entity property (identifier, name) if (IDENTIFIER.equals(propertyName) || NAME.equals(propertyName)) { - // Join to target entity identifiers first - Join targetIdJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); + // Join to the embeddable collection + Join targetJoin = relJoin.join(TARGET_ENTITIES); + // Create a subquery to find the actual target entities and filter by their // properties var subquery = query.subquery(String.class); @@ -127,7 +137,8 @@ private static Specification relationPropertySpec(FilterCriteri buildPredicate(cb, subRoot.get(propertyName), criterion.operator(), criterion.value())); return cb.and(cb.equal(relJoin.get(NAME), relationName), - cb.in(targetIdJoin).value(subquery)); + // Access targetEntityIdentifier from the embeddable + cb.in(targetJoin.get(TARGET_ENTITY_IDENTIFIER)).value(subquery)); } else { // Direct relation property (shouldn't happen normally as RelationJpaEntity has // limited properties) @@ -163,25 +174,23 @@ private static Specification relationsAsTargetNameSpec( return (root, query, cb) -> { // Find entities whose identifier appears as a target in any relation whose name // matches. - // Uses a correlated subquery to avoid joining through the entity's own outgoing - // relations. Subquery subquery = query.subquery(String.class); Root relRoot = subquery.from(RelationJpaEntity.class); - Join targetJoin = relRoot.join(TARGET_ENTITY_IDENTIFIERS); - subquery.select(targetJoin) + Join targetJoin = relRoot.join(TARGET_ENTITIES); + + subquery.select(targetJoin.get(TARGET_ENTITY_IDENTIFIER)) // Access embeddable field .where(buildPredicate(cb, relRoot.get(NAME), criterion.operator(), criterion.value())); + return cb.in(root.get(IDENTIFIER)).value(subquery); }; } /// Finds entities whose `identifier` appears as a `targetEntityIdentifier` in - /// any - /// relation whose **source entity** property matches the criterion. + /// any relation whose **source entity** property matches the criterion. /// /// Example: `relations_as_target.api-link.name:microservice` returns entities - /// that - /// are targeted by a `api-link` relation originating from an entity whose name - /// contains "microservice". + /// that are targeted by a `api-link` relation originating from an entity whose + /// name contains "microservice". private static Specification relationsAsTargetPropertySpec( FilterCriterion criterion) { return (root, query, cb) -> { @@ -198,9 +207,12 @@ private static Specification relationsAsTargetPropertySpec( Subquery subquery = query.subquery(String.class); Root sourceRoot = subquery.from(EntityJpaEntity.class); Join relJoin = sourceRoot.join(RELATIONS); - Join targetJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); - subquery.select(targetJoin).where(cb.equal(relJoin.get(NAME), relationName), buildPredicate( - cb, sourceRoot.get(propertyName), criterion.operator(), criterion.value())); + Join targetJoin = relJoin.join(TARGET_ENTITIES); + + subquery.select(targetJoin.get(TARGET_ENTITY_IDENTIFIER)) + .where(cb.equal(relJoin.get(NAME), relationName), buildPredicate(cb, + sourceRoot.get(propertyName), criterion.operator(), criterion.value())); + return cb.in(root.get(IDENTIFIER)).value(subquery); }; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecification.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecification.java index f4d927f2..b1ebe99c 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecification.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntitySearchSpecification.java @@ -16,6 +16,7 @@ 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 com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.RelationTargetJpaEntity; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -54,7 +55,8 @@ public final class EntitySearchSpecification { 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 TARGET_ENTITIES = "targetEntities"; + private static final String TARGET_ENTITY_IDENTIFIER = "targetEntityIdentifier"; 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."; @@ -204,10 +206,10 @@ private static Specification relationEntitySpec(SearchFilterNod var sub = query.subquery(Integer.class); var subRoot = sub.from(EntityJpaEntity.class); var relJoin = subRoot.join(RELATIONS); - var targetJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); + Join targetJoin = relJoin.join(TARGET_ENTITIES); 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())); + buildPredicate(cb, targetJoin.get(TARGET_ENTITY_IDENTIFIER), c.operation(), c.value())); return cb.exists(sub); }; } @@ -221,7 +223,7 @@ private static Specification relationPropertySpec(SearchFilterN var sub = query.subquery(Integer.class); var subRoot = sub.from(EntityJpaEntity.class); var relJoin = subRoot.join(RELATIONS); - var targetIdJoin = relJoin.join(TARGET_ENTITY_IDENTIFIERS); + Join targetJoin = relJoin.join(TARGET_ENTITIES); // Inner scalar subquery: entity identifiers whose identifier/name satisfies the // criterion. @@ -231,7 +233,8 @@ private static Specification relationPropertySpec(SearchFilterN .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)); + cb.equal(relJoin.get(NAME), relationName), + cb.in(targetJoin.get(TARGET_ENTITY_IDENTIFIER)).value(innerSubquery)); return cb.exists(sub); }; } @@ -257,8 +260,8 @@ private static Specification relationsAsTargetNameSpec( 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) + Join targetJoin = relJoin.join(TARGET_ENTITIES); + subquery.select(targetJoin.get(TARGET_ENTITY_IDENTIFIER)) .where(buildPredicate(cb, relJoin.get(NAME), effectiveOp, c.value())); boolean isNegated = c.operation() == SearchOperator.NOT_CONTAINS @@ -284,8 +287,9 @@ private static Specification relationsAsTargetSpec(SearchFilter 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), + Join targetJoin = relJoin.join(TARGET_ENTITIES); + subquery.select(targetJoin.get(TARGET_ENTITY_IDENTIFIER)).where( + cb.equal(relJoin.get(NAME), relationName), buildPredicate(cb, sourceRoot.get(property), c.operation(), c.value())); return cb.in(root.get(IDENTIFIER)).value(subquery); }; From feb530a758b6905324af5f8fb0ba1d6b69a83273 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Thu, 11 Jun 2026 10:36:17 +0200 Subject: [PATCH 36/53] feat(core): add a entity graph service and endpoint --- .../api/controller/EntityGraphController.java | 1 - .../entity_graph/EntityGraphServiceTest.java | 29 ++++--- .../test/R__2_Insert_entities_test_data.sql | 78 ------------------- .../R__3_Insert_graph_entities_test_data.sql | 77 ++++++++++++++++++ 4 files changed, 94 insertions(+), 91 deletions(-) create mode 100644 src/test/resources/db/test/R__3_Insert_graph_entities_test_data.sql diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java index 65b6d59d..bf33f772 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java @@ -15,7 +15,6 @@ import java.util.List; import java.util.Set; - import jakarta.validation.constraints.NotBlank; import org.springframework.validation.annotation.Validated; diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java index 6033e810..b3c7b5b0 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java @@ -2,8 +2,10 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -108,9 +110,8 @@ void shouldThrowWhenRootEntityNotFound() { assertThatThrownBy(() -> entityGraphService.getEntityGraph(TEMPLATE, "missing", 1, false, Set.of(), Set.of())).isInstanceOf(EntityNotFoundException.class); - // verify(entityGraphRepositoryPort, never()).findEntityGraph(anyUuid(), - // anyInt(), - // anyBoolean()); + verify(entityGraphRepositoryPort, never()).findEntityGraph(any(UUID.class), anyInt(), + anyBoolean()); } } @@ -163,7 +164,7 @@ void shouldResolveOutboundRelations() { } @Test - @DisplayName("Should return fallback node when target is not in the pre-loaded entity map") + @DisplayName("Should filter out target when not in the pre-loaded entity map") void shouldReturnFallbackNodeWhenTargetNotInMap() { Entity api = entityWithRelations(TEMPLATE, "api", "API Service", List.of(relation("uses-db", "database", "missing-db"))); @@ -175,9 +176,8 @@ void shouldReturnFallbackNodeWhenTargetNotInMap() { EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, Set.of(), Set.of()); - assertThat(result.relations()).hasSize(1); - EntityGraphNode fallback = result.relations().get(0).targets().get(0); - assertThat(fallback.identifier()).isEqualTo("missing-db"); + // When target entity is not found in the map, it's filtered out + assertThat(result.relations()).isEmpty(); } } @@ -235,7 +235,7 @@ void shouldClampDepthAboveTen() { entityGraphService.getEntityGraph(TEMPLATE, "api", 99, false, Set.of(), Set.of()); - verify(entityGraphRepositoryPort).findEntityGraph(api.id(), 10, false); + verify(entityGraphRepositoryPort).findEntityGraph(api.id(), 20, false); } } @@ -251,20 +251,25 @@ void shouldReturnLeafNodeAtDepthBoundary() { List.of(relation("uses-db", "database", "postgres"))); Entity postgres = entityWithRelations("database", "postgres", "Postgres DB", List.of(relation("runs-on", "infra", "server-1"))); - Entity server = entity("infra", "server-1", "Server 1"); when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(api)); - stubGraph(api, postgres, server); + // At depth=1, only api and postgres should be in the graph (server is beyond + // depth limit) + stubGraph(api, postgres); EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, Set.of(), Set.of()); EntityGraphNode postgresNode = result.relations().get(0).targets().get(0); assertThat(postgresNode.identifier()).isEqualTo("postgres"); - // At depth=1, postgres is a leaf — no further relations resolved + // At depth=1, postgres is a leaf — no further outbound relations resolved assertThat(postgresNode.relations()).isEmpty(); - assertThat(postgresNode.relationsAsTarget()).isEmpty(); + // But it CAN have inbound relations from entities already in the graph (api) + assertThat(postgresNode.relationsAsTarget()).hasSize(1); + assertThat(postgresNode.relationsAsTarget().get(0).name()).isEqualTo("uses-db"); + assertThat(postgresNode.relationsAsTarget().get(0).targets().get(0).identifier()) + .isEqualTo("api"); } } diff --git a/src/test/resources/db/test/R__2_Insert_entities_test_data.sql b/src/test/resources/db/test/R__2_Insert_entities_test_data.sql index 99919848..cb7e8e3e 100644 --- a/src/test/resources/db/test/R__2_Insert_entities_test_data.sql +++ b/src/test/resources/db/test/R__2_Insert_entities_test_data.sql @@ -140,81 +140,3 @@ VALUES INSERT INTO idp_core.entity_relations (entity_id, relation_id) VALUES ('550e8400-e29b-41d4-a716-446655440117', 'bb000000-0000-0000-0000-000000000006'); - --- ----------------------------------------------------------------------- --- Graph test data: 3-level chain of entities connected via two relation --- types ("uses" and "monitors") for integration testing of the graph API. --- --- Graph topology (depth-3 chain): --- graph-svc-a --[uses]--> graph-svc-b --[uses]--> graph-svc-c --- graph-svc-a --[monitors]--> graph-svc-b --- --- This setup allows us to verify: --- 1. Graph traversal works at all depths (not just root level) --- 2. Relation name filtering excludes the correct edges/nodes at every depth --- 3. "uses" filter returns: a → b → c (2 edges, 3 nodes) --- 4. "monitors" filter returns: a → b (1 edge, 2 nodes; c not reachable) --- ----------------------------------------------------------------------- - -INSERT INTO entity (id, identifier, name, template_identifier) -VALUES - ('aa000001-0000-0000-0000-000000000001', 'graph-svc-a', 'Graph Service A', 'web-service'), - ('aa000001-0000-0000-0000-000000000002', 'graph-svc-b', 'Graph Service B', 'web-service'), - ('aa000001-0000-0000-0000-000000000003', 'graph-svc-c', 'Graph Service C', 'web-service'); - --- Relations owned by graph-svc-a: "uses" → b, "monitors" → b -INSERT INTO relation (id, name, target_template_identifier) -VALUES - ('bb000001-0000-0000-0000-000000000001', 'uses', 'web-service'), - ('bb000001-0000-0000-0000-000000000002', 'monitors', 'web-service'); - --- Relation owned by graph-svc-b: "uses" → c -INSERT INTO relation (id, name, target_template_identifier) -VALUES - ('bb000002-0000-0000-0000-000000000001', 'uses', 'web-service'); - --- Target entity identifiers for each relation -INSERT INTO relation_target_entities (relation_id, target_entity_identifier, target_entity_uuid) -VALUES - ('bb000001-0000-0000-0000-000000000001', 'graph-svc-b', 'aa000001-0000-0000-0000-000000000002'), -- a -[uses]-> b - ('bb000001-0000-0000-0000-000000000002', 'graph-svc-b', 'aa000001-0000-0000-0000-000000000002'), -- a -[monitors]-> b - ('bb000002-0000-0000-0000-000000000001', 'graph-svc-c', 'aa000001-0000-0000-0000-000000000003'); -- b -[uses]-> c - --- Link relations to their owner entities -INSERT INTO entity_relations (entity_id, relation_id) -VALUES - ('aa000001-0000-0000-0000-000000000001', 'bb000001-0000-0000-0000-000000000001'), -- a owns "uses" relation - ('aa000001-0000-0000-0000-000000000001', 'bb000001-0000-0000-0000-000000000002'), -- a owns "monitors" relation - ('aa000001-0000-0000-0000-000000000002', 'bb000002-0000-0000-0000-000000000001'); -- b owns "uses" relation - --- ----------------------------------------------------------------------- --- Property data for graph test entities (used by the property-filter tests). --- --- Each graph entity gets two properties: "tier" and "version". --- This lets us verify: --- 1. No filter → both properties appear in node data --- 2. Filter "tier" → only tier present, version absent --- 3. Filter "tier"+"version" → both present --- 4. Filter "non-existent" → data field omitted entirely (NON_EMPTY) --- ----------------------------------------------------------------------- - -INSERT INTO property (id, name, value) -VALUES - -- graph-svc-a - ('cc000001-0000-0000-0000-000000000001', 'tier', 'gold'), - ('cc000001-0000-0000-0000-000000000002', 'version', '1.0.0'), - -- graph-svc-b - ('cc000001-0000-0000-0000-000000000003', 'tier', 'silver'), - ('cc000001-0000-0000-0000-000000000004', 'version', '2.0.0'), - -- graph-svc-c - ('cc000001-0000-0000-0000-000000000005', 'tier', 'bronze'), - ('cc000001-0000-0000-0000-000000000006', 'version', '3.0.0'); - -INSERT INTO entity_properties (entity_id, property_id) -VALUES - ('aa000001-0000-0000-0000-000000000001', 'cc000001-0000-0000-0000-000000000001'), -- a.tier - ('aa000001-0000-0000-0000-000000000001', 'cc000001-0000-0000-0000-000000000002'), -- a.version - ('aa000001-0000-0000-0000-000000000002', 'cc000001-0000-0000-0000-000000000003'), -- b.tier - ('aa000001-0000-0000-0000-000000000002', 'cc000001-0000-0000-0000-000000000004'), -- b.version - ('aa000001-0000-0000-0000-000000000003', 'cc000001-0000-0000-0000-000000000005'), -- c.tier - ('aa000001-0000-0000-0000-000000000003', 'cc000001-0000-0000-0000-000000000006'); -- c.version diff --git a/src/test/resources/db/test/R__3_Insert_graph_entities_test_data.sql b/src/test/resources/db/test/R__3_Insert_graph_entities_test_data.sql new file mode 100644 index 00000000..ec996fba --- /dev/null +++ b/src/test/resources/db/test/R__3_Insert_graph_entities_test_data.sql @@ -0,0 +1,77 @@ +-- ----------------------------------------------------------------------- +-- Graph test data: 3-level chain of entities connected via two relation +-- types ("uses" and "monitors") for integration testing of the graph API. +-- +-- Graph topology (depth-3 chain): +-- graph-svc-a --[uses]--> graph-svc-b --[uses]--> graph-svc-c +-- graph-svc-a --[monitors]--> graph-svc-b +-- +-- This setup allows us to verify: +-- 1. Graph traversal works at all depths (not just root level) +-- 2. Relation name filtering excludes the correct edges/nodes at every depth +-- 3. "uses" filter returns: a → b → c (2 edges, 3 nodes) +-- 4. "monitors" filter returns: a → b (1 edge, 2 nodes; c not reachable) +-- ----------------------------------------------------------------------- + +INSERT INTO entity (id, identifier, name, template_identifier) +VALUES + ('aa000001-0000-0000-0000-000000000001', 'graph-svc-a', 'Graph Service A', 'web-service'), + ('aa000001-0000-0000-0000-000000000002', 'graph-svc-b', 'Graph Service B', 'web-service'), + ('aa000001-0000-0000-0000-000000000003', 'graph-svc-c', 'Graph Service C', 'web-service'); + +-- Relations owned by graph-svc-a: "uses" → b, "monitors" → b +INSERT INTO relation (id, name, target_template_identifier) +VALUES + ('bb000001-0000-0000-0000-000000000001', 'uses', 'web-service'), + ('bb000001-0000-0000-0000-000000000002', 'monitors', 'web-service'); + +-- Relation owned by graph-svc-b: "uses" → c +INSERT INTO relation (id, name, target_template_identifier) +VALUES + ('bb000002-0000-0000-0000-000000000001', 'uses', 'web-service'); + +-- Target entity identifiers for each relation +INSERT INTO relation_target_entities (relation_id, target_entity_identifier, target_entity_uuid) +VALUES + ('bb000001-0000-0000-0000-000000000001', 'graph-svc-b', 'aa000001-0000-0000-0000-000000000002'), -- a -[uses]-> b + ('bb000001-0000-0000-0000-000000000002', 'graph-svc-b', 'aa000001-0000-0000-0000-000000000002'), -- a -[monitors]-> b + ('bb000002-0000-0000-0000-000000000001', 'graph-svc-c', 'aa000001-0000-0000-0000-000000000003'); -- b -[uses]-> c + +-- Link relations to their owner entities +INSERT INTO entity_relations (entity_id, relation_id) +VALUES + ('aa000001-0000-0000-0000-000000000001', 'bb000001-0000-0000-0000-000000000001'), -- a owns "uses" relation + ('aa000001-0000-0000-0000-000000000001', 'bb000001-0000-0000-0000-000000000002'), -- a owns "monitors" relation + ('aa000001-0000-0000-0000-000000000002', 'bb000002-0000-0000-0000-000000000001'); -- b owns "uses" relation + +-- ----------------------------------------------------------------------- +-- Property data for graph test entities (used by the property-filter tests). +-- +-- Each graph entity gets two properties: "tier" and "version". +-- This lets us verify: +-- 1. No filter → both properties appear in node data +-- 2. Filter "tier" → only tier present, version absent +-- 3. Filter "tier"+"version" → both present +-- 4. Filter "non-existent" → data field omitted entirely (NON_EMPTY) +-- ----------------------------------------------------------------------- + +INSERT INTO property (id, name, value) +VALUES + -- graph-svc-a + ('cc000001-0000-0000-0000-000000000001', 'tier', 'gold'), + ('cc000001-0000-0000-0000-000000000002', 'version', '1.0.0'), + -- graph-svc-b + ('cc000001-0000-0000-0000-000000000003', 'tier', 'silver'), + ('cc000001-0000-0000-0000-000000000004', 'version', '2.0.0'), + -- graph-svc-c + ('cc000001-0000-0000-0000-000000000005', 'tier', 'bronze'), + ('cc000001-0000-0000-0000-000000000006', 'version', '3.0.0'); + +INSERT INTO entity_properties (entity_id, property_id) +VALUES + ('aa000001-0000-0000-0000-000000000001', 'cc000001-0000-0000-0000-000000000001'), -- a.tier + ('aa000001-0000-0000-0000-000000000001', 'cc000001-0000-0000-0000-000000000002'), -- a.version + ('aa000001-0000-0000-0000-000000000002', 'cc000001-0000-0000-0000-000000000003'), -- b.tier + ('aa000001-0000-0000-0000-000000000002', 'cc000001-0000-0000-0000-000000000004'), -- b.version + ('aa000001-0000-0000-0000-000000000003', 'cc000001-0000-0000-0000-000000000005'), -- c.tier + ('aa000001-0000-0000-0000-000000000003', 'cc000001-0000-0000-0000-000000000006'); -- c.version From 2366d8844dd9865198df11532c8398f54e37e715 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Thu, 11 Jun 2026 10:44:06 +0200 Subject: [PATCH 37/53] feat(core): add a entity graph service and endpoint --- pom.xml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pom.xml b/pom.xml index 6926f667..68cb35a5 100644 --- a/pom.xml +++ b/pom.xml @@ -289,7 +289,7 @@ - + - + com.github.spotbugs From ce459791667a97755a33051745c0476cbf380bdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Thu, 11 Jun 2026 11:04:56 +0200 Subject: [PATCH 38/53] feat(core): add a entity graph service and endpoint --- .../InvalidEntityCompositeKeyException.java | 28 ------------- .../model/entity/EntityCompositeKey.java | 40 ------------------- .../domain/model/entity/EntityGraphNode.java | 0 .../model/entity/EntityGraphRelation.java | 0 .../port/EntityGraphRepositoryPort.java | 2 - .../entity_graph/EntityGraphService.java | 8 +--- .../api/handler/ApiExceptionHandler.java | 12 ------ .../entity/EntityGraphFlatDtoOutMapper.java | 1 - 8 files changed, 1 insertion(+), 90 deletions(-) delete mode 100644 src/main/java/com/decathlon/idp_core/domain/exception/entity/InvalidEntityCompositeKeyException.java delete mode 100644 src/main/java/com/decathlon/idp_core/domain/model/entity/EntityCompositeKey.java delete mode 100644 src/main/java/com/decathlon/idp_core/domain/model/entity/EntityGraphNode.java delete mode 100644 src/main/java/com/decathlon/idp_core/domain/model/entity/EntityGraphRelation.java diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity/InvalidEntityCompositeKeyException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity/InvalidEntityCompositeKeyException.java deleted file mode 100644 index 737db865..00000000 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity/InvalidEntityCompositeKeyException.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.decathlon.idp_core.domain.exception.entity; - -/// Exception thrown when an entity composite key format is invalid. -/// -/// Composite keys must follow the format "templateIdentifier:identifier" -/// where both parts are non-empty strings separated by a single colon. -/// -/// **Business context:** -/// - Composite keys are used to uniquely identify entities across templates -/// - The same identifier can exist in multiple templates, so both parts are required -/// - This exception indicates a malformed key that cannot be parsed -/// -/// **HTTP mapping:** 400 Bad Request (client error — invalid input format) -public class InvalidEntityCompositeKeyException extends RuntimeException { - - private final String invalidKey; - - public InvalidEntityCompositeKeyException(String invalidKey) { - super(String.format( - "Invalid entity composite key format: '%s'. Expected format: 'templateIdentifier:identifier'", - invalidKey)); - this.invalidKey = invalidKey; - } - - public String getInvalidKey() { - return invalidKey; - } -} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityCompositeKey.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityCompositeKey.java deleted file mode 100644 index 9449d5c6..00000000 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityCompositeKey.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.decathlon.idp_core.domain.model.entity; - -import java.util.Objects; - -import com.decathlon.idp_core.domain.exception.entity.InvalidEntityCompositeKeyException; - -/** - * Composite key for uniquely identifying an entity across templates. Since the - * same identifier can exist in different templates, we need both fields. - */ -public record EntityCompositeKey(String templateIdentifier, String identifier) { - public static EntityCompositeKey fromString(String compositeKey) { - String[] parts = compositeKey.split(":", 2); - if (parts.length != 2) { - throw new InvalidEntityCompositeKeyException(compositeKey); - } - return new EntityCompositeKey(parts[0], parts[1]); - } - - @Override - public String toString() { - return templateIdentifier + ":" + identifier; - } - - @Override - public boolean equals(Object o) { - if (this == o) - return true; - if (o == null || getClass() != o.getClass()) - return false; - EntityCompositeKey that = (EntityCompositeKey) o; - return Objects.equals(templateIdentifier, that.templateIdentifier) - && Objects.equals(identifier, that.identifier); - } - - @Override - public int hashCode() { - return Objects.hash(templateIdentifier, identifier); - } -} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityGraphNode.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityGraphNode.java deleted file mode 100644 index e69de29b..00000000 diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityGraphRelation.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityGraphRelation.java deleted file mode 100644 index e69de29b..00000000 diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java index 6de09376..59a6ead6 100644 --- a/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java @@ -4,7 +4,6 @@ import java.util.UUID; import com.decathlon.idp_core.domain.model.entity.Entity; -import com.decathlon.idp_core.domain.model.entity.EntityCompositeKey; /// Driven port defining the contract for entity relationship graph retrieval. /// @@ -35,7 +34,6 @@ public interface EntityGraphRepositoryPort { /// its template /// @param depth the maximum traversal depth (1-10) /// @param includeProperties when true, entity properties are loaded along with - /// @return map of [EntityCompositeKey] to [Entity] for O(1) lookup; empty if /// root not found /// Relation name filtering is intentionally NOT pushed into this port. /// The CTE always traverses all relation types so that nodes reachable via diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java index e130262d..aaa1b4d6 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java @@ -246,13 +246,7 @@ private static record IndexBundle(Map textToUuidLookup private static record GraphTraversalContext(Map entityMap, Map textToUuidLookup, Map>> inboundIndex, boolean includeProperties, - Set propertyFilter, Set relationFilter, Set activeStack, // Tracks - // current - // parent - // line to - // block - // infinite - // loops + Set propertyFilter, Set relationFilter, Set activeStack, Map memoCache // High-speed in-memory reuse cache ) { } 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 b8aba726..9a21a6d0 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 @@ -24,7 +24,6 @@ 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.InvalidEntityCompositeKeyException; import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateAlreadyExistsException; import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateIdentifierCannotChangeException; import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNameAlreadyExistsException; @@ -260,17 +259,6 @@ public ResponseEntity handleEntityValidationException( return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); } - /// Handles domain exception when entity composite key format is invalid. - /// - /// **HTTP mapping:** Maps domain InvalidEntityCompositeKeyException to HTTP 400 - /// status indicating client provided a malformed composite key string. - @ExceptionHandler(InvalidEntityCompositeKeyException.class) - public ResponseEntity handleInvalidEntityCompositeKeyException( - InvalidEntityCompositeKeyException ex) { - log.warn("Invalid entity composite key format: {}", ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); - } - /// Handles Spring MVC request body validation failures. /// /// **Field-level errors:** Extracts and aggregates field validation errors from diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java index 83d57854..a7d2f5d8 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java @@ -121,7 +121,6 @@ private static void addEdge(TraversalState state, String sourceId, String target /// Builds the unique node identifier from the entity's composite key. /// Format: "templateIdentifier:identifier" — mirrors - /// EntityCompositeKey.toString(). private static String nodeId(String templateIdentifier, String identifier) { return templateIdentifier + ":" + identifier; } From 6094b8e1040af4e32ab663ffeaf7e60d97365fbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Thu, 11 Jun 2026 18:31:56 +0200 Subject: [PATCH 39/53] feat(core): add a entity graph service and endpoint --- docs/src/static/swagger.yaml | 1041 ++++++++++------- .../EntityGraphTraversalMode.java | 15 + .../port/EntityGraphRepositoryPort.java | 4 +- .../entity_graph/EntityGraphService.java | 7 +- .../api/configuration/SwaggerDescription.java | 1 + .../api/controller/EntityGraphController.java | 5 +- .../PostgresEntityGraphAdapter.java | 16 +- .../repository/JpaEntityRepository.java | 63 +- .../entity_graph/EntityGraphServiceTest.java | 50 +- 9 files changed, 721 insertions(+), 481 deletions(-) create mode 100644 src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphTraversalMode.java diff --git a/docs/src/static/swagger.yaml b/docs/src/static/swagger.yaml index c856af84..85343a4c 100644 --- a/docs/src/static/swagger.yaml +++ b/docs/src/static/swagger.yaml @@ -6,252 +6,382 @@ info: servers: - url: http://localhost:8084 security: - - clientId: [] - - bearer: [] +- clientId: [] +- bearer: [] tags: - - name: Entities Management - description: Operations related to entity management - - name: Entities Templates Management - description: Operations related to entity template management +- name: Entity Graph + description: Entity relationship graph operations +- name: Entities Management + description: Operations related to entity management +- name: Entities Templates Management + description: Operations related to entity template management paths: - /api/v1/entity-templates/{identifier}: + "/api/v1/entity-templates/{identifier}": get: tags: - - Entities Templates Management + - Entities Templates Management summary: Get template by identifier description: Retrieve a specific template using its string identifier operationId: getTemplateByIdentifier parameters: - - name: identifier - in: path - required: true - schema: - type: string + - name: identifier + in: path + required: true + schema: + type: string responses: '200': description: Template found content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/EntityTemplateDtoOut' + "$ref": "#/components/schemas/EntityTemplateDtoOut" '404': description: Template not found with the provided identifier content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' + "$ref": "#/components/schemas/ErrorResponse" put: tags: - - Entities Templates Management + - Entities Templates Management summary: Update an existing template by template identifier description: Update the details of an existing template identified by its unique string identifier operationId: updateTemplate parameters: - - name: identifier - in: path - required: true - schema: - type: string - requestBody: + - name: identifier + in: path required: true + schema: + type: string + requestBody: content: application/json: schema: - $ref: '#/components/schemas/EntityTemplateUpdateDtoIn' + "$ref": "#/components/schemas/EntityTemplateUpdateDtoIn" + required: true responses: '200': description: Template update successfully content: - '*/*': + "*/*": + schema: + "$ref": "#/components/schemas/EntityTemplateDtoOut" + '404': + description: Template not found with the provided identifier + content: + "*/*": + schema: + "$ref": "#/components/schemas/ErrorResponse" + delete: + tags: + - Entities Templates Management + summary: Delete template by identifier + description: Remove a template from the system using its unique identifier + operationId: deleteTemplate + parameters: + - name: identifier + in: path + required: true + schema: + type: string + responses: + '204': + description: Template deleted successfully + '404': + description: Template not found with the provided identifier + content: + "*/*": + schema: + "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/entities/{templateIdentifier}/{entityIdentifier}": + get: + tags: + - Entities Management + summary: Get entity by entity template and identifier + description: Retrieve a specific entity using its string identifier and its + template identifier + operationId: getEntity + parameters: + - name: templateIdentifier + in: path + required: true + schema: + type: string + - name: entityIdentifier + in: path + required: true + schema: + type: string + responses: + '200': + description: Entity found + content: + "*/*": + schema: + "$ref": "#/components/schemas/EntityDtoOut" + '404': + description: Entity not found with the provided identifier + content: + "*/*": + schema: + "$ref": "#/components/schemas/ErrorResponse" + put: + tags: + - Entities Management + summary: Update an existing entity + description: Update an existing entity in the system with the provided information + operationId: updateEntity + parameters: + - name: templateIdentifier + in: path + required: true + schema: + type: string + minLength: 1 + - name: entityIdentifier + in: path + required: true + schema: + type: string + minLength: 1 + requestBody: + content: + application/json: + schema: + "$ref": "#/components/schemas/EntityUpdateDtoIn" + required: true + responses: + '200': + description: Entity updated successfully + content: + "*/*": schema: - $ref: '#/components/schemas/EntityTemplateDtoOut' + "$ref": "#/components/schemas/EntityDtoOut" '400': - description: Invalid template data provided + description: Invalid entity data provided content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' + "$ref": "#/components/schemas/ErrorResponse" '401': description: Unauthorized - Missing or invalid token '403': description: Insufficient rights '404': - description: Template not found with the provided identifier - content: - '*/*': - schema: - $ref: '#/components/schemas/ErrorResponse' - '409': - description: Template with this identifier already exists + description: Entity not found with the provided identifier content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' + "$ref": "#/components/schemas/ErrorResponse" '500': description: Unexpected server-side failure content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' + "$ref": "#/components/schemas/ErrorResponse" delete: tags: - - Entities Templates Management - summary: Delete template by identifier - description: Remove a template from the system using its unique identifier - operationId: deleteTemplate + - Entities Management + summary: Delete an existing entity + description: Delete an entity from the system using its template and entity + identifiers. This operation removes the entity and automatically cleans up + any relations from other entities that reference it. + operationId: deleteEntity parameters: - - name: identifier - in: path - required: true - schema: - type: string + - name: templateIdentifier + in: path + required: true + schema: + type: string + minLength: 1 + - name: entityIdentifier + in: path + required: true + schema: + type: string + minLength: 1 responses: '204': - description: Template deleted successfully + description: Entity deleted successfully + '400': + description: Invalid entity data provided + content: + "*/*": + schema: + "$ref": "#/components/schemas/ErrorResponse" + '401': + description: Unauthorized - Missing or invalid token + '403': + description: Insufficient rights '404': - description: Template not found with the provided identifier + description: Entity not found with the provided identifier content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' - /api/v1/entity-templates: + "$ref": "#/components/schemas/ErrorResponse" + '409': + description: Target entity has required relations + content: + "*/*": + schema: + "$ref": "#/components/schemas/ErrorResponse" + '500': + description: Unexpected server-side failure + content: + "*/*": + schema: + "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/entity-templates": get: tags: - - Entities Templates Management + - Entities Templates Management summary: Get paginated templates description: Retrieve a paginated list of templates with optional sorting operationId: getTemplatesPaginated parameters: - - name: page - in: query - description: Page number for pagination. Defaults to 0. - content: - '*/*': - schema: - type: integer - default: '0' - - name: size - in: query - description: Number of items per page. Defaults to 20. - content: - '*/*': - schema: - type: integer - default: '20' - - name: sort - in: query - description: 'Sorting criteria in the format: property(,asc|desc). Defaults to - identifier,asc.' - content: - '*/*': - schema: - type: string - default: identifier,asc + - name: page + in: query + description: Page number for pagination. Defaults to 0. + content: + "*/*": + schema: + type: integer + default: '0' + - name: size + in: query + description: Number of items per page. Defaults to 20. + content: + "*/*": + schema: + type: integer + default: '20' + - name: sort + in: query + description: 'Sorting criteria in the format: property(,asc|desc). Defaults + to identifier,asc.' + content: + "*/*": + schema: + type: string + default: identifier,asc responses: '200': description: Paginated templates retrieved successfully content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/TemplatePageResponse' + "$ref": "#/components/schemas/TemplatePageResponse" '400': description: Invalid pagination parameters content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' + "$ref": "#/components/schemas/ErrorResponse" post: tags: - - Entities Templates Management + - Entities Templates Management summary: Create a new template description: Create a new template in the system with the provided information operationId: createTemplate requestBody: - required: true content: application/json: schema: - $ref: '#/components/schemas/EntityTemplateCreateDtoIn' + "$ref": "#/components/schemas/EntityTemplateCreateDtoIn" + required: true responses: '201': description: Template created successfully content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/EntityTemplateDtoOut' + "$ref": "#/components/schemas/EntityTemplateDtoOut" '400': description: Invalid template data provided content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' - /api/v1/entities/{templateIdentifier}: + "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/entities/{templateIdentifier}": get: tags: - - Entities Management + - Entities Management summary: Get entities by template identifier description: Retrieve a paginated list of entities with optional sorting operationId: getEntities parameters: - - name: page - in: query - required: false - description: Page number for pagination. Defaults to 0. - content: - '*/*': - schema: - type: integer - default: '0' - - name: size - in: query - required: false - description: Number of items per page. Defaults to 20. - content: - '*/*': - schema: - type: integer - default: '20' - - name: templateIdentifier - in: path - required: true - schema: - type: string - - name: sort - in: query - description: 'Sorting criteria in the format: property(,asc|desc). Defaults to - identifier,asc.' - content: - '*/*': - schema: - type: string - default: identifier,asc + - name: page + in: query + description: Page number for pagination. Defaults to 0. + required: false + content: + "*/*": + schema: + type: integer + default: '0' + - name: size + in: query + description: Number of items per page. Defaults to 20. + required: false + content: + "*/*": + schema: + type: integer + default: '20' + - name: templateIdentifier + in: path + required: true + schema: + type: string + - name: q + in: query + description: 'Optional filter query using a simple expression language. See + more details in the API documentation. Example: `name:idp` for entities + with names containing ''idp''. + + ' + required: false + content: + "*/*": + schema: + type: string + - name: sort + in: query + description: 'Sorting criteria in the format: property(,asc|desc). Defaults + to identifier,asc.' + content: + "*/*": + schema: + type: string + default: identifier,asc responses: '200': description: Paginated entities retrieved successfully content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/EntityPageResponse' + "$ref": "#/components/schemas/EntityPageResponse" '400': - description: Invalid pagination parameters + description: Invalid filter query syntax content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' + "$ref": "#/components/schemas/ErrorResponse" post: tags: - - Entities Management + - Entities Management summary: Create a new entity description: Create a new entity in the system with the provided information operationId: createEntity parameters: - - in: path - name: templateIdentifier + - name: templateIdentifier + in: path required: true schema: - minLength: 1 type: string + minLength: 1 requestBody: content: application/json: @@ -260,207 +390,150 @@ paths: required: true responses: '201': + description: Entity created successfully content: "*/*": schema: "$ref": "#/components/schemas/EntityDtoOut" - description: Entity created successfully '400': + description: Invalid entity data provided content: "*/*": schema: "$ref": "#/components/schemas/ErrorResponse" - description: Invalid entity data provided '401': description: Unauthorized - Missing or invalid token '403': description: Insufficient rights '404': + description: Template not found with the provided identifier content: "*/*": schema: "$ref": "#/components/schemas/ErrorResponse" - description: Template not found with the provided identifier '409': + description: Entity already exists in this template content: "*/*": schema: "$ref": "#/components/schemas/ErrorResponse" - description: Entity already exists in this template '500': + description: Unexpected server-side failure content: "*/*": schema: "$ref": "#/components/schemas/ErrorResponse" - description: Unexpected server-side failure - /api/v1/entities/{templateIdentifier}/{entityIdentifier}: - get: - tags: - - Entities Management - summary: Get entity by entity template and identifier - description: Retrieve a specific entity using its string identifier and its - template identifier - operationId: getEntity - parameters: - - name: templateIdentifier - in: path - required: true - schema: - type: string - - name: entityIdentifier - in: path - required: true - schema: - type: string - responses: - '200': - description: Entity found - content: - '*/*': - schema: - $ref: '#/components/schemas/EntityDtoOut' - '404': - description: Entity not found with the provided identifier - content: - '*/*': - schema: - $ref: '#/components/schemas/ErrorResponse' - put: + "/api/v1/entities/search": + post: tags: - - Entities Management - summary: Update an existing entity - description: Update an existing entity in the system with the provided information - operationId: updateEntity - parameters: - - name: templateIdentifier - in: path - required: true - schema: - minLength: 1 - type: string - - name: entityIdentifier - in: path - required: true - schema: - minLength: 1 - type: string + - Entities Management + summary: Search entities + 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. + operationId: searchEntities requestBody: - required: true content: application/json: schema: - $ref: '#/components/schemas/EntityUpdateDtoIn' - example: - name: my-web-service-updated - properties: - applicationName: catalog-api - ownerEmail: owner@example.com - port: '8080' - environment: DEV - version: 1.2.3 - teamName: platform-team - baseUrl: https://catalog.example.com - protocol: HTTP - programmingLanguage: JAVA - relations: - - name: depends-on - target_entity_identifiers: - - web-api-1 + "$ref": "#/components/schemas/EntitySearchRequestDtoIn" + required: true responses: '200': - description: Entity updated successfully + description: Entities retrieved successfully content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/EntityDtoOut' - example: - identifier: my-web-service - name: my-web-service-updated - template_identifier: web-service - properties: - applicationName: catalog-api - ownerEmail: owner@example.com - port: '8080' - environment: DEV - version: 1.2.3 - teamName: platform-team - baseUrl: https://catalog.example.com - protocol: HTTP - programmingLanguage: JAVA - relations: {} - relations_as_target: {} + "$ref": "#/components/schemas/EntityPageResponse" '400': - description: Invalid entity data provided - content: - '*/*': - schema: - $ref: '#/components/schemas/ErrorResponse' - '401': - description: Unauthorized - Missing or invalid token - '403': - description: Insufficient rights - '404': - description: Entity not found with the provided identifier - content: - '*/*': - schema: - $ref: '#/components/schemas/ErrorResponse' - '500': - description: Unexpected server-side failure + description: Invalid search filter content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' - delete: + "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/entities/{templateIdentifier}/{entityIdentifier}/graph": + get: tags: - - Entities Management - summary: Delete an existing entity - description: Delete an entity from the system using its template and entity identifiers. This operation removes the entity and automatically cleans up any relations from other entities that reference it. - operationId: deleteEntity + - Entity Graph + summary: Get entity relationship graph as flat nodes and edges + description: Retrieves the entity relationship graph as a flat nodes-and-edges + structure, suitable for frontend visualization tools such as React Flow, Vis.js, + and Cytoscape. + operationId: getEntityGraph parameters: - - name: templateIdentifier - in: path - required: true - schema: - minLength: 1 + - name: templateIdentifier + in: path + required: true + schema: + type: string + minLength: 1 + - name: entityIdentifier + in: path + required: true + schema: + type: string + minLength: 1 + - name: depth + in: query + description: Maximum traversal depth for relationship resolution. Clamped + between 1 and 10. + required: false + schema: + type: integer + format: int32 + default: 1 + - name: include_data + in: query + description: When true, each graph node includes a data object containing + the entity's property values. Defaults to false. + required: false + schema: + type: boolean + default: false + - name: traversal_mode + in: query + description: Specifies the traversal mode for the entity graph. Defaults to + STRICT_LINEAGE. + required: false + schema: + type: string + default: STRICT_LINEAGE + enum: + - STRICT_LINEAGE + - BIDIRECTIONAL + - OUTBOUND_ONLY + - name: relations + in: query + description: When provided, only relations whose name matches one of the listed + values are traversed and included. Omit to include all relations. + required: false + schema: + type: array + items: type: string - - name: entityIdentifier - in: path - required: true - schema: - minLength: 1 + - name: properties + in: query + description: When provided, each node's data object is restricted to the listed + property names. Requires include_data=true to have any effect. Omit to include + all properties. + required: false + schema: + type: array + items: type: string responses: - '204': - description: Entity deleted successfully - '400': - description: Invalid identifiers provided + '200': + description: Flat entity graph successfully retrieved content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' - '401': - description: Unauthorized - Missing or invalid token - '403': - description: Insufficient rights + "$ref": "#/components/schemas/EntityGraphFlatDtoOut" '404': - description: Entity or template not found with the provided identifier - content: - '*/*': - schema: - $ref: '#/components/schemas/ErrorResponse' - '409': - description: Target entity has required relations - content: - '*/*': - schema: - $ref: '#/components/schemas/ErrorResponse' - '500': - description: Unexpected server-side failure + description: Entity not found with the provided identifier content: - '*/*': + "*/*": schema: - $ref: '#/components/schemas/ErrorResponse' + "$ref": "#/components/schemas/ErrorResponse" components: schemas: EntityTemplateUpdateDtoIn: @@ -472,8 +545,8 @@ components: description: Unique Entity Template name example: Service maxLength: 255 - minLength: 1 - pattern: ^[a-zA-Z0-9 _-]+$ + minLength: 0 + pattern: "^[a-zA-Z0-9 _-]+$" description: type: string description: Entity Template description @@ -482,14 +555,14 @@ components: type: array description: List of property definitions for this template items: - $ref: '#/components/schemas/PropertyDefinitionDtoIn' + "$ref": "#/components/schemas/PropertyDefinitionDtoIn" relations_definitions: type: array description: List of relation definitions for this template items: - $ref: '#/components/schemas/RelationDefinitionDtoIn' + "$ref": "#/components/schemas/RelationDefinitionDtoIn" required: - - name + - name PropertyDefinitionDtoIn: type: object description: Input DTO for creating or updating a property definition @@ -508,22 +581,22 @@ components: type: string description: Property data type enum: - - STRING - - NUMBER - - BOOLEAN + - STRING + - NUMBER + - BOOLEAN example: STRING required: type: boolean + default: false description: Whether this property is required example: true - default: false rules: - $ref: '#/components/schemas/PropertyRulesDtoIn' + "$ref": "#/components/schemas/PropertyRulesDtoIn" description: Property validation rules required: - - description - - name - - type + - description + - name + - type PropertyRulesDtoIn: type: object description: Input DTO for creating or updating a property definition @@ -532,21 +605,21 @@ components: type: string description: Property format validation enum: - - URL - - EMAIL + - URL + - EMAIL example: EMAIL enum_values: type: array description: Enumeration values for enum properties example: - - ACTIVE - - INACTIVE + - ACTIVE + - INACTIVE items: type: string regex: type: string description: Regular expression pattern for validation - example: ^[a-zA-Z0-9]+$ + example: "^[a-zA-Z0-9]+$" max_length: type: integer format: int32 @@ -583,17 +656,17 @@ components: minLength: 1 required: type: boolean + default: false description: Whether this relation is required example: false - default: false to_many: type: boolean + default: false description: Whether this relation can have multiple targets example: true - default: false required: - - name - - target_template_identifier + - name + - target_template_identifier EntityTemplateDtoOut: type: object description: Output for entity template @@ -614,12 +687,12 @@ components: type: array description: List of property definitions for this template items: - $ref: '#/components/schemas/PropertyDefinitionDtoOut' + "$ref": "#/components/schemas/PropertyDefinitionDtoOut" relations_definitions: type: array description: List of relation definitions for this template items: - $ref: '#/components/schemas/RelationDefinitionDtoOut' + "$ref": "#/components/schemas/RelationDefinitionDtoOut" PropertyDefinitionDtoOut: type: object description: Output DTO for property definition @@ -636,16 +709,16 @@ components: type: string description: Property data type enum: - - STRING - - NUMBER - - BOOLEAN + - STRING + - NUMBER + - BOOLEAN example: STRING required: type: boolean description: Whether this property is required example: true rules: - $ref: '#/components/schemas/PropertyRulesDtoOut' + "$ref": "#/components/schemas/PropertyRulesDtoOut" description: Property validation rules example: Property validation rules PropertyRulesDtoOut: @@ -656,21 +729,21 @@ components: type: string description: Format of the property enum: - - URL - - EMAIL + - URL + - EMAIL example: STRING enum_values: type: array description: Allowed enum values for the property example: - - VALUE1 - - VALUE2 + - VALUE1 + - VALUE2 items: type: string regex: type: string description: Regular expression for property validation - example: ^[A-Za-z0-9]+$ + example: "^[A-Za-z0-9]+$" max_length: type: integer format: int32 @@ -718,72 +791,9 @@ components: type: string errorDescription: type: string - EntityTemplateCreateDtoIn: - type: object - description: Input DTO for creating an entity template - properties: - identifier: - type: string - description: Unique Entity Template identifier - example: service - minLength: 1 - name: - type: string - description: Unique Entity Template name - example: Service - maxLength: 255 - minLength: 0 - pattern: ^[a-zA-Z0-9 _-]+$ - description: - type: string - description: Entity Template description - example: A comprehensive service template - properties_definitions: - type: array - description: List of property definitions for this template - items: - $ref: '#/components/schemas/PropertyDefinitionDtoIn' - relations_definitions: - type: array - description: List of relation definitions for this template - items: - $ref: '#/components/schemas/RelationDefinitionDtoIn' - required: - - identifier - - name - EntityCreateDtoIn: - type: object - description: Input DTO for creating or updating an entity - properties: - name: - type: string - description: Name of the entity - example: my-web-service - minLength: 1 - identifier: - type: string - description: Unique identifier of the entity within the template scope - example: my-web-service - minLength: 1 - properties: - type: object - additionalProperties: - type: string - description: Map of property name to value for this entity - example: - port: '8080' - environment: dev - relations: - type: array - description: List of relations for this entity - items: - $ref: '#/components/schemas/RelationDtoIn' - required: - - identifier - - name EntityUpdateDtoIn: type: object - description: Input DTO for creating or updating an entity + description: Input DTO for updating an entity properties: name: type: string @@ -802,9 +812,9 @@ components: type: array description: List of relations for this entity items: - $ref: '#/components/schemas/RelationDtoIn' + "$ref": "#/components/schemas/RelationDtoIn" required: - - name + - name RelationDtoIn: type: object description: Input DTO for an entity relation instance @@ -818,13 +828,13 @@ components: type: array description: List of target entity identifiers for this relation example: - - web-api-1 - - web-api-2 + - web-api-1 + - web-api-2 items: type: string required: - - name - - target_entity_identifiers + - name + - target_entity_identifiers EntityDtoOut: type: object properties: @@ -842,13 +852,13 @@ components: additionalProperties: type: array items: - $ref: '#/components/schemas/EntitySummaryDto' + "$ref": "#/components/schemas/EntitySummaryDto" relations_as_target: type: object additionalProperties: type: array items: - $ref: '#/components/schemas/EntitySummaryDto' + "$ref": "#/components/schemas/EntitySummaryDto" EntitySummaryDto: type: object properties: @@ -856,43 +866,143 @@ components: type: string name: type: string - PageableObject: + EntityTemplateCreateDtoIn: type: object + description: Input DTO for creating an entity template properties: - offset: - type: integer - format: int64 - paged: - type: boolean - pageNumber: + identifier: + type: string + description: Unique Entity Template identifier + example: service + minLength: 1 + name: + type: string + description: Unique Entity Template name + example: Service + maxLength: 255 + minLength: 0 + pattern: "^[a-zA-Z0-9 _-]+$" + description: + type: string + description: Entity Template description + example: A comprehensive service template + properties_definitions: + type: array + description: List of property definitions for this template + items: + "$ref": "#/components/schemas/PropertyDefinitionDtoIn" + relations_definitions: + type: array + description: List of relation definitions for this template + items: + "$ref": "#/components/schemas/RelationDefinitionDtoIn" + required: + - identifier + - name + EntityCreateDtoIn: + type: object + description: Input DTO for creating an entity + properties: + identifier: + type: string + description: Unique identifier of the entity within the template scope + example: my-web-service + minLength: 1 + name: + type: string + description: Name of the entity + example: my-web-service + minLength: 1 + properties: + type: object + additionalProperties: + type: string + description: Map of property name to value for this entity + example: + port: '8080' + environment: dev + relations: + type: array + description: List of relations for this entity + items: + "$ref": "#/components/schemas/RelationDtoIn" + required: + - identifier + - name + EntitySearchRequestDtoIn: + type: object + description: Request body for the POST /api/v1/entities/search endpoint + properties: + query: + type: string + description: Free-text search string. When present, returns entities whose + identifier, name, templateIdentifier, or any property value contains this + string (case-insensitive). Can be combined with filter. + example: checkout + filter: + "$ref": "#/components/schemas/FilterNodeDtoIn" + description: Root node of the search filter tree. May be omitted or null + to return all entities. + page: type: integer format: int32 - pageSize: + default: 0 + description: Page number for pagination. Defaults to 0. + example: 0 + size: type: integer format: int32 + default: 20 + description: Number of items per page. Defaults to 20. + example: 20 sort: - $ref: '#/components/schemas/SortObject' - unpaged: - type: boolean - SortObject: + type: string + description: 'Sorting criteria in the format: property(,asc|desc). Defaults + to identifier,asc.' + example: identifier:asc + FilterNodeDtoIn: type: object + description: A node in the search filter tree. Either a logical group (connector + + criteria) or a leaf criterion (field + operation + value). properties: - empty: - type: boolean - sorted: - type: boolean - unsorted: - type: boolean - TemplatePageResponse: + connector: + type: string + description: 'Logical connector for a group node. One of: AND, OR. Required + for group nodes.' + example: AND + criteria: + type: array + description: Child filter nodes for a group node. Required for group nodes + (must be non-empty). + items: + "$ref": "#/components/schemas/FilterNodeDtoIn" + field: + type: string + description: 'Field to filter on for a criterion node. Required for leaf + nodes. Examples: template, identifier, name, relation, property.language, + relation.api-link, relation.api-link.identifier, relations_as_target.api-link.name' + example: template + operation: + type: string + description: 'Filter operation for a criterion node. One of: EQ, NEQ, CONTAINS, + NOT_CONTAINS, STARTS_WITH, ENDS_WITH, GT, GTE, LT, LTE. Required for leaf + nodes.' + example: EQ + value: + type: string + description: Value to compare against for a criterion node. Required for + leaf nodes. + example: microservice + EntityPageResponse: type: object - description: Paginated response containing Template objects + description: Paginated response containing Entity objects properties: content: type: array items: - $ref: '#/components/schemas/EntityTemplateDtoOut' + "$ref": "#/components/schemas/EntityDtoOut" pageable: - $ref: '#/components/schemas/PageableObject' + "$ref": "#/components/schemas/PageableObject" totalElements: type: integer format: int64 @@ -901,31 +1011,58 @@ components: format: int32 last: type: boolean + sort: + "$ref": "#/components/schemas/SortObject" + numberOfElements: + type: integer + format: int32 + first: + type: boolean size: type: integer format: int32 number: type: integer format: int32 - sort: - $ref: '#/components/schemas/SortObject' - first: + empty: type: boolean - numberOfElements: + PageableObject: + type: object + properties: + unpaged: + type: boolean + paged: + type: boolean + pageNumber: + type: integer + format: int32 + pageSize: type: integer format: int32 + sort: + "$ref": "#/components/schemas/SortObject" + offset: + type: integer + format: int64 + SortObject: + type: object + properties: + sorted: + type: boolean + unsorted: + type: boolean empty: type: boolean - EntityPageResponse: + TemplatePageResponse: type: object - description: Paginated response containing Entity objects + description: Paginated response containing Template objects properties: content: type: array items: - $ref: '#/components/schemas/EntityDtoOut' + "$ref": "#/components/schemas/EntityTemplateDtoOut" pageable: - $ref: '#/components/schemas/PageableObject' + "$ref": "#/components/schemas/PageableObject" totalElements: type: integer format: int64 @@ -934,21 +1071,69 @@ components: format: int32 last: type: boolean - size: - type: integer - format: int32 - number: + sort: + "$ref": "#/components/schemas/SortObject" + numberOfElements: type: integer format: int32 - sort: - $ref: '#/components/schemas/SortObject' first: type: boolean - numberOfElements: + size: + type: integer + format: int32 + number: type: integer format: int32 empty: type: boolean + EntityGraphEdgeDtoOut: + type: object + properties: + id: + type: string + description: Unique edge identifier + source: + type: string + description: Node id of the source entity + target: + type: string + description: Node id of the target entity + type: + type: string + description: Relation name as defined in the entity template + EntityGraphFlatDtoOut: + type: object + properties: + nodes: + type: array + description: All entity nodes in the graph + items: + "$ref": "#/components/schemas/EntityGraphNodeFlatDtoOut" + edges: + type: array + description: All directed relation edges in the graph + items: + "$ref": "#/components/schemas/EntityGraphEdgeDtoOut" + EntityGraphNodeFlatDtoOut: + type: object + properties: + id: + type: string + description: Unique node identifier composed of templateIdentifier:identifier + label: + type: string + description: Human-readable entity name + template_identifier: + type: string + description: Template identifier this entity belongs to + identifier: + type: string + description: Business identifier of the entity within its template + data: + type: object + additionalProperties: {} + description: Entity property values keyed by property name; present only + when include_data=true is requested securitySchemes: clientId: type: oauth2 diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphTraversalMode.java b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphTraversalMode.java new file mode 100644 index 00000000..deafd9b7 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphTraversalMode.java @@ -0,0 +1,15 @@ +package com.decathlon.idp_core.domain.model.entity_graph; + +/// Defines the traversal mode for entity graph queries. +/// +/// - **STRICT_LINEAGE**: Follow only outbound relations (forward dependencies) +/// - **BIDIRECTIONAL**: Follow both outbound and inbound relations (full graph) +/// - **OUTBOUND_ONLY**: Follow only outbound relations without inbound lookups +public enum EntityGraphTraversalMode { + STRICT_LINEAGE, BIDIRECTIONAL, OUTBOUND_ONLY; + + // @Override + // public String toString() { + // return name(); + // } +} \ No newline at end of file diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java index 59a6ead6..87ed1cc9 100644 --- a/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java @@ -4,6 +4,7 @@ import java.util.UUID; import com.decathlon.idp_core.domain.model.entity.Entity; +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphTraversalMode; /// Driven port defining the contract for entity relationship graph retrieval. /// @@ -39,6 +40,7 @@ public interface EntityGraphRepositoryPort { /// The CTE always traverses all relation types so that nodes reachable via /// any path are loaded. Edge filtering is applied in the service layer so /// that "filter owns" still returns B and C when A→(depends-on)→B→(owns)→C. - Map findEntityGraph(UUID entityId, int depth, boolean includeProperties); + Map findEntityGraph(UUID entityId, int depth, boolean includeProperties, + EntityGraphTraversalMode mode); } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java index aaa1b4d6..14245512 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java @@ -20,6 +20,7 @@ import com.decathlon.idp_core.domain.model.entity.Relation; import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphRelation; +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphTraversalMode; import com.decathlon.idp_core.domain.port.EntityGraphRepositoryPort; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; @@ -62,8 +63,8 @@ public class EntityGraphService { @Transactional(readOnly = true) public EntityGraphNode getEntityGraph(String templateIdentifier, String entityIdentifier, - int depth, boolean includeProperties, Set relationFilter, - Set propertyFilter) { + int depth, boolean includeProperties, Set relationFilter, Set propertyFilter, + EntityGraphTraversalMode mode) { final long tStartTotal = System.nanoTime(); int effectiveDepth = Math.clamp(depth, 1, MAX_DEPTH); @@ -79,7 +80,7 @@ public EntityGraphNode getEntityGraph(String templateIdentifier, String entityId // 2. Load the graph footprint via optimized DB calls (Takes ~150ms) final long tStartRepo = System.nanoTime(); Map entityMap = entityGraphRepositoryPort.findEntityGraph(rootEntity.id(), - effectiveDepth, includeProperties); + effectiveDepth, includeProperties, mode); final long tAfterRepo = System.nanoTime(); if (entityMap == null || entityMap.isEmpty()) { 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 2e06d901..ded0bd4c 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 @@ -192,6 +192,7 @@ public class SwaggerDescription { public static final String PARAM_INCLUDE_DATA_DESCRIPTION = "When true, each graph node includes a data object containing the entity's property values. Defaults to false."; public static final String PARAM_RELATIONS_DESCRIPTION = "When provided, only relations whose name matches one of the listed values are traversed and included. Omit to include all relations."; public static final String PARAM_PROPERTIES_DESCRIPTION = "When provided, each node's data object is restricted to the listed property names. Requires include_data=true to have any effect. Omit to include all properties."; + public static final String PARAM_TRAVERSAL_MODE_DESCRIPTION = "Specifies the traversal mode for the entity graph. Defaults to STRICT_LINEAGE."; /// Search API endpoint constants public static final String ENDPOINT_POST_SEARCH_SUMMARY = "Search entities"; public static final String ENDPOINT_POST_SEARCH_DESCRIPTION = """ diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java index bf33f772..02354bf9 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java @@ -8,6 +8,7 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_INCLUDE_DATA_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_PROPERTIES_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_RELATIONS_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_TRAVERSAL_MODE_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_GRAPH_FLAT_SUCCESS; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER; import static org.springframework.http.HttpStatus.OK; @@ -26,6 +27,7 @@ import org.springframework.web.bind.annotation.RestController; import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphTraversalMode; import com.decathlon.idp_core.domain.service.entity_graph.EntityGraphService; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityGraphFlatDtoOut; import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse; @@ -79,6 +81,7 @@ public EntityGraphFlatDtoOut getEntityGraph(@PathVariable @NotBlank String templ @PathVariable @NotBlank String entityIdentifier, @Parameter(description = PARAM_DEPTH_DESCRIPTION) @RequestParam(defaultValue = "1") int depth, @Parameter(description = PARAM_INCLUDE_DATA_DESCRIPTION) @RequestParam(name = "include_data", defaultValue = "false") boolean includeData, + @Parameter(description = PARAM_TRAVERSAL_MODE_DESCRIPTION) @RequestParam(name = "traversal_mode", defaultValue = "STRICT_LINEAGE") EntityGraphTraversalMode mode, @Parameter(description = PARAM_RELATIONS_DESCRIPTION) @RequestParam(required = false) List relations, @Parameter(description = PARAM_PROPERTIES_DESCRIPTION) @RequestParam(required = false) List properties) { @@ -87,7 +90,7 @@ public EntityGraphFlatDtoOut getEntityGraph(@PathVariable @NotBlank String templ Set propertyFilter = properties != null ? Set.copyOf(properties) : Set.of(); EntityGraphNode graphNode = entityGraphService.getEntityGraph(templateIdentifier, - entityIdentifier, depth, includeData, relationFilter, propertyFilter); + entityIdentifier, depth, includeData, relationFilter, propertyFilter, mode); return EntityGraphFlatDtoOutMapper.toFlatDto(graphNode); } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java index 8abd8ca7..beddfdf4 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java @@ -12,6 +12,7 @@ import org.springframework.transaction.annotation.Transactional; import com.decathlon.idp_core.domain.model.entity.Entity; +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphTraversalMode; import com.decathlon.idp_core.domain.port.EntityGraphRepositoryPort; import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.EntityPersistenceMapper; import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.EntityJpaEntity; @@ -42,7 +43,8 @@ public class PostgresEntityGraphAdapter implements EntityGraphRepositoryPort { @Override @Transactional(readOnly = true) - public Map findEntityGraph(UUID entityId, int depth, boolean includeProperties) { + public Map findEntityGraph(UUID entityId, int depth, boolean includeProperties, + EntityGraphTraversalMode mode) { log.debug( "[EntityGraphAdapter] findEntityGraph start: entityId={}, depth={}, includeProperties={}", entityId, depth, includeProperties); @@ -55,7 +57,8 @@ public Map findEntityGraph(UUID entityId, int depth, boolean inclu // so nodes reachable via any path are included even if the filter only matches // edges at deeper levels (e.g. filtering "owns" still returns B→C when A→B→C). final long tStartCte = System.nanoTime(); - List graphPairs = jpaEntityRepository.findEntityUuidsInGraph(entityId, depth); + List graphPairs = jpaEntityRepository.findEntityUuidsInGraph(entityId, depth, + mode.name()); final long tAfterCte = System.nanoTime(); log.debug("[EntityGraphAdapter] CTE returned {} identifiers (elapsed={}ms)", graphPairs == null ? 0 : graphPairs.size(), (tAfterCte - tStartCte) / 1_000_000); @@ -74,11 +77,10 @@ public Map findEntityGraph(UUID entityId, int depth, boolean inclu entitiesIds.size(), (tAfterIdExtract - tStartIdExtract) / 1_000_000); // Step 3: batch-load entities with relations, then optionally properties in a - // separate - // query. Properties are skipped when not requested to avoid the extra - // round-trip and - // keep payloads lean. The two-query split also avoids Hibernate's - // MultipleBagFetchException. + // separate query. + // Properties are skipped when not requested to avoid the extra round-trip and + // keep payloads lean. + // The two-query split also avoids Hibernate's MultipleBagFetchException. log.debug("[EntityGraphAdapter] Loading JPA entities with relations..."); final long tStartJpaLoad = System.nanoTime(); List jpaEntities = jpaEntityRepository diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java index 2ed98494..ee3c425e 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java @@ -55,38 +55,63 @@ List findAllByIdentifierInWithProperties( @Param("identifiers") Collection ids); @Query(value = """ - WITH RECURSIVE entity_graph(id, depth) AS ( - -- 1. ANCHOR MEMBER: Start with the specific root entity UUID - SELECT CAST(:entityId AS UUID), 0 + WITH RECURSIVE entity_graph(id, depth, flow) AS ( + -- 1. ANCHOR MEMBER: Initialize state tokens for a single root entity + SELECT e.id, 0, 'OUTBOUND' AS flow + FROM idp_core.entity e + WHERE e.id = :rootId AND :mode IN ('STRICT_LINEAGE', 'OUTBOUND_ONLY') - UNION -- Frontier propagation: automatically eliminates path duplicates at each step + UNION - -- 2. RECURSIVE MEMBER: Scan indexed schema tables via direct binary matches - SELECT neighbor.id, eg.depth + 1 + SELECT e.id, 0, 'INBOUND' AS flow + FROM idp_core.entity e + WHERE e.id = :rootId AND :mode = 'STRICT_LINEAGE' + + UNION + + SELECT e.id, 0, 'ANY' AS flow + FROM idp_core.entity e + WHERE e.id = :rootId AND :mode = 'BIDIRECTIONAL' + + UNION + + -- 2. RECURSIVE MEMBER: Propagate isolated pathways down the graph footprint + SELECT combined.id, eg.depth + 1, eg.flow FROM entity_graph eg - CROSS JOIN LATERAL ( - -- Track A: Outbound direction (this entity -> targets) - SELECT rte.target_entity_uuid AS id + JOIN ( + -- Outbound Paths + SELECT er.entity_id AS source_id, rte.target_entity_uuid AS id, 'OUTBOUND' AS flow_match FROM idp_core.entity_relations er JOIN idp_core.relation_target_entities rte ON rte.relation_id = er.relation_id - WHERE er.entity_id = eg.id - AND rte.target_entity_uuid IS NOT NULL + WHERE rte.target_entity_uuid IS NOT NULL UNION ALL - -- Track B: Inbound direction (sources -> this entity as target) - SELECT er.entity_id AS id + SELECT er.entity_id AS source_id, rte.target_entity_uuid AS id, 'ANY' AS flow_match + FROM idp_core.entity_relations er + JOIN idp_core.relation_target_entities rte ON rte.relation_id = er.relation_id + WHERE rte.target_entity_uuid IS NOT NULL + + UNION ALL + + -- Inbound Paths + SELECT rte.target_entity_uuid AS source_id, er.entity_id AS id, 'INBOUND' AS flow_match FROM idp_core.relation_target_entities rte JOIN idp_core.entity_relations er ON er.relation_id = rte.relation_id - WHERE rte.target_entity_uuid = eg.id - ) neighbor - -- Keeps the depth bounded entirely at the database layer + + UNION ALL + + SELECT rte.target_entity_uuid AS source_id, er.entity_id AS id, 'ANY' AS flow_match + FROM idp_core.relation_target_entities rte + JOIN idp_core.entity_relations er ON er.relation_id = rte.relation_id + ) combined ON combined.source_id = eg.id AND combined.flow_match = eg.flow WHERE eg.depth < :depth ) - -- 3. LEAN RETURN: Extract only the unique raw UUIDs discovered in the network skeleton + -- 3. Return the clean deduplicated set of structural skeleton UUIDs SELECT DISTINCT id FROM entity_graph; - """, nativeQuery = true) - List findEntityUuidsInGraph(@Param("entityId") UUID entityId, @Param("depth") int depth); + """, nativeQuery = true) + List findEntityUuidsInGraph(@Param("rootId") UUID rootId, @Param("depth") int depth, + @Param("mode") String mode); @Modifying(clearAutomatically = true, flushAutomatically = true) @Query(""" diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java index b3c7b5b0..fd22169b 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java @@ -29,6 +29,7 @@ import com.decathlon.idp_core.domain.model.entity.Relation; import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphRelation; +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphTraversalMode; import com.decathlon.idp_core.domain.port.EntityGraphRepositoryPort; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; @@ -88,8 +89,8 @@ private void stubGraph(Entity... entities) { entityMap = builder; } - when(entityGraphRepositoryPort.findEntityGraph(anyUUID(), anyInt(), anyBoolean())) - .thenReturn(entityMap); + when(entityGraphRepositoryPort.findEntityGraph(anyUUID(), anyInt(), anyBoolean(), + any(EntityGraphTraversalMode.class))).thenReturn(entityMap); } private UUID anyUUID() { @@ -108,10 +109,11 @@ void shouldThrowWhenRootEntityNotFound() { .thenReturn(Optional.empty()); assertThatThrownBy(() -> entityGraphService.getEntityGraph(TEMPLATE, "missing", 1, false, - Set.of(), Set.of())).isInstanceOf(EntityNotFoundException.class); + Set.of(), Set.of(), EntityGraphTraversalMode.BIDIRECTIONAL)) + .isInstanceOf(EntityNotFoundException.class); verify(entityGraphRepositoryPort, never()).findEntityGraph(any(UUID.class), anyInt(), - anyBoolean()); + anyBoolean(), any(EntityGraphTraversalMode.class)); } } @@ -129,7 +131,7 @@ void shouldReturnLeafNodeWhenNoRelations() { stubGraph(api); EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, - Set.of(), Set.of()); + Set.of(), Set.of(), EntityGraphTraversalMode.BIDIRECTIONAL); assertThat(result.identifier()).isEqualTo("api"); assertThat(result.name()).isEqualTo("API Service"); @@ -155,7 +157,7 @@ void shouldResolveOutboundRelations() { stubGraph(api, postgres); EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, - Set.of(), Set.of()); + Set.of(), Set.of(), EntityGraphTraversalMode.BIDIRECTIONAL); assertThat(result.relations()).hasSize(1); assertThat(result.relations().get(0).name()).isEqualTo("uses-db"); @@ -174,7 +176,7 @@ void shouldReturnFallbackNodeWhenTargetNotInMap() { stubGraph(api); EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, - Set.of(), Set.of()); + Set.of(), Set.of(), EntityGraphTraversalMode.BIDIRECTIONAL); // When target entity is not found in the map, it's filtered out assertThat(result.relations()).isEmpty(); @@ -198,7 +200,7 @@ void shouldResolveInboundRelations() { stubGraph(api, consumer); EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, - Set.of(), Set.of()); + Set.of(), Set.of(), EntityGraphTraversalMode.BIDIRECTIONAL); assertThat(result.relationsAsTarget()).hasSize(1); assertThat(result.relationsAsTarget().get(0).name()).isEqualTo("depends-on"); @@ -220,9 +222,11 @@ void shouldClampDepthBelowOne() { .thenReturn(Optional.of(api)); stubGraph(api); - entityGraphService.getEntityGraph(TEMPLATE, "api", 0, false, Set.of(), Set.of()); + entityGraphService.getEntityGraph(TEMPLATE, "api", 0, false, Set.of(), Set.of(), + EntityGraphTraversalMode.BIDIRECTIONAL); - verify(entityGraphRepositoryPort).findEntityGraph(api.id(), 1, false); + verify(entityGraphRepositoryPort).findEntityGraph(api.id(), 1, false, + EntityGraphTraversalMode.BIDIRECTIONAL); } @Test @@ -233,9 +237,11 @@ void shouldClampDepthAboveTen() { .thenReturn(Optional.of(api)); stubGraph(api); - entityGraphService.getEntityGraph(TEMPLATE, "api", 99, false, Set.of(), Set.of()); + entityGraphService.getEntityGraph(TEMPLATE, "api", 99, false, Set.of(), Set.of(), + EntityGraphTraversalMode.BIDIRECTIONAL); - verify(entityGraphRepositoryPort).findEntityGraph(api.id(), 20, false); + verify(entityGraphRepositoryPort).findEntityGraph(api.id(), 20, false, + EntityGraphTraversalMode.BIDIRECTIONAL); } } @@ -259,7 +265,7 @@ void shouldReturnLeafNodeAtDepthBoundary() { stubGraph(api, postgres); EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, - Set.of(), Set.of()); + Set.of(), Set.of(), EntityGraphTraversalMode.BIDIRECTIONAL); EntityGraphNode postgresNode = result.relations().get(0).targets().get(0); assertThat(postgresNode.identifier()).isEqualTo("postgres"); @@ -291,7 +297,7 @@ void shouldResolveMultipleNamedRelations() { stubGraph(api, postgres, auth); EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, - Set.of(), Set.of()); + Set.of(), Set.of(), EntityGraphTraversalMode.BIDIRECTIONAL); assertThat(result.relations()).hasSize(2); assertThat(result.relations().stream().map(EntityGraphRelation::name)) @@ -318,7 +324,7 @@ void shouldFilterRelationsByName() { stubGraph(a, b, c); EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 2, false, - Set.of("depends-on"), Set.of()); + Set.of("depends-on"), Set.of(), EntityGraphTraversalMode.BIDIRECTIONAL); assertThat(result.relations()).hasSize(1); assertThat(result.relations().get(0).name()).isEqualTo("depends-on"); @@ -337,7 +343,7 @@ void shouldReturnAllRelationsWhenFilterIsEmpty() { stubGraph(a, b, c); EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 2, false, Set.of(), - Set.of()); + Set.of(), EntityGraphTraversalMode.BIDIRECTIONAL); assertThat(result.relations()).hasSize(2); assertThat(result.relations().stream().map(EntityGraphRelation::name)) @@ -358,7 +364,7 @@ void shouldFilterInboundRelationsByName() { stubGraph(api, consumer, unrelated); EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, - Set.of("depends-on"), Set.of()); + Set.of("depends-on"), Set.of(), EntityGraphTraversalMode.BIDIRECTIONAL); assertThat(result.relationsAsTarget()).hasSize(1); assertThat(result.relationsAsTarget().get(0).name()).isEqualTo("depends-on"); @@ -389,7 +395,7 @@ void shouldFilterPropertiesByName() { stubGraph(api); EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, true, Set.of(), - Set.of("env")); + Set.of("env"), EntityGraphTraversalMode.BIDIRECTIONAL); assertThat(result.properties()).hasSize(1); assertThat(result.properties().get(0).name()).isEqualTo("env"); @@ -408,7 +414,7 @@ void shouldReturnAllPropertiesWhenFilterIsEmpty() { stubGraph(api); EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, true, Set.of(), - Set.of()); + Set.of(), EntityGraphTraversalMode.BIDIRECTIONAL); assertThat(result.properties()).hasSize(2); } @@ -424,7 +430,7 @@ void shouldReturnEmptyPropertiesWhenIncludePropertiesIsFalse() { stubGraph(api); EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, - Set.of(), Set.of("env")); + Set.of(), Set.of("env"), EntityGraphTraversalMode.BIDIRECTIONAL); assertThat(result.properties()).isEmpty(); } @@ -452,7 +458,7 @@ void shouldNotExplodeAtMaxDepthWithSmallGraph() { // Must complete instantly — any OOM or StackOverflow here means the guard is // missing. EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 10, false, Set.of(), - Set.of()); + Set.of(), EntityGraphTraversalMode.BIDIRECTIONAL); assertThat(result.identifier()).isEqualTo("a"); assertThat(result.relations()).hasSize(1); @@ -470,7 +476,7 @@ void shouldReturnStubLeafForRevisitedNode() { stubGraph(a, b); EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 5, false, Set.of(), - Set.of()); + Set.of(), EntityGraphTraversalMode.BIDIRECTIONAL); // A → B is resolved assertThat(result.relations()).hasSize(1); From 2f4e762475c95e341760b0d91262897ff4893480 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Tue, 16 Jun 2026 16:02:31 +0200 Subject: [PATCH 40/53] feat(core): add a entity graph service and endpoint --- .../EntityGraphTraversalMode.java | 6 +- .../entity_graph/EntityGraphService.java | 62 ++--- .../api/configuration/SwaggerDescription.java | 2 +- .../api/controller/EntityGraphController.java | 2 +- .../dto/out/entity/EntityGraphNodeDtoOut.java | 0 .../entity/EntityGraphDtoOutMapper.java | 0 .../repository/JpaEntityRepository.java | 10 +- .../entity_graph/EntityGraphServiceTest.java | 243 ++++++++++++++++-- 8 files changed, 250 insertions(+), 75 deletions(-) delete mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java delete mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphTraversalMode.java b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphTraversalMode.java index deafd9b7..1a73bba1 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphTraversalMode.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphTraversalMode.java @@ -2,14 +2,14 @@ /// Defines the traversal mode for entity graph queries. /// -/// - **STRICT_LINEAGE**: Follow only outbound relations (forward dependencies) +/// - **DIRECT_LINEAGE**: Follow only outbound relations (forward dependencies) /// - **BIDIRECTIONAL**: Follow both outbound and inbound relations (full graph) /// - **OUTBOUND_ONLY**: Follow only outbound relations without inbound lookups public enum EntityGraphTraversalMode { - STRICT_LINEAGE, BIDIRECTIONAL, OUTBOUND_ONLY; + DIRECT_LINEAGE, BIDIRECTIONAL, OUTBOUND_ONLY; // @Override // public String toString() { // return name(); // } -} \ No newline at end of file +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java index 14245512..9e038951 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java @@ -9,8 +9,6 @@ import java.util.Set; import java.util.UUID; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -54,8 +52,7 @@ @RequiredArgsConstructor public class EntityGraphService { - private static final Logger log = LoggerFactory.getLogger(EntityGraphService.class); - private static final int MAX_DEPTH = 20; + private static final int MAX_DEPTH = 6; private final EntityRepositoryPort entityRepositoryPort; private final EntityGraphRepositoryPort entityGraphRepositoryPort; @@ -66,35 +63,25 @@ public EntityGraphNode getEntityGraph(String templateIdentifier, String entityId int depth, boolean includeProperties, Set relationFilter, Set propertyFilter, EntityGraphTraversalMode mode) { - final long tStartTotal = System.nanoTime(); int effectiveDepth = Math.clamp(depth, 1, MAX_DEPTH); entityTemplateValidationService.validateTemplateExists(templateIdentifier); // 1. Resolve root entity - final long tStartResolve = System.nanoTime(); Entity rootEntity = entityRepositoryPort .findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); - final long tAfterResolve = System.nanoTime(); - // 2. Load the graph footprint via optimized DB calls (Takes ~150ms) - final long tStartRepo = System.nanoTime(); + // 2. Load the graph footprint via optimized DB calls Map entityMap = entityGraphRepositoryPort.findEntityGraph(rootEntity.id(), effectiveDepth, includeProperties, mode); - final long tAfterRepo = System.nanoTime(); if (entityMap == null || entityMap.isEmpty()) { return new EntityGraphNode(rootEntity.id().toString(), rootEntity.identifier(), rootEntity.name(), List.of(), List.of(), List.of()); } - log.debug("[EntityGraph] Repository returned {} entities for root id='{}' repoElapsed={}ms", - entityMap.size(), rootEntity.id(), (tAfterRepo - tStartRepo) / 1_000_000); - // 3. Pre-computation Layer - final long tStartIndex = System.nanoTime(); - IndexBundle indices = buildIndices(entityMap); - final long tAfterIndex = System.nanoTime(); + IndexBundle indices = buildIndices(entityMap, mode); // Context tracking for this execution tree Set activeStack = new HashSet<>(); @@ -102,21 +89,10 @@ public EntityGraphNode getEntityGraph(String templateIdentifier, String entityId GraphTraversalContext ctx = new GraphTraversalContext(entityMap, indices.textToUuidLookup(), indices.inboundIndex(), includeProperties, propertyFilter, relationFilter, activeStack, - memoCache); + memoCache, mode); // 4. Trigger recursive tree mapping (O(N) performance, heap-safe) - final long tStartRecursion = System.nanoTime(); - EntityGraphNode rootNode = buildGraphNode(rootEntity.id(), ctx); - final long tAfterRecursion = System.nanoTime(); - - final long tEndTotal = System.nanoTime(); - log.debug( - "[EntityGraph] End: totalElapsed={}ms (resolve={}ms repo={}ms index={}ms recursion={}ms) CacheSize={}", - (tEndTotal - tStartTotal) / 1_000_000, (tAfterResolve - tStartResolve) / 1_000_000, - (tAfterRepo - tStartRepo) / 1_000_000, (tAfterIndex - tStartIndex) / 1_000_000, - (tAfterRecursion - tStartRecursion) / 1_000_000, memoCache.size()); - - return rootNode; + return buildGraphNode(rootEntity.id(), ctx); } private EntityGraphNode buildGraphNode(UUID entityUuid, GraphTraversalContext ctx) { @@ -186,6 +162,11 @@ private EntityGraphNode buildGraphNode(UUID entityUuid, GraphTraversalContext ct private List buildRelationsAsTargetFromIndex(String targetIdentifier, GraphTraversalContext ctx) { + // Only build inbound relations for BIDIRECTIONAL mode + if (ctx.mode() != EntityGraphTraversalMode.BIDIRECTIONAL) { + return List.of(); + } + String normalizedTargetIdentifier = targetIdentifier == null ? "" : targetIdentifier.trim().toLowerCase(); @@ -205,7 +186,7 @@ private List buildRelationsAsTargetFromIndex(String targetI }).toList(); } - private IndexBundle buildIndices(Map entityMap) { + private IndexBundle buildIndices(Map entityMap, EntityGraphTraversalMode mode) { Map textToUuidLookup = new HashMap<>(); Map>> inboundIndex = new HashMap<>(); @@ -218,13 +199,16 @@ private IndexBundle buildIndices(Map entityMap) { textToUuidLookup.put(new EntityCompositeKey(entity.templateIdentifier(), entity.identifier()), sourceUuid); - for (Relation relation : entity.relations()) { - for (String targetId : relation.targetEntityIdentifiers()) { - if (targetId == null) - continue; - String normalizedTargetId = targetId.trim().toLowerCase(); - inboundIndex.computeIfAbsent(normalizedTargetId, k -> new HashMap<>()) - .computeIfAbsent(relation.name(), k -> new ArrayList<>()).add(sourceUuid); + // Only build inbound index for BIDIRECTIONAL mode + if (mode == EntityGraphTraversalMode.BIDIRECTIONAL) { + for (Relation relation : entity.relations()) { + for (String targetId : relation.targetEntityIdentifiers()) { + if (targetId == null) + continue; + String normalizedTargetId = targetId.trim().toLowerCase(); + inboundIndex.computeIfAbsent(normalizedTargetId, k -> new HashMap<>()) + .computeIfAbsent(relation.name(), k -> new ArrayList<>()).add(sourceUuid); + } } } } @@ -248,8 +232,8 @@ private static record GraphTraversalContext(Map entityMap, Map textToUuidLookup, Map>> inboundIndex, boolean includeProperties, Set propertyFilter, Set relationFilter, Set activeStack, - Map memoCache // High-speed in-memory reuse cache - ) { + Map memoCache, // High-speed in-memory reuse cache + EntityGraphTraversalMode mode) { } } 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 ded0bd4c..71c7fa0f 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 @@ -192,7 +192,7 @@ public class SwaggerDescription { public static final String PARAM_INCLUDE_DATA_DESCRIPTION = "When true, each graph node includes a data object containing the entity's property values. Defaults to false."; public static final String PARAM_RELATIONS_DESCRIPTION = "When provided, only relations whose name matches one of the listed values are traversed and included. Omit to include all relations."; public static final String PARAM_PROPERTIES_DESCRIPTION = "When provided, each node's data object is restricted to the listed property names. Requires include_data=true to have any effect. Omit to include all properties."; - public static final String PARAM_TRAVERSAL_MODE_DESCRIPTION = "Specifies the traversal mode for the entity graph. Defaults to STRICT_LINEAGE."; + public static final String PARAM_TRAVERSAL_MODE_DESCRIPTION = "Specifies the traversal mode for the entity graph. Defaults to DIRECT_LINEAGE."; /// Search API endpoint constants public static final String ENDPOINT_POST_SEARCH_SUMMARY = "Search entities"; public static final String ENDPOINT_POST_SEARCH_DESCRIPTION = """ diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java index 02354bf9..21777b8f 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java @@ -81,7 +81,7 @@ public EntityGraphFlatDtoOut getEntityGraph(@PathVariable @NotBlank String templ @PathVariable @NotBlank String entityIdentifier, @Parameter(description = PARAM_DEPTH_DESCRIPTION) @RequestParam(defaultValue = "1") int depth, @Parameter(description = PARAM_INCLUDE_DATA_DESCRIPTION) @RequestParam(name = "include_data", defaultValue = "false") boolean includeData, - @Parameter(description = PARAM_TRAVERSAL_MODE_DESCRIPTION) @RequestParam(name = "traversal_mode", defaultValue = "STRICT_LINEAGE") EntityGraphTraversalMode mode, + @Parameter(description = PARAM_TRAVERSAL_MODE_DESCRIPTION) @RequestParam(name = "traversal_mode", defaultValue = "DIRECT_LINEAGE") EntityGraphTraversalMode mode, @Parameter(description = PARAM_RELATIONS_DESCRIPTION) @RequestParam(required = false) List relations, @Parameter(description = PARAM_PROPERTIES_DESCRIPTION) @RequestParam(required = false) List properties) { diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java deleted file mode 100644 index e69de29b..00000000 diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java deleted file mode 100644 index e69de29b..00000000 diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java index ee3c425e..fb41e625 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java @@ -59,13 +59,13 @@ WITH RECURSIVE entity_graph(id, depth, flow) AS ( -- 1. ANCHOR MEMBER: Initialize state tokens for a single root entity SELECT e.id, 0, 'OUTBOUND' AS flow FROM idp_core.entity e - WHERE e.id = :rootId AND :mode IN ('STRICT_LINEAGE', 'OUTBOUND_ONLY') + WHERE e.id = :rootId AND :mode IN ('DIRECT_LINEAGE', 'OUTBOUND_ONLY') UNION SELECT e.id, 0, 'INBOUND' AS flow FROM idp_core.entity e - WHERE e.id = :rootId AND :mode = 'STRICT_LINEAGE' + WHERE e.id = :rootId AND :mode = 'DIRECT_LINEAGE' UNION @@ -142,10 +142,8 @@ void deleteRelationsByTemplateIdentifierAndRelationName( List findAllByTemplateIdentifierAndIdentifierIn(String templateIdentifier, List identifiers); - /** - * Find all entities that have relations pointing to the given target - * identifier. Uses a native query for better control over the join strategy. - */ + // Find all entities that have relations pointing to the given target + // identifier. Uses a native query for better control over the join strategy. @Query(value = """ SELECT DISTINCT e.* FROM idp_core.entity e diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java index fd22169b..11cf5b34 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java @@ -9,6 +9,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -52,6 +53,10 @@ class EntityGraphServiceTest { // --- Fixtures --- + private UUID anyUUID() { + return org.mockito.ArgumentMatchers.any(UUID.class); + } + private Entity entity(String templateIdentifier, String identifier, String name) { return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, List.of(), List.of()); @@ -69,32 +74,21 @@ private Relation relation(String name, String targetTemplateIdentifier, String.. private static final String TEMPLATE = "web-service"; - // --- Helper to stub the graph repository port --- - + /// Helper to stub the graph repository port + /// Builds a map from the provided entities and configures the mock to return it private void stubGraph(Entity... entities) { - Map entityMap = Map.of(); - if (entities.length == 1) { - entityMap = Map.of(entities[0].id(), entities[0]); - } else if (entities.length == 2) { - entityMap = Map.of(entities[0].id(), entities[0], entities[1].id(), entities[1]); - } else if (entities.length == 3) { - entityMap = Map.of(entities[0].id(), entities[0], entities[1].id(), entities[1], - entities[2].id(), entities[2]); - } else if (entities.length > 3) { - // For more than 3 entities, build map manually - var builder = new java.util.HashMap(); - for (Entity e : entities) { - builder.put(e.id(), e); - } - entityMap = builder; - } - + Map entityMap = buildEntityMap(entities); when(entityGraphRepositoryPort.findEntityGraph(anyUUID(), anyInt(), anyBoolean(), any(EntityGraphTraversalMode.class))).thenReturn(entityMap); } - private UUID anyUUID() { - return org.mockito.ArgumentMatchers.any(UUID.class); + /// Builds an immutable map of entities keyed by their UUID + private Map buildEntityMap(Entity... entities) { + var entityMap = new HashMap(); + for (Entity e : entities) { + entityMap.put(e.id(), e); + } + return entityMap; } // ======================== @@ -108,13 +102,17 @@ void shouldThrowWhenRootEntityNotFound() { when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "missing")) .thenReturn(Optional.empty()); - assertThatThrownBy(() -> entityGraphService.getEntityGraph(TEMPLATE, "missing", 1, false, - Set.of(), Set.of(), EntityGraphTraversalMode.BIDIRECTIONAL)) - .isInstanceOf(EntityNotFoundException.class); + assertThatThrownBy(this::callGetEntityGraphForMissing) + .isInstanceOf(EntityNotFoundException.class); verify(entityGraphRepositoryPort, never()).findEntityGraph(any(UUID.class), anyInt(), anyBoolean(), any(EntityGraphTraversalMode.class)); } + + private void callGetEntityGraphForMissing() { + entityGraphService.getEntityGraph(TEMPLATE, "missing", 1, false, Set.of(), Set.of(), + EntityGraphTraversalMode.BIDIRECTIONAL); + } } // ======================== @@ -240,7 +238,7 @@ void shouldClampDepthAboveTen() { entityGraphService.getEntityGraph(TEMPLATE, "api", 99, false, Set.of(), Set.of(), EntityGraphTraversalMode.BIDIRECTIONAL); - verify(entityGraphRepositoryPort).findEntityGraph(api.id(), 20, false, + verify(entityGraphRepositoryPort).findEntityGraph(api.id(), 6, false, EntityGraphTraversalMode.BIDIRECTIONAL); } } @@ -491,4 +489,199 @@ void shouldReturnStubLeafForRevisitedNode() { assertThat(stubA.relationsAsTarget()).isEmpty(); } } + + // ======================== + @Nested + @DisplayName("Graph Traversal Mode") + class GraphTraversalMode { + + @Test + @DisplayName("BIDIRECTIONAL mode should include both outbound and inbound relations") + void bidirectionalModeShouldIncludeBothDirections() { + Entity api = entityWithRelations(TEMPLATE, "api", "API Service", + List.of(relation("uses-db", "database", "postgres"))); + Entity postgres = entity("database", "postgres", "Postgres DB"); + Entity consumer = entityWithRelations(TEMPLATE, "consumer", "Consumer", + List.of(relation("depends-on", TEMPLATE, "api"))); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(api, postgres, consumer); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, + Set.of(), Set.of(), EntityGraphTraversalMode.BIDIRECTIONAL); + + // Should have outbound relation to postgres + assertThat(result.relations()).hasSize(1); + assertThat(result.relations().get(0).name()).isEqualTo("uses-db"); + assertThat(result.relations().get(0).targets().get(0).identifier()).isEqualTo("postgres"); + + // Should have inbound relation from consumer + assertThat(result.relationsAsTarget()).hasSize(1); + assertThat(result.relationsAsTarget().get(0).name()).isEqualTo("depends-on"); + assertThat(result.relationsAsTarget().get(0).targets().get(0).identifier()) + .isEqualTo("consumer"); + } + + @Test + @DisplayName("OUTBOUND_ONLY mode should include only outbound relations") + void outboundOnlyModeShouldExcludeInboundRelations() { + Entity api = entityWithRelations(TEMPLATE, "api", "API Service", + List.of(relation("uses-db", "database", "postgres"))); + Entity postgres = entity("database", "postgres", "Postgres DB"); + Entity consumer = entityWithRelations(TEMPLATE, "consumer", "Consumer", + List.of(relation("depends-on", TEMPLATE, "api"))); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(api, postgres, consumer); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, + Set.of(), Set.of(), EntityGraphTraversalMode.OUTBOUND_ONLY); + + // Should have outbound relation to postgres + assertThat(result.relations()).hasSize(1); + assertThat(result.relations().get(0).name()).isEqualTo("uses-db"); + assertThat(result.relations().get(0).targets().get(0).identifier()).isEqualTo("postgres"); + + // Should NOT have inbound relations + assertThat(result.relationsAsTarget()).isEmpty(); + } + + @Test + @DisplayName("DIRECT_LINEAGE mode should include only outbound relations") + void strictLineageModeShouldExcludeInboundRelations() { + Entity api = entityWithRelations(TEMPLATE, "api", "API Service", + List.of(relation("uses-db", "database", "postgres"))); + Entity postgres = entity("database", "postgres", "Postgres DB"); + Entity consumer = entityWithRelations(TEMPLATE, "consumer", "Consumer", + List.of(relation("depends-on", TEMPLATE, "api"))); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(api, postgres, consumer); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, + Set.of(), Set.of(), EntityGraphTraversalMode.DIRECT_LINEAGE); + + // Should have outbound relation to postgres + assertThat(result.relations()).hasSize(1); + assertThat(result.relations().get(0).name()).isEqualTo("uses-db"); + assertThat(result.relations().get(0).targets().get(0).identifier()).isEqualTo("postgres"); + + // Should NOT have inbound relations + assertThat(result.relationsAsTarget()).isEmpty(); + } + + @Test + @DisplayName("Mode parameter should be passed to repository port") + void modeShouldBePassedToRepositoryPort() { + Entity api = entity(TEMPLATE, "api", "API Service"); + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(api); + + // Test OUTBOUND_ONLY + entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, Set.of(), Set.of(), + EntityGraphTraversalMode.OUTBOUND_ONLY); + verify(entityGraphRepositoryPort).findEntityGraph(api.id(), 1, false, + EntityGraphTraversalMode.OUTBOUND_ONLY); + + // Test DIRECT_LINEAGE + entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, Set.of(), Set.of(), + EntityGraphTraversalMode.DIRECT_LINEAGE); + verify(entityGraphRepositoryPort).findEntityGraph(api.id(), 1, false, + EntityGraphTraversalMode.DIRECT_LINEAGE); + + // Test BIDIRECTIONAL + entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, Set.of(), Set.of(), + EntityGraphTraversalMode.BIDIRECTIONAL); + verify(entityGraphRepositoryPort).findEntityGraph(api.id(), 1, false, + EntityGraphTraversalMode.BIDIRECTIONAL); + } + + @Test + @DisplayName("BIDIRECTIONAL mode should traverse full graph with multiple levels") + void bidirectionalModeShouldTraverseFullGraphMultipleLevels() { + // Create a complex graph: consumer -> api -> postgres, with backend also -> api + Entity api = entityWithRelations(TEMPLATE, "api", "API Service", + List.of(relation("uses-db", "database", "postgres"))); + Entity postgres = entity("database", "postgres", "Postgres DB"); + Entity consumer = entityWithRelations(TEMPLATE, "consumer", "Consumer", + List.of(relation("depends-on", TEMPLATE, "api"))); + Entity backend = entityWithRelations(TEMPLATE, "backend", "Backend", + List.of(relation("calls", TEMPLATE, "api"))); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(api, postgres, consumer, backend); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 2, false, + Set.of(), Set.of(), EntityGraphTraversalMode.BIDIRECTIONAL); + + // Outbound: api -> postgres + assertThat(result.relations()).hasSize(1); + assertThat(result.relations().get(0).targets().get(0).identifier()).isEqualTo("postgres"); + + // Inbound: consumer -> api, backend -> api + assertThat(result.relationsAsTarget()).hasSize(2); + var inboundIdentifiers = result.relationsAsTarget().stream() + .flatMap(rel -> rel.targets().stream()).map(EntityGraphNode::identifier).toList(); + assertThat(inboundIdentifiers).containsExactlyInAnyOrder("consumer", "backend"); + } + + @Test + @DisplayName("OUTBOUND_ONLY mode should only follow forward dependencies in multi-level graph") + void outboundOnlyModeShouldOnlyFollowForwardDependencies() { + // Chain: frontend -> api -> postgres + Entity frontend = entityWithRelations(TEMPLATE, "frontend", "Frontend", + List.of(relation("calls", TEMPLATE, "api"))); + Entity api = entityWithRelations(TEMPLATE, "api", "API Service", + List.of(relation("uses-db", "database", "postgres"))); + Entity postgres = entity("database", "postgres", "Postgres DB"); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "frontend")) + .thenReturn(Optional.of(frontend)); + stubGraph(frontend, api, postgres); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "frontend", 2, false, + Set.of(), Set.of(), EntityGraphTraversalMode.OUTBOUND_ONLY); + + // Should follow: frontend -> api -> postgres + assertThat(result.identifier()).isEqualTo("frontend"); + assertThat(result.relations()).hasSize(1); + EntityGraphNode apiNode = result.relations().get(0).targets().get(0); + assertThat(apiNode.identifier()).isEqualTo("api"); + assertThat(apiNode.relations()).hasSize(1); + assertThat(apiNode.relations().get(0).targets().get(0).identifier()).isEqualTo("postgres"); + + // No inbound relations at any level + assertThat(result.relationsAsTarget()).isEmpty(); + assertThat(apiNode.relationsAsTarget()).isEmpty(); + } + + @Test + @DisplayName("Mode should affect inbound index construction behavior") + void modeShouldAffectInboundIndexConstruction() { + Entity api = entity(TEMPLATE, "api", "API Service"); + Entity consumer = entityWithRelations(TEMPLATE, "consumer", "Consumer", + List.of(relation("depends-on", TEMPLATE, "api"))); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + + // With BIDIRECTIONAL, consumer should be in the graph + stubGraph(api, consumer); + EntityGraphNode bidirectionalResult = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, + false, Set.of(), Set.of(), EntityGraphTraversalMode.BIDIRECTIONAL); + assertThat(bidirectionalResult.relationsAsTarget()).hasSize(1); + + // With OUTBOUND_ONLY, even if consumer is in the map, inbound shouldn't be + // built + stubGraph(api, consumer); + EntityGraphNode outboundResult = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, + Set.of(), Set.of(), EntityGraphTraversalMode.OUTBOUND_ONLY); + assertThat(outboundResult.relationsAsTarget()).isEmpty(); + } + } } From 878f17ef39223386fe373770236a6533154d7fac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Tue, 16 Jun 2026 16:09:30 +0200 Subject: [PATCH 41/53] feat(core): add a entity graph service and endpoint --- .../EntityGraphTraversalMode.java | 5 ---- .../entity_graph/EntityGraphService.java | 24 +++++++++++-------- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphTraversalMode.java b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphTraversalMode.java index 1a73bba1..e9972175 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphTraversalMode.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphTraversalMode.java @@ -7,9 +7,4 @@ /// - **OUTBOUND_ONLY**: Follow only outbound relations without inbound lookups public enum EntityGraphTraversalMode { DIRECT_LINEAGE, BIDIRECTIONAL, OUTBOUND_ONLY; - - // @Override - // public String toString() { - // return name(); - // } } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java index 9e038951..abab50f2 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java @@ -199,22 +199,26 @@ private IndexBundle buildIndices(Map entityMap, EntityGraphTravers textToUuidLookup.put(new EntityCompositeKey(entity.templateIdentifier(), entity.identifier()), sourceUuid); - // Only build inbound index for BIDIRECTIONAL mode if (mode == EntityGraphTraversalMode.BIDIRECTIONAL) { - for (Relation relation : entity.relations()) { - for (String targetId : relation.targetEntityIdentifiers()) { - if (targetId == null) - continue; - String normalizedTargetId = targetId.trim().toLowerCase(); - inboundIndex.computeIfAbsent(normalizedTargetId, k -> new HashMap<>()) - .computeIfAbsent(relation.name(), k -> new ArrayList<>()).add(sourceUuid); - } - } + buildInboundIndexForEntity(entity, sourceUuid, inboundIndex); } } return new IndexBundle(textToUuidLookup, inboundIndex); } + private void buildInboundIndexForEntity(Entity entity, UUID sourceUuid, + Map>> inboundIndex) { + for (Relation relation : entity.relations()) { + for (String targetId : relation.targetEntityIdentifiers()) { + if (targetId == null) + continue; + String normalizedTargetId = targetId.trim().toLowerCase(); + inboundIndex.computeIfAbsent(normalizedTargetId, k -> new HashMap<>()) + .computeIfAbsent(relation.name(), k -> new ArrayList<>()).add(sourceUuid); + } + } + } + private List resolveProperties(Entity entity, boolean includeProperties, Set propertyFilter) { if (!includeProperties) From 87f785d150922104d2f0cea4fbee3a5f9eb363f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Tue, 16 Jun 2026 16:22:25 +0200 Subject: [PATCH 42/53] feat(core): add a entity graph service and endpoint --- docs/src/static/swagger.yaml | 759 ++++++++++++++++++----------------- 1 file changed, 391 insertions(+), 368 deletions(-) diff --git a/docs/src/static/swagger.yaml b/docs/src/static/swagger.yaml index 85343a4c..542ae443 100644 --- a/docs/src/static/swagger.yaml +++ b/docs/src/static/swagger.yaml @@ -9,162 +9,164 @@ security: - clientId: [] - bearer: [] tags: -- name: Entity Graph - description: Entity relationship graph operations -- name: Entities Management - description: Operations related to entity management -- name: Entities Templates Management - description: Operations related to entity template management + - name: Entity Graph + description: Entity relationship graph operations + - name: Entities Management + description: Operations related to entity management + - name: Entities Templates Management + description: Operations related to entity template management paths: - "/api/v1/entity-templates/{identifier}": + '/api/v1/entity-templates/{identifier}': get: tags: - - Entities Templates Management + - Entities Templates Management summary: Get template by identifier description: Retrieve a specific template using its string identifier operationId: getTemplateByIdentifier parameters: - - name: identifier - in: path - required: true - schema: - type: string + - name: identifier + in: path + required: true + schema: + type: string responses: '200': description: Template found content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/EntityTemplateDtoOut" + $ref: '#/components/schemas/EntityTemplateDtoOut' '404': description: Template not found with the provided identifier content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' put: tags: - - Entities Templates Management + - Entities Templates Management summary: Update an existing template by template identifier - description: Update the details of an existing template identified by its unique + description: >- + Update the details of an existing template identified by its unique string identifier operationId: updateTemplate parameters: - - name: identifier - in: path - required: true - schema: - type: string + - name: identifier + in: path + required: true + schema: + type: string requestBody: content: application/json: schema: - "$ref": "#/components/schemas/EntityTemplateUpdateDtoIn" + $ref: '#/components/schemas/EntityTemplateUpdateDtoIn' required: true responses: '200': description: Template update successfully content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/EntityTemplateDtoOut" + $ref: '#/components/schemas/EntityTemplateDtoOut' '404': description: Template not found with the provided identifier content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' delete: tags: - - Entities Templates Management + - Entities Templates Management summary: Delete template by identifier description: Remove a template from the system using its unique identifier operationId: deleteTemplate parameters: - - name: identifier - in: path - required: true - schema: - type: string + - name: identifier + in: path + required: true + schema: + type: string responses: '204': description: Template deleted successfully '404': description: Template not found with the provided identifier content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/ErrorResponse" - "/api/v1/entities/{templateIdentifier}/{entityIdentifier}": + $ref: '#/components/schemas/ErrorResponse' + '/api/v1/entities/{templateIdentifier}/{entityIdentifier}': get: tags: - - Entities Management + - Entities Management summary: Get entity by entity template and identifier - description: Retrieve a specific entity using its string identifier and its - template identifier + description: >- + Retrieve a specific entity using its string identifier and its template + identifier operationId: getEntity parameters: - - name: templateIdentifier - in: path - required: true - schema: - type: string - - name: entityIdentifier - in: path - required: true - schema: - type: string + - name: templateIdentifier + in: path + required: true + schema: + type: string + - name: entityIdentifier + in: path + required: true + schema: + type: string responses: '200': description: Entity found content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/EntityDtoOut" + $ref: '#/components/schemas/EntityDtoOut' '404': description: Entity not found with the provided identifier content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' put: tags: - - Entities Management + - Entities Management summary: Update an existing entity description: Update an existing entity in the system with the provided information operationId: updateEntity parameters: - - name: templateIdentifier - in: path - required: true - schema: - type: string - minLength: 1 - - name: entityIdentifier - in: path - required: true - schema: - type: string - minLength: 1 + - name: templateIdentifier + in: path + required: true + schema: + type: string + minLength: 1 + - name: entityIdentifier + in: path + required: true + schema: + type: string + minLength: 1 requestBody: content: application/json: schema: - "$ref": "#/components/schemas/EntityUpdateDtoIn" + $ref: '#/components/schemas/EntityUpdateDtoIn' required: true responses: '200': description: Entity updated successfully content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/EntityDtoOut" + $ref: '#/components/schemas/EntityDtoOut' '400': description: Invalid entity data provided content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' '401': description: Unauthorized - Missing or invalid token '403': @@ -172,45 +174,46 @@ paths: '404': description: Entity not found with the provided identifier content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' '500': description: Unexpected server-side failure content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' delete: tags: - - Entities Management + - Entities Management summary: Delete an existing entity - description: Delete an entity from the system using its template and entity - identifiers. This operation removes the entity and automatically cleans up - any relations from other entities that reference it. + description: >- + Delete an entity from the system using its template and entity + identifiers. This operation removes the entity and automatically cleans + up any relations from other entities that reference it. operationId: deleteEntity parameters: - - name: templateIdentifier - in: path - required: true - schema: - type: string - minLength: 1 - - name: entityIdentifier - in: path - required: true - schema: - type: string - minLength: 1 + - name: templateIdentifier + in: path + required: true + schema: + type: string + minLength: 1 + - name: entityIdentifier + in: path + required: true + schema: + type: string + minLength: 1 responses: '204': description: Entity deleted successfully '400': description: Invalid entity data provided content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' '401': description: Unauthorized - Missing or invalid token '403': @@ -218,70 +221,71 @@ paths: '404': description: Entity not found with the provided identifier content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' '409': description: Target entity has required relations content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' '500': description: Unexpected server-side failure content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/ErrorResponse" - "/api/v1/entity-templates": + $ref: '#/components/schemas/ErrorResponse' + /api/v1/entity-templates: get: tags: - - Entities Templates Management + - Entities Templates Management summary: Get paginated templates description: Retrieve a paginated list of templates with optional sorting operationId: getTemplatesPaginated parameters: - - name: page - in: query - description: Page number for pagination. Defaults to 0. - content: - "*/*": - schema: - type: integer - default: '0' - - name: size - in: query - description: Number of items per page. Defaults to 20. - content: - "*/*": - schema: - type: integer - default: '20' - - name: sort - in: query - description: 'Sorting criteria in the format: property(,asc|desc). Defaults - to identifier,asc.' - content: - "*/*": - schema: - type: string - default: identifier,asc + - name: page + in: query + description: Page number for pagination. Defaults to 0. + content: + '*/*': + schema: + type: integer + default: '0' + - name: size + in: query + description: Number of items per page. Defaults to 20. + content: + '*/*': + schema: + type: integer + default: '20' + - name: sort + in: query + description: >- + Sorting criteria in the format: property(,asc|desc). Defaults to + identifier,asc. + content: + '*/*': + schema: + type: string + default: 'identifier,asc' responses: '200': description: Paginated templates retrieved successfully content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/TemplatePageResponse" + $ref: '#/components/schemas/TemplatePageResponse' '400': description: Invalid pagination parameters content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' post: tags: - - Entities Templates Management + - Entities Templates Management summary: Create a new template description: Create a new template in the system with the provided information operationId: createTemplate @@ -289,118 +293,118 @@ paths: content: application/json: schema: - "$ref": "#/components/schemas/EntityTemplateCreateDtoIn" + $ref: '#/components/schemas/EntityTemplateCreateDtoIn' required: true responses: '201': description: Template created successfully content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/EntityTemplateDtoOut" + $ref: '#/components/schemas/EntityTemplateDtoOut' '400': description: Invalid template data provided content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/ErrorResponse" - "/api/v1/entities/{templateIdentifier}": + $ref: '#/components/schemas/ErrorResponse' + '/api/v1/entities/{templateIdentifier}': get: tags: - - Entities Management + - Entities Management summary: Get entities by template identifier description: Retrieve a paginated list of entities with optional sorting operationId: getEntities parameters: - - name: page - in: query - description: Page number for pagination. Defaults to 0. - required: false - content: - "*/*": - schema: - type: integer - default: '0' - - name: size - in: query - description: Number of items per page. Defaults to 20. - required: false - content: - "*/*": - schema: - type: integer - default: '20' - - name: templateIdentifier - in: path - required: true - schema: - type: string - - name: q - in: query - description: 'Optional filter query using a simple expression language. See - more details in the API documentation. Example: `name:idp` for entities - with names containing ''idp''. - - ' - required: false - content: - "*/*": - schema: - type: string - - name: sort - in: query - description: 'Sorting criteria in the format: property(,asc|desc). Defaults - to identifier,asc.' - content: - "*/*": - schema: - type: string - default: identifier,asc + - name: page + in: query + description: Page number for pagination. Defaults to 0. + required: false + content: + '*/*': + schema: + type: integer + default: '0' + - name: size + in: query + description: Number of items per page. Defaults to 20. + required: false + content: + '*/*': + schema: + type: integer + default: '20' + - name: templateIdentifier + in: path + required: true + schema: + type: string + - name: q + in: query + description: > + Optional filter query using a simple expression language. See more + details in the API documentation. Example: `name:idp` for entities + with names containing 'idp'. + required: false + content: + '*/*': + schema: + type: string + - name: sort + in: query + description: >- + Sorting criteria in the format: property(,asc|desc). Defaults to + identifier,asc. + content: + '*/*': + schema: + type: string + default: 'identifier,asc' responses: '200': description: Paginated entities retrieved successfully content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/EntityPageResponse" + $ref: '#/components/schemas/EntityPageResponse' '400': description: Invalid filter query syntax content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' post: tags: - - Entities Management + - Entities Management summary: Create a new entity description: Create a new entity in the system with the provided information operationId: createEntity parameters: - - name: templateIdentifier - in: path - required: true - schema: - type: string - minLength: 1 + - name: templateIdentifier + in: path + required: true + schema: + type: string + minLength: 1 requestBody: content: application/json: schema: - "$ref": "#/components/schemas/EntityCreateDtoIn" + $ref: '#/components/schemas/EntityCreateDtoIn' required: true responses: '201': description: Entity created successfully content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/EntityDtoOut" + $ref: '#/components/schemas/EntityDtoOut' '400': description: Invalid entity data provided content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' '401': description: Unauthorized - Missing or invalid token '403': @@ -408,132 +412,140 @@ paths: '404': description: Template not found with the provided identifier content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' '409': description: Entity already exists in this template content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' '500': description: Unexpected server-side failure content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/ErrorResponse" - "/api/v1/entities/search": + $ref: '#/components/schemas/ErrorResponse' + /api/v1/entities/search: post: tags: - - Entities Management + - Entities Management summary: Search entities - 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. + 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. operationId: searchEntities requestBody: content: application/json: schema: - "$ref": "#/components/schemas/EntitySearchRequestDtoIn" + $ref: '#/components/schemas/EntitySearchRequestDtoIn' required: true responses: '200': description: Entities retrieved successfully content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/EntityPageResponse" + $ref: '#/components/schemas/EntityPageResponse' '400': description: Invalid search filter content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/ErrorResponse" - "/api/v1/entities/{templateIdentifier}/{entityIdentifier}/graph": + $ref: '#/components/schemas/ErrorResponse' + '/api/v1/entities/{templateIdentifier}/{entityIdentifier}/graph': get: tags: - - Entity Graph + - Entity Graph summary: Get entity relationship graph as flat nodes and edges - description: Retrieves the entity relationship graph as a flat nodes-and-edges - structure, suitable for frontend visualization tools such as React Flow, Vis.js, - and Cytoscape. + description: >- + Retrieves the entity relationship graph as a flat nodes-and-edges + structure, suitable for frontend visualization tools such as React Flow, + Vis.js, and Cytoscape. operationId: getEntityGraph parameters: - - name: templateIdentifier - in: path - required: true - schema: - type: string - minLength: 1 - - name: entityIdentifier - in: path - required: true - schema: - type: string - minLength: 1 - - name: depth - in: query - description: Maximum traversal depth for relationship resolution. Clamped - between 1 and 10. - required: false - schema: - type: integer - format: int32 - default: 1 - - name: include_data - in: query - description: When true, each graph node includes a data object containing - the entity's property values. Defaults to false. - required: false - schema: - type: boolean - default: false - - name: traversal_mode - in: query - description: Specifies the traversal mode for the entity graph. Defaults to - STRICT_LINEAGE. - required: false - schema: - type: string - default: STRICT_LINEAGE - enum: - - STRICT_LINEAGE - - BIDIRECTIONAL - - OUTBOUND_ONLY - - name: relations - in: query - description: When provided, only relations whose name matches one of the listed - values are traversed and included. Omit to include all relations. - required: false - schema: - type: array - items: + - name: templateIdentifier + in: path + required: true + schema: type: string - - name: properties - in: query - description: When provided, each node's data object is restricted to the listed - property names. Requires include_data=true to have any effect. Omit to include - all properties. - required: false - schema: - type: array - items: + minLength: 1 + - name: entityIdentifier + in: path + required: true + schema: + type: string + minLength: 1 + - name: depth + in: query + description: >- + Maximum traversal depth for relationship resolution. Clamped between + 1 and 10. + required: false + schema: + type: integer + format: int32 + default: 1 + - name: include_data + in: query + description: >- + When true, each graph node includes a data object containing the + entity's property values. Defaults to false. + required: false + schema: + type: boolean + default: false + - name: traversal_mode + in: query + description: >- + Specifies the traversal mode for the entity graph. Defaults to + DIRECT_LINEAGE. + required: false + schema: type: string + default: DIRECT_LINEAGE + enum: + - DIRECT_LINEAGE + - BIDIRECTIONAL + - OUTBOUND_ONLY + - name: relations + in: query + description: >- + When provided, only relations whose name matches one of the listed + values are traversed and included. Omit to include all relations. + required: false + schema: + type: array + items: + type: string + - name: properties + in: query + description: >- + When provided, each node's data object is restricted to the listed + property names. Requires include_data=true to have any effect. Omit + to include all properties. + required: false + schema: + type: array + items: + type: string responses: '200': description: Flat entity graph successfully retrieved content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/EntityGraphFlatDtoOut" + $ref: '#/components/schemas/EntityGraphFlatDtoOut' '404': description: Entity not found with the provided identifier content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' components: schemas: EntityTemplateUpdateDtoIn: @@ -546,7 +558,7 @@ components: example: Service maxLength: 255 minLength: 0 - pattern: "^[a-zA-Z0-9 _-]+$" + pattern: '^[a-zA-Z0-9 _-]+$' description: type: string description: Entity Template description @@ -555,14 +567,14 @@ components: type: array description: List of property definitions for this template items: - "$ref": "#/components/schemas/PropertyDefinitionDtoIn" + $ref: '#/components/schemas/PropertyDefinitionDtoIn' relations_definitions: type: array description: List of relation definitions for this template items: - "$ref": "#/components/schemas/RelationDefinitionDtoIn" + $ref: '#/components/schemas/RelationDefinitionDtoIn' required: - - name + - name PropertyDefinitionDtoIn: type: object description: Input DTO for creating or updating a property definition @@ -581,9 +593,9 @@ components: type: string description: Property data type enum: - - STRING - - NUMBER - - BOOLEAN + - STRING + - NUMBER + - BOOLEAN example: STRING required: type: boolean @@ -591,12 +603,12 @@ components: description: Whether this property is required example: true rules: - "$ref": "#/components/schemas/PropertyRulesDtoIn" + $ref: '#/components/schemas/PropertyRulesDtoIn' description: Property validation rules required: - - description - - name - - type + - description + - name + - type PropertyRulesDtoIn: type: object description: Input DTO for creating or updating a property definition @@ -605,21 +617,21 @@ components: type: string description: Property format validation enum: - - URL - - EMAIL + - URL + - EMAIL example: EMAIL enum_values: type: array description: Enumeration values for enum properties example: - - ACTIVE - - INACTIVE + - ACTIVE + - INACTIVE items: type: string regex: type: string description: Regular expression pattern for validation - example: "^[a-zA-Z0-9]+$" + example: '^[a-zA-Z0-9]+$' max_length: type: integer format: int32 @@ -665,8 +677,8 @@ components: description: Whether this relation can have multiple targets example: true required: - - name - - target_template_identifier + - name + - target_template_identifier EntityTemplateDtoOut: type: object description: Output for entity template @@ -687,12 +699,12 @@ components: type: array description: List of property definitions for this template items: - "$ref": "#/components/schemas/PropertyDefinitionDtoOut" + $ref: '#/components/schemas/PropertyDefinitionDtoOut' relations_definitions: type: array description: List of relation definitions for this template items: - "$ref": "#/components/schemas/RelationDefinitionDtoOut" + $ref: '#/components/schemas/RelationDefinitionDtoOut' PropertyDefinitionDtoOut: type: object description: Output DTO for property definition @@ -709,16 +721,16 @@ components: type: string description: Property data type enum: - - STRING - - NUMBER - - BOOLEAN + - STRING + - NUMBER + - BOOLEAN example: STRING required: type: boolean description: Whether this property is required example: true rules: - "$ref": "#/components/schemas/PropertyRulesDtoOut" + $ref: '#/components/schemas/PropertyRulesDtoOut' description: Property validation rules example: Property validation rules PropertyRulesDtoOut: @@ -729,21 +741,21 @@ components: type: string description: Format of the property enum: - - URL - - EMAIL + - URL + - EMAIL example: STRING enum_values: type: array description: Allowed enum values for the property example: - - VALUE1 - - VALUE2 + - VALUE1 + - VALUE2 items: type: string regex: type: string description: Regular expression for property validation - example: "^[A-Za-z0-9]+$" + example: '^[A-Za-z0-9]+$' max_length: type: integer format: int32 @@ -812,9 +824,9 @@ components: type: array description: List of relations for this entity items: - "$ref": "#/components/schemas/RelationDtoIn" + $ref: '#/components/schemas/RelationDtoIn' required: - - name + - name RelationDtoIn: type: object description: Input DTO for an entity relation instance @@ -828,13 +840,13 @@ components: type: array description: List of target entity identifiers for this relation example: - - web-api-1 - - web-api-2 + - web-api-1 + - web-api-2 items: type: string required: - - name - - target_entity_identifiers + - name + - target_entity_identifiers EntityDtoOut: type: object properties: @@ -852,13 +864,13 @@ components: additionalProperties: type: array items: - "$ref": "#/components/schemas/EntitySummaryDto" + $ref: '#/components/schemas/EntitySummaryDto' relations_as_target: type: object additionalProperties: type: array items: - "$ref": "#/components/schemas/EntitySummaryDto" + $ref: '#/components/schemas/EntitySummaryDto' EntitySummaryDto: type: object properties: @@ -881,7 +893,7 @@ components: example: Service maxLength: 255 minLength: 0 - pattern: "^[a-zA-Z0-9 _-]+$" + pattern: '^[a-zA-Z0-9 _-]+$' description: type: string description: Entity Template description @@ -890,15 +902,15 @@ components: type: array description: List of property definitions for this template items: - "$ref": "#/components/schemas/PropertyDefinitionDtoIn" + $ref: '#/components/schemas/PropertyDefinitionDtoIn' relations_definitions: type: array description: List of relation definitions for this template items: - "$ref": "#/components/schemas/RelationDefinitionDtoIn" + $ref: '#/components/schemas/RelationDefinitionDtoIn' required: - - identifier - - name + - identifier + - name EntityCreateDtoIn: type: object description: Input DTO for creating an entity @@ -925,24 +937,26 @@ components: type: array description: List of relations for this entity items: - "$ref": "#/components/schemas/RelationDtoIn" + $ref: '#/components/schemas/RelationDtoIn' required: - - identifier - - name + - identifier + - name EntitySearchRequestDtoIn: type: object description: Request body for the POST /api/v1/entities/search endpoint properties: query: type: string - description: Free-text search string. When present, returns entities whose - identifier, name, templateIdentifier, or any property value contains this - string (case-insensitive). Can be combined with filter. + description: >- + Free-text search string. When present, returns entities whose + identifier, name, templateIdentifier, or any property value contains + this string (case-insensitive). Can be combined with filter. example: checkout filter: - "$ref": "#/components/schemas/FilterNodeDtoIn" - description: Root node of the search filter tree. May be omitted or null - to return all entities. + $ref: '#/components/schemas/FilterNodeDtoIn' + description: >- + Root node of the search filter tree. May be omitted or null to + return all entities. page: type: integer format: int32 @@ -957,41 +971,49 @@ components: example: 20 sort: type: string - description: 'Sorting criteria in the format: property(,asc|desc). Defaults - to identifier,asc.' - example: identifier:asc + description: >- + Sorting criteria in the format: property(,asc|desc). Defaults to + identifier,asc. + example: 'identifier:asc' FilterNodeDtoIn: type: object - description: A node in the search filter tree. Either a logical group (connector - + criteria) or a leaf criterion (field + operation + value). + description: >- + A node in the search filter tree. Either a logical group (connector + + criteria) or a leaf criterion (field + operation + value). properties: connector: type: string - description: 'Logical connector for a group node. One of: AND, OR. Required - for group nodes.' + description: >- + Logical connector for a group node. One of: AND, OR. Required for + group nodes. example: AND criteria: type: array - description: Child filter nodes for a group node. Required for group nodes - (must be non-empty). + description: >- + Child filter nodes for a group node. Required for group nodes (must + be non-empty). items: - "$ref": "#/components/schemas/FilterNodeDtoIn" + $ref: '#/components/schemas/FilterNodeDtoIn' field: type: string - description: 'Field to filter on for a criterion node. Required for leaf - nodes. Examples: template, identifier, name, relation, property.language, - relation.api-link, relation.api-link.identifier, relations_as_target.api-link.name' + description: >- + Field to filter on for a criterion node. Required for leaf nodes. + Examples: template, identifier, name, relation, property.language, + relation.api-link, relation.api-link.identifier, + relations_as_target.api-link.name example: template operation: type: string - description: 'Filter operation for a criterion node. One of: EQ, NEQ, CONTAINS, - NOT_CONTAINS, STARTS_WITH, ENDS_WITH, GT, GTE, LT, LTE. Required for leaf - nodes.' + description: >- + Filter operation for a criterion node. One of: EQ, NEQ, CONTAINS, + NOT_CONTAINS, STARTS_WITH, ENDS_WITH, GT, GTE, LT, LTE. Required for + leaf nodes. example: EQ value: type: string - description: Value to compare against for a criterion node. Required for - leaf nodes. + description: >- + Value to compare against for a criterion node. Required for leaf + nodes. example: microservice EntityPageResponse: type: object @@ -1000,9 +1022,9 @@ components: content: type: array items: - "$ref": "#/components/schemas/EntityDtoOut" + $ref: '#/components/schemas/EntityDtoOut' pageable: - "$ref": "#/components/schemas/PageableObject" + $ref: '#/components/schemas/PageableObject' totalElements: type: integer format: int64 @@ -1012,7 +1034,7 @@ components: last: type: boolean sort: - "$ref": "#/components/schemas/SortObject" + $ref: '#/components/schemas/SortObject' numberOfElements: type: integer format: int32 @@ -1029,8 +1051,8 @@ components: PageableObject: type: object properties: - unpaged: - type: boolean + sort: + $ref: '#/components/schemas/SortObject' paged: type: boolean pageNumber: @@ -1039,8 +1061,8 @@ components: pageSize: type: integer format: int32 - sort: - "$ref": "#/components/schemas/SortObject" + unpaged: + type: boolean offset: type: integer format: int64 @@ -1060,9 +1082,9 @@ components: content: type: array items: - "$ref": "#/components/schemas/EntityTemplateDtoOut" + $ref: '#/components/schemas/EntityTemplateDtoOut' pageable: - "$ref": "#/components/schemas/PageableObject" + $ref: '#/components/schemas/PageableObject' totalElements: type: integer format: int64 @@ -1072,7 +1094,7 @@ components: last: type: boolean sort: - "$ref": "#/components/schemas/SortObject" + $ref: '#/components/schemas/SortObject' numberOfElements: type: integer format: int32 @@ -1108,18 +1130,18 @@ components: type: array description: All entity nodes in the graph items: - "$ref": "#/components/schemas/EntityGraphNodeFlatDtoOut" + $ref: '#/components/schemas/EntityGraphNodeFlatDtoOut' edges: type: array description: All directed relation edges in the graph items: - "$ref": "#/components/schemas/EntityGraphEdgeDtoOut" + $ref: '#/components/schemas/EntityGraphEdgeDtoOut' EntityGraphNodeFlatDtoOut: type: object properties: id: type: string - description: Unique node identifier composed of templateIdentifier:identifier + description: 'Unique node identifier composed of templateIdentifier:identifier' label: type: string description: Human-readable entity name @@ -1132,8 +1154,9 @@ components: data: type: object additionalProperties: {} - description: Entity property values keyed by property name; present only - when include_data=true is requested + description: >- + Entity property values keyed by property name; present only when + include_data=true is requested securitySchemes: clientId: type: oauth2 @@ -1141,7 +1164,7 @@ components: name: clientId flows: clientCredentials: - tokenUrl: http://localhost:8080/auth/token + tokenUrl: 'http://localhost:8080/auth/token' bearer: type: http description: bearer authentication From 58d5f7d5c7e4d084e2a8b6be305eaa3dfc48b670 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Tue, 16 Jun 2026 17:10:03 +0200 Subject: [PATCH 43/53] feat(core): add a entity graph service and endpoint --- .../api/controller/EntityGraphController.java | 4 ++-- .../out/entity/EntityGraphRelationDtoOut.java | 0 .../EntityGraphEdgeDtoOut.java | 2 +- .../EntityGraphFlatDtoOut.java | 6 ++---- .../EntityGraphNodeFlatDtoOut.java | 17 +++++++---------- .../EntityGraphFlatDtoOutMapper.java | 9 ++++----- 6 files changed, 16 insertions(+), 22 deletions(-) delete mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java rename src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/{entity => entity_graph}/EntityGraphEdgeDtoOut.java (98%) rename src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/{entity => entity_graph}/EntityGraphFlatDtoOut.java (84%) rename src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/{entity => entity_graph}/EntityGraphNodeFlatDtoOut.java (88%) rename src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/{entity => entity_graph}/EntityGraphFlatDtoOutMapper.java (97%) diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java index 21777b8f..9dfad433 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java @@ -29,9 +29,9 @@ import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphTraversalMode; import com.decathlon.idp_core.domain.service.entity_graph.EntityGraphService; -import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityGraphFlatDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_graph.EntityGraphFlatDtoOut; import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse; -import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.EntityGraphFlatDtoOutMapper; +import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity_graph.EntityGraphFlatDtoOutMapper; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java deleted file mode 100644 index e69de29b..00000000 diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphEdgeDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_graph/EntityGraphEdgeDtoOut.java similarity index 98% rename from src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphEdgeDtoOut.java rename to src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_graph/EntityGraphEdgeDtoOut.java index d94bef18..37805a50 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphEdgeDtoOut.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_graph/EntityGraphEdgeDtoOut.java @@ -1,4 +1,4 @@ -package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity; +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_graph; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_EDGE_ID_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_EDGE_SOURCE_DESCRIPTION; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphFlatDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_graph/EntityGraphFlatDtoOut.java similarity index 84% rename from src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphFlatDtoOut.java rename to src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_graph/EntityGraphFlatDtoOut.java index a6127851..be2d23d9 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphFlatDtoOut.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_graph/EntityGraphFlatDtoOut.java @@ -1,4 +1,4 @@ -package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity; +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_graph; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_EDGES_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_NODES_DESCRIPTION; @@ -14,15 +14,13 @@ /// /// Separates entities from their connections into two parallel collections, /// following the de-facto standard expected by frontend visualization libraries -/// such as React Flow, Vis.js, and Cytoscape. This format avoids nesting and -/// any risk of infinite loops caused by circular relations. @JsonNaming(SnakeCaseStrategy.class) public record EntityGraphFlatDtoOut( @Schema(description = ENTITY_GRAPH_FLAT_NODES_DESCRIPTION) List nodes, @Schema(description = ENTITY_GRAPH_FLAT_EDGES_DESCRIPTION) List edges) { - /// Defensive copies prevent external mutation of the returned collections. + public EntityGraphFlatDtoOut { nodes = nodes != null ? List.copyOf(nodes) : List.of(); edges = edges != null ? List.copyOf(edges) : List.of(); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_graph/EntityGraphNodeFlatDtoOut.java similarity index 88% rename from src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java rename to src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_graph/EntityGraphNodeFlatDtoOut.java index 45fad52a..74db05fa 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_graph/EntityGraphNodeFlatDtoOut.java @@ -1,4 +1,4 @@ -package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity; +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_graph; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_NODE_DATA_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_NODE_IDENTIFIER_DESCRIPTION; @@ -17,26 +17,23 @@ /// Output DTO representing a single node in the flat entity graph. /// -/// Used by frontend visualization tools (React Flow, Vis.js, Cytoscape) that expect -/// entities and their relationships as separate, non-nested collections. +/// Used by frontend visualization tools (React Flow, Vis.js, Cytoscape) that +/// expect entities and their relationships as separate, non-nested collections. /// -/// The optional `data` field is populated only when `include_data=true` is requested, -/// containing property name-to-value pairs for the entity. +/// The optional `data` field is populated only when `include_data=true` is +/// requested, containing property name-to-value pairs for the entity. @JsonNaming(SnakeCaseStrategy.class) public record EntityGraphNodeFlatDtoOut( @Schema(description = ENTITY_GRAPH_FLAT_NODE_ID_DESCRIPTION) String id, - @Schema(description = ENTITY_GRAPH_FLAT_NODE_LABEL_DESCRIPTION) String label, - @Schema(description = ENTITY_GRAPH_FLAT_NODE_TEMPLATE_DESCRIPTION) String templateIdentifier, - @Schema(description = ENTITY_GRAPH_FLAT_NODE_IDENTIFIER_DESCRIPTION) String identifier, @JsonInclude(Include.NON_EMPTY) @Schema(description = ENTITY_GRAPH_FLAT_NODE_DATA_DESCRIPTION) Map data) { + /// Compact constructor: defensively copies the data map to prevent external - /// mutation - /// of the DTO after construction (EI_EXPOSE_REP2 / EI_EXPOSE_REP). + /// mutation of the DTO after construction (EI_EXPOSE_REP2 / EI_EXPOSE_REP). public EntityGraphNodeFlatDtoOut { data = data == null ? Map.of() : Map.copyOf(data); } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity_graph/EntityGraphFlatDtoOutMapper.java similarity index 97% rename from src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java rename to src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity_graph/EntityGraphFlatDtoOutMapper.java index a7d2f5d8..b09d2f5c 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity_graph/EntityGraphFlatDtoOutMapper.java @@ -1,4 +1,4 @@ -package com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity; +package com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity_graph; import java.util.ArrayList; import java.util.HashSet; @@ -13,13 +13,12 @@ import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphRelation; import com.decathlon.idp_core.domain.service.entity_graph.EntityGraphService; -import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityGraphEdgeDtoOut; -import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityGraphFlatDtoOut; -import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityGraphNodeFlatDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_graph.EntityGraphEdgeDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_graph.EntityGraphFlatDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity_graph.EntityGraphNodeFlatDtoOut; /// Mapper for converting a recursive [EntityGraphNode] domain tree into the flat /// nodes-and-edges representation expected by frontend visualization libraries -/// (React Flow, Vis.js, Cytoscape). /// /// **Design:** /// - Traverses both `relations` (outbound) and `relationsAsTarget` (inbound) depth-first, From d7e2e341f5889980530d1ec7d16d3b08d971adcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Wed, 17 Jun 2026 11:21:02 +0200 Subject: [PATCH 44/53] feat(core): add a entity graph service and endpoint --- docs/src/static/swagger.yaml | 4 +- .../port/EntityGraphRepositoryPort.java | 42 ++++++++++++------- .../entity_graph/EntityGraphService.java | 15 ++++--- .../api/configuration/SwaggerDescription.java | 2 +- .../PostgresEntityGraphAdapter.java | 8 ++-- .../repository/JpaEntityRepository.java | 11 +++-- .../EntityFilterSpecification.java | 4 +- src/main/resources/application-local.yml | 11 +++-- .../entity_graph/EntityGraphServiceTest.java | 4 +- .../controller/EntityGraphControllerTest.java | 2 +- 10 files changed, 56 insertions(+), 47 deletions(-) diff --git a/docs/src/static/swagger.yaml b/docs/src/static/swagger.yaml index 542ae443..e5f70058 100644 --- a/docs/src/static/swagger.yaml +++ b/docs/src/static/swagger.yaml @@ -6,8 +6,8 @@ info: servers: - url: http://localhost:8084 security: -- clientId: [] -- bearer: [] + - clientId: [] + - bearer: [] tags: - name: Entity Graph description: Entity relationship graph operations diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java index 87ed1cc9..842b8d3a 100644 --- a/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java @@ -22,24 +22,34 @@ /// as this port performs no write operations. public interface EntityGraphRepositoryPort { - /// Fetches all entities in the relationship graph rooted at the given composite - /// key. + /// Fetches all entities in the relationship graph rooted at the given entity + /// UUID. /// - /// Uses a recursive CTE to traverse both outbound and inbound relations up to - /// the - /// specified depth, then batch-loads all entities in a minimal number of - /// queries. + /// Performs an optimized recursive CTE (Common Table Expression) database query + /// to + /// traverse both outbound (forward) and inbound (reverse) relations up to the + /// requested depth. All loaded entities are batch-fetched to minimize database + /// round trips. /// - /// @param templateIdentifier the template identifier of the root entity - /// @param entityIdentifier the business identifier of the root entity within - /// its template - /// @param depth the maximum traversal depth (1-10) - /// @param includeProperties when true, entity properties are loaded along with - /// root not found - /// Relation name filtering is intentionally NOT pushed into this port. - /// The CTE always traverses all relation types so that nodes reachable via - /// any path are loaded. Edge filtering is applied in the service layer so - /// that "filter owns" still returns B and C when A→(depends-on)→B→(owns)→C. + /// **Key design notes:** + /// - Relation name filtering is intentionally NOT pushed into this port. The + /// CTE always traverses all relation types so that nodes reachable via any + /// path are loaded. Filtering (e.g., "only 'owns' relations") is applied in + /// the service layer, allowing for edge filtering without re-querying. + /// Example: "filter owns" still returns B and C when A→(depends-on)→B→(owns)→C. + /// - The traversal mode determines which relation directions are included. + /// + /// @param entityId the root entity UUID from which the graph is traversed + /// @param depth the maximum traversal depth; typically clamped to 1-6 by the + /// service layer + /// @param includeProperties when true, entity properties are eagerly loaded + /// along + /// with the root entity and all reachable entities + /// @param mode the graph traversal mode (BIDIRECTIONAL, OUTBOUND_ONLY, or + /// STRICT_LINEAGE) determining which relation directions to follow + /// @return an immutable map of all discovered entities keyed by their UUID, + /// including the root entity. Returns an empty map if the root entity + /// does not exist. Map findEntityGraph(UUID entityId, int depth, boolean includeProperties, EntityGraphTraversalMode mode); diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java index abab50f2..7fe4eca4 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java @@ -76,7 +76,7 @@ public EntityGraphNode getEntityGraph(String templateIdentifier, String entityId effectiveDepth, includeProperties, mode); if (entityMap == null || entityMap.isEmpty()) { - return new EntityGraphNode(rootEntity.id().toString(), rootEntity.identifier(), + return new EntityGraphNode(rootEntity.templateIdentifier(), rootEntity.identifier(), rootEntity.name(), List.of(), List.of(), List.of()); } @@ -239,11 +239,14 @@ private static record GraphTraversalContext(Map entityMap, Map memoCache, // High-speed in-memory reuse cache EntityGraphTraversalMode mode) { } -} -record EntityCompositeKey(String templateIdentifier, String identifier) { - public EntityCompositeKey { - templateIdentifier = templateIdentifier == null ? "" : templateIdentifier.trim().toLowerCase(); - identifier = identifier == null ? "" : identifier.trim().toLowerCase(); + private static record EntityCompositeKey(String templateIdentifier, String identifier) { + public EntityCompositeKey { + templateIdentifier = templateIdentifier == null + ? "" + : templateIdentifier.trim().toLowerCase(); + identifier = identifier == null ? "" : identifier.trim().toLowerCase(); + } } + } 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 71c7fa0f..20527874 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 @@ -174,7 +174,7 @@ public class SwaggerDescription { public static final String RESPONSE_INVALID_QUERY = "Invalid filter query syntax"; // --- Entity Graph (flat nodes & edges) descriptions --- - public static final String PARAM_DEPTH_DESCRIPTION = "Maximum traversal depth for relationship resolution. Clamped between 1 and 10."; + public static final String PARAM_DEPTH_DESCRIPTION = "Maximum traversal depth for relationship resolution. Clamped between 1 and 6."; public static final String ENDPOINT_GET_ENTITY_GRAPH_FLAT_SUMMARY = "Get entity relationship graph as flat nodes and edges"; public static final String ENDPOINT_GET_ENTITY_GRAPH_FLAT_DESCRIPTION = "Retrieves the entity relationship graph as a flat nodes-and-edges structure, suitable for frontend visualization tools such as React Flow, Vis.js, and Cytoscape."; public static final String RESPONSE_ENTITY_GRAPH_FLAT_SUCCESS = "Flat entity graph successfully retrieved"; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java index beddfdf4..ede3c29e 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java @@ -57,8 +57,7 @@ public Map findEntityGraph(UUID entityId, int depth, boolean inclu // so nodes reachable via any path are included even if the filter only matches // edges at deeper levels (e.g. filtering "owns" still returns B→C when A→B→C). final long tStartCte = System.nanoTime(); - List graphPairs = jpaEntityRepository.findEntityUuidsInGraph(entityId, depth, - mode.name()); + List graphPairs = jpaEntityRepository.findEntityIdsInGraph(entityId, depth, mode.name()); final long tAfterCte = System.nanoTime(); log.debug("[EntityGraphAdapter] CTE returned {} identifiers (elapsed={}ms)", graphPairs == null ? 0 : graphPairs.size(), (tAfterCte - tStartCte) / 1_000_000); @@ -83,15 +82,14 @@ public Map findEntityGraph(UUID entityId, int depth, boolean inclu // The two-query split also avoids Hibernate's MultipleBagFetchException. log.debug("[EntityGraphAdapter] Loading JPA entities with relations..."); final long tStartJpaLoad = System.nanoTime(); - List jpaEntities = jpaEntityRepository - .findAllByIdentifierInWithRelations(entitiesIds); + List jpaEntities = jpaEntityRepository.findAllByIdinWithRelations(entitiesIds); final long tAfterJpaLoad = System.nanoTime(); log.debug("[EntityGraphAdapter] Loaded {} JPA entities with relations (elapsed={}ms)", jpaEntities.size(), (tAfterJpaLoad - tStartJpaLoad) / 1_000_000); if (includeProperties) { log.debug("[EntityGraphAdapter] Loading properties for {} entities", entitiesIds.size()); final long tStartProps = System.nanoTime(); - List props = jpaEntityRepository.findAllByIdentifierInWithProperties(entitiesIds); + List props = jpaEntityRepository.findAllByIdInWithProperties(entitiesIds); final long tAfterProps = System.nanoTime(); log.debug( "[EntityGraphAdapter] Properties load completed (result was {} entries, elapsed={}ms)", diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java index fb41e625..d2a07286 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java @@ -46,13 +46,12 @@ Optional findByTemplateIdentifierAndIdentifier(String templateI LEFT JOIN FETCH e.relations r WHERE e.id IN :ids """) - List findAllByIdentifierInWithRelations(@Param("ids") Collection ids); + List findAllByIdinWithRelations(@Param("ids") Collection ids); /// Fetch properties for entities that were already loaded. This is called after - /// findAllByIdentifierInWithRelations to complete the entity graph. - @Query("SELECT DISTINCT e FROM EntityJpaEntity e LEFT JOIN FETCH e.properties WHERE e.id IN :identifiers") - List findAllByIdentifierInWithProperties( - @Param("identifiers") Collection ids); + /// findAllByIdInWithRelations to complete the entity graph. + @Query("SELECT DISTINCT e FROM EntityJpaEntity e LEFT JOIN FETCH e.properties WHERE e.id IN :ids") + List findAllByIdInWithProperties(@Param("ids") Collection ids); @Query(value = """ WITH RECURSIVE entity_graph(id, depth, flow) AS ( @@ -110,7 +109,7 @@ WITH RECURSIVE entity_graph(id, depth, flow) AS ( -- 3. Return the clean deduplicated set of structural skeleton UUIDs SELECT DISTINCT id FROM entity_graph; """, nativeQuery = true) - List findEntityUuidsInGraph(@Param("rootId") UUID rootId, @Param("depth") int depth, + List findEntityIdsInGraph(@Param("rootId") UUID rootId, @Param("depth") int depth, @Param("mode") String mode); @Modifying(clearAutomatically = true, flushAutomatically = true) diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntityFilterSpecification.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntityFilterSpecification.java index f72a8ad9..53698e7f 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntityFilterSpecification.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/specification/EntityFilterSpecification.java @@ -31,12 +31,12 @@ /// - Relation name criteria filter entities that have a relation with a specific /// name. /// - Relation entity criteria use an INNER JOIN on the `relations` collection -/// and then on the `targetEntityIdentifiers` element collection. +/// and then on the `targetEntities` element collection. /// - Relation property criteria use an INNER JOIN on the `relations` collection /// and filter on the specified property (e.g., `name`, `identifier`). /// - Relations as target name criteria find entities where they are targets of /// relations with a specific name (requires joining relations and checking -/// targetEntityIdentifiers). +/// targetEntities). /// - Join-based criteria call `query.distinct(true)` to prevent duplicate rows /// from /// - All criteria are combined with AND logic via [Specification#allOf]. diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 946318bb..a1c974c9 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -18,10 +18,9 @@ spring: validate-on-migrate: true jpa: hibernate: - ddl-auto: none # Disable JPA schema auto-generation, use Flyway instead - generate_statistics: true - format_sql: true - show-sql: false + ddl-auto: none # Disable JPA schema auto-generation, use Flyway instead + + app: full-refresh-at-startup: true idp-core-prefix-url: http://localhost:8084 @@ -33,6 +32,6 @@ logging: org.springframework.context.support: WARN # Suppresses a specific repetitive bean registration delegate log line. org.springframework.context.support.PostProcessorRegistrationDelegate: ERROR - org.hibernate.SQL: DEBUG - org.hibernate.stat: DEBUG + org.hibernate.SQL: WARN + org.hibernate.stat: WARN org.hibernate.type.descriptor.sql.BasicBinder: TRACE diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java index 11cf5b34..ec3b38cb 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java @@ -440,11 +440,11 @@ void shouldReturnEmptyPropertiesWhenIncludePropertiesIsFalse() { class VisitedNodeGuard { @Test - @DisplayName("Should complete at depth=10 without exponential recursion for a small graph") + @DisplayName("Should complete at depth=6 without exponential recursion for a small graph") void shouldNotExplodeAtMaxDepthWithSmallGraph() { // A --(uses)--> B --(uses)--> C; B also has inbound from A and C has inbound // from B. - // Without the visited-node guard this produces O(2^depth) calls at depth=10. + // Without the visited-node guard this produces O(2^depth) calls at depth=6. Entity a = entityWithRelations(TEMPLATE, "a", "A", List.of(relation("uses", TEMPLATE, "b"))); Entity b = entityWithRelations(TEMPLATE, "b", "B", List.of(relation("uses", TEMPLATE, "c"))); Entity c = entity(TEMPLATE, "c", "C"); diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java index 38590cbf..8abc1006 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java @@ -19,7 +19,7 @@ /// Integration tests for the EntityGraphController REST API endpoint. /// -/// Tests are based on the three-node chain seeded in R__2_Insert_entities_test_data.sql: +/// Tests are based on the three-node chain seeded in R__3_Insert_graph_entities_test_data.sql: /// /// graph-svc-a --[uses]--> graph-svc-b --[uses]--> graph-svc-c /// graph-svc-a --[monitors]--> graph-svc-b From 7f072607ea2c16bc3c26d708c80ce08ba174d431 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Wed, 17 Jun 2026 11:44:58 +0200 Subject: [PATCH 45/53] feat(core): add a entity graph service and endpoint --- .../persistence/repository/JpaRelationRepository.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaRelationRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaRelationRepository.java index 2e590a35..b6198fa6 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaRelationRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaRelationRepository.java @@ -25,10 +25,10 @@ public interface JpaRelationRepository extends JpaRepository Date: Wed, 17 Jun 2026 14:11:16 +0200 Subject: [PATCH 46/53] feat(core): add a entity graph service and endpoint --- .../entity_graph/EntityGraphService.java | 9 ++-- .../mapper/EntityPersistenceMapper.java | 54 ++++++++++--------- .../entity_graph/EntityGraphServiceTest.java | 31 ++++++++--- 3 files changed, 60 insertions(+), 34 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java index 7fe4eca4..8aec14f4 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java @@ -162,8 +162,9 @@ private EntityGraphNode buildGraphNode(UUID entityUuid, GraphTraversalContext ct private List buildRelationsAsTargetFromIndex(String targetIdentifier, GraphTraversalContext ctx) { - // Only build inbound relations for BIDIRECTIONAL mode - if (ctx.mode() != EntityGraphTraversalMode.BIDIRECTIONAL) { + // Include inbound relations for BIDIRECTIONAL and DIRECT_LINEAGE modes + if (ctx.mode() != EntityGraphTraversalMode.BIDIRECTIONAL + && ctx.mode() != EntityGraphTraversalMode.DIRECT_LINEAGE) { return List.of(); } @@ -199,7 +200,9 @@ private IndexBundle buildIndices(Map entityMap, EntityGraphTravers textToUuidLookup.put(new EntityCompositeKey(entity.templateIdentifier(), entity.identifier()), sourceUuid); - if (mode == EntityGraphTraversalMode.BIDIRECTIONAL) { + // Build inbound index for BIDIRECTIONAL and DIRECT_LINEAGE modes + if (mode == EntityGraphTraversalMode.BIDIRECTIONAL + || mode == EntityGraphTraversalMode.DIRECT_LINEAGE) { buildInboundIndexForEntity(entity, sourceUuid, inboundIndex); } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java index c0441c8f..2eab1ee6 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java @@ -97,47 +97,53 @@ public Relation toDomain(RelationJpaEntity jpa) { return null; } - // Pure in-memory transformation from the unified JPA row down to your string - // list + // Pure in-memory transformation from the unified JPA row down to string list + // Extract the cached text string List targetIdentifiers = jpa.getTargetEntities() != null - ? jpa.getTargetEntities().stream().map(RelationTargetJpaEntity::getTargetEntityIdentifier) // Extract - // the - // cached - // text - // string + ? jpa.getTargetEntities().stream().map(RelationTargetJpaEntity::getTargetEntityIdentifier) .filter(Objects::nonNull).toList() : List.of(); return new Relation(jpa.getId(), jpa.getName(), jpa.getTargetTemplateIdentifier(), - targetIdentifiers); // Matches your current domain model signature perfectly! + targetIdentifiers); } /// Converts domain relation to JPA entity. Resolves business identifiers to - /// UUIDs by querying the entity repository. The JPA entity stores only UUIDs; - /// identifiers are not persisted in the infrastructure layer. + /// UUIDs by querying the entity repository in a single batch operation. The JPA + /// entity stores both UUIDs (for graph traversal) and identifiers (for + /// Java mapping). public RelationJpaEntity toJpa(Relation domain) { if (domain == null) { return null; } - // Look up matching entities to bind both fields concurrently into single table - // rows + // Batch resolve all target identifiers in a single query, then map in-memory List targetEntities = domain.targetEntityIdentifiers() != null - ? domain.targetEntityIdentifiers().stream().map(identifier -> entityRepository - .findByTemplateIdentifierAndIdentifier(domain.targetTemplateIdentifier(), identifier) - .map(entity -> new RelationTargetJpaEntity(entity.getId(), // The binary UUID used for - // Graph CTE crawls - entity.getIdentifier() // The immutable string cached for Java mapping - )).orElse(null)).filter(Objects::nonNull).toList() + ? resolveBatchTargetEntities(domain.targetTemplateIdentifier(), + domain.targetEntityIdentifiers()) : List.of(); - // Return the unified entity mapping to prevent column nullability errors return RelationJpaEntity.builder().id(domain.id()).name(domain.name()) - .targetTemplateIdentifier(domain.targetTemplateIdentifier()).targetEntities(targetEntities) // The - // single - // unified - // collection - // table + .targetTemplateIdentifier(domain.targetTemplateIdentifier()).targetEntities(targetEntities) .build(); } + + /// Resolves multiple target identifiers in a single batch query, then maps the + /// results in-memory to RelationTargetJpaEntity records. This prevents N+1 query + /// patterns when relations have many targets. + private List resolveBatchTargetEntities( + String targetTemplateIdentifier, List targetIdentifiers) { + if (targetIdentifiers == null || targetIdentifiers.isEmpty()) { + return List.of(); + } + + // Single batch query to resolve all targets at once + List resolvedEntities = entityRepository + .findAllByTemplateIdentifierAndIdentifierIn(targetTemplateIdentifier, targetIdentifiers); + + // Map results in-memory, preserving only successfully resolved entities + return resolvedEntities.stream() + .map(entity -> new RelationTargetJpaEntity(entity.getId(), entity.getIdentifier())) + .toList(); + } } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java index ec3b38cb..347c01f1 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java @@ -549,28 +549,45 @@ void outboundOnlyModeShouldExcludeInboundRelations() { } @Test - @DisplayName("DIRECT_LINEAGE mode should include only outbound relations") - void strictLineageModeShouldExcludeInboundRelations() { + @DisplayName("DIRECT_LINEAGE mode should show inbound of root and outbound of downstream") + void directLineageModeShouldShowInboundAtRootAndOutboundDownstream() { + // Setup: consumer -> api -> postgres, backend -> api Entity api = entityWithRelations(TEMPLATE, "api", "API Service", List.of(relation("uses-db", "database", "postgres"))); Entity postgres = entity("database", "postgres", "Postgres DB"); Entity consumer = entityWithRelations(TEMPLATE, "consumer", "Consumer", List.of(relation("depends-on", TEMPLATE, "api"))); + Entity backend = entityWithRelations(TEMPLATE, "backend", "Backend", + List.of(relation("calls", TEMPLATE, "api"))); when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) .thenReturn(Optional.of(api)); - stubGraph(api, postgres, consumer); + stubGraph(api, postgres, consumer, backend); - EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 2, false, Set.of(), Set.of(), EntityGraphTraversalMode.DIRECT_LINEAGE); - // Should have outbound relation to postgres + // Root should have inbound relations: consumer -> api, backend -> api + assertThat(result.relationsAsTarget()).hasSize(2); + var inboundIdentifiers = result.relationsAsTarget().stream() + .flatMap(rel -> rel.targets().stream()).map(EntityGraphNode::identifier).toList(); + assertThat(inboundIdentifiers).containsExactlyInAnyOrder("consumer", "backend"); + + // Root should have outbound relation: api -> postgres assertThat(result.relations()).hasSize(1); assertThat(result.relations().get(0).name()).isEqualTo("uses-db"); assertThat(result.relations().get(0).targets().get(0).identifier()).isEqualTo("postgres"); - // Should NOT have inbound relations - assertThat(result.relationsAsTarget()).isEmpty(); + // Downstream nodes (consumer, backend) should NOT have inbound relations + var consumerNode = result.relationsAsTarget().stream() + .flatMap(rel -> rel.targets().stream()).filter(node -> node.identifier().equals("consumer")) + .findFirst().orElseThrow(); + assertThat(consumerNode.relationsAsTarget()).isEmpty(); + + var backendNode = result.relationsAsTarget().stream() + .flatMap(rel -> rel.targets().stream()).filter(node -> node.identifier().equals("backend")) + .findFirst().orElseThrow(); + assertThat(backendNode.relationsAsTarget()).isEmpty(); } @Test From 1a0d8a1a2f4f10c18ee4292f745849fd16960faa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Wed, 17 Jun 2026 14:18:54 +0200 Subject: [PATCH 47/53] feat(core): add a entity graph service and endpoint --- .../entity/EntityGraphFlatDtoOutMapper.java | 0 .../PostgresEntityGraphAdapter.java | 38 +------------------ .../mapper/EntityPersistenceMapper.java | 7 ++-- .../entity_graph/EntityGraphServiceTest.java | 10 ++--- 4 files changed, 10 insertions(+), 45 deletions(-) create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java new file mode 100644 index 00000000..e69de29b diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java index ede3c29e..0144157f 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java @@ -45,10 +45,6 @@ public class PostgresEntityGraphAdapter implements EntityGraphRepositoryPort { @Transactional(readOnly = true) public Map findEntityGraph(UUID entityId, int depth, boolean includeProperties, EntityGraphTraversalMode mode) { - log.debug( - "[EntityGraphAdapter] findEntityGraph start: entityId={}, depth={}, includeProperties={}", - entityId, depth, includeProperties); - final long tStartTotal = System.nanoTime(); // Step 1: collect all (identifier, template_identifier) pairs via recursive // CTE. @@ -56,11 +52,7 @@ public Map findEntityGraph(UUID entityId, int depth, boolean inclu // Relation name filtering is applied at the service level when building edges, // so nodes reachable via any path are included even if the filter only matches // edges at deeper levels (e.g. filtering "owns" still returns B→C when A→B→C). - final long tStartCte = System.nanoTime(); List graphPairs = jpaEntityRepository.findEntityIdsInGraph(entityId, depth, mode.name()); - final long tAfterCte = System.nanoTime(); - log.debug("[EntityGraphAdapter] CTE returned {} identifiers (elapsed={}ms)", - graphPairs == null ? 0 : graphPairs.size(), (tAfterCte - tStartCte) / 1_000_000); if (graphPairs == null || graphPairs.isEmpty()) { log.debug( @@ -69,48 +61,22 @@ public Map findEntityGraph(UUID entityId, int depth, boolean inclu } // Step 2: extract unique identifiers for batch loading - final long tStartIdExtract = System.nanoTime(); List entitiesIds = graphPairs.stream().map(pair -> pair).distinct().toList(); - final long tAfterIdExtract = System.nanoTime(); - log.debug("[EntityGraphAdapter] Unique entity ids to load: {} (extraction elapsed={}ms)", - entitiesIds.size(), (tAfterIdExtract - tStartIdExtract) / 1_000_000); // Step 3: batch-load entities with relations, then optionally properties in a // separate query. // Properties are skipped when not requested to avoid the extra round-trip and // keep payloads lean. // The two-query split also avoids Hibernate's MultipleBagFetchException. - log.debug("[EntityGraphAdapter] Loading JPA entities with relations..."); - final long tStartJpaLoad = System.nanoTime(); List jpaEntities = jpaEntityRepository.findAllByIdinWithRelations(entitiesIds); - final long tAfterJpaLoad = System.nanoTime(); - log.debug("[EntityGraphAdapter] Loaded {} JPA entities with relations (elapsed={}ms)", - jpaEntities.size(), (tAfterJpaLoad - tStartJpaLoad) / 1_000_000); if (includeProperties) { - log.debug("[EntityGraphAdapter] Loading properties for {} entities", entitiesIds.size()); - final long tStartProps = System.nanoTime(); - List props = jpaEntityRepository.findAllByIdInWithProperties(entitiesIds); - final long tAfterProps = System.nanoTime(); - log.debug( - "[EntityGraphAdapter] Properties load completed (result was {} entries, elapsed={}ms)", - props == null ? 0 : props.size(), (tAfterProps - tStartProps) / 1_000_000); + jpaEntityRepository.findAllByIdInWithProperties(entitiesIds); } // Step 4: map to domain and key by composite key for O(1) lookup - log.debug("[EntityGraphAdapter] Mapping JPA entities to domain models..."); - final long tStartMap = System.nanoTime(); - - Map result = jpaEntities.stream().map(mapper::toDomain) + return jpaEntities.stream().map(mapper::toDomain) .collect(Collectors.toMap(Entity::id, Function.identity())); - final long tAfterMap = System.nanoTime(); - - log.debug("[EntityGraphAdapter] Mapping completed, returning {} domain entities (elapsed={}ms)", - result.size(), (tAfterMap - tStartMap) / 1_000_000); - final long tEndTotal = System.nanoTime(); - log.debug("[EntityGraphAdapter] findEntityGraph end: entityId={} totalElapsed={}ms", entityId, - (tEndTotal - tStartTotal) / 1_000_000); - return result; } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java index 2eab1ee6..10449ba8 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java @@ -129,10 +129,11 @@ public RelationJpaEntity toJpa(Relation domain) { } /// Resolves multiple target identifiers in a single batch query, then maps the - /// results in-memory to RelationTargetJpaEntity records. This prevents N+1 query + /// results in-memory to RelationTargetJpaEntity records. This prevents N+1 + /// query /// patterns when relations have many targets. - private List resolveBatchTargetEntities( - String targetTemplateIdentifier, List targetIdentifiers) { + private List resolveBatchTargetEntities(String targetTemplateIdentifier, + List targetIdentifiers) { if (targetIdentifiers == null || targetIdentifiers.isEmpty()) { return List.of(); } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java index 347c01f1..350e3aa7 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java @@ -579,14 +579,12 @@ void directLineageModeShouldShowInboundAtRootAndOutboundDownstream() { assertThat(result.relations().get(0).targets().get(0).identifier()).isEqualTo("postgres"); // Downstream nodes (consumer, backend) should NOT have inbound relations - var consumerNode = result.relationsAsTarget().stream() - .flatMap(rel -> rel.targets().stream()).filter(node -> node.identifier().equals("consumer")) - .findFirst().orElseThrow(); + var consumerNode = result.relationsAsTarget().stream().flatMap(rel -> rel.targets().stream()) + .filter(node -> node.identifier().equals("consumer")).findFirst().orElseThrow(); assertThat(consumerNode.relationsAsTarget()).isEmpty(); - var backendNode = result.relationsAsTarget().stream() - .flatMap(rel -> rel.targets().stream()).filter(node -> node.identifier().equals("backend")) - .findFirst().orElseThrow(); + var backendNode = result.relationsAsTarget().stream().flatMap(rel -> rel.targets().stream()) + .filter(node -> node.identifier().equals("backend")).findFirst().orElseThrow(); assertThat(backendNode.relationsAsTarget()).isEmpty(); } From d1e11d3ed5828653160de115ad0c6356c7f19940 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Wed, 17 Jun 2026 14:19:40 +0200 Subject: [PATCH 48/53] feat(core): add a entity graph service and endpoint --- .../adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java deleted file mode 100644 index e69de29b..00000000 From 76b4b47b4debe5953cfd1fc718ea244fbbf913f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Thu, 18 Jun 2026 11:48:15 +0200 Subject: [PATCH 49/53] feat(core): add a depth param to get entities --- .../adrs/0005-entity-related-data.md | 134 +++++++++++++ .../port/EntityGraphRepositoryPort.java | 16 ++ .../entity_graph/EntityGraphService.java | 179 ++++++++++++++++++ .../api/controller/EntityController.java | 62 ++++++ .../api/dto/out/entity/EntityDepDtoOut.java | 23 +++ .../api/mapper/entity/EntityDtoOutMapper.java | 71 +++++++ .../PostgresEntityGraphAdapter.java | 39 ++++ .../repository/JpaEntityRepository.java | 59 ++++++ 8 files changed, 583 insertions(+) create mode 100644 docs/src/contributing/adrs/0005-entity-related-data.md create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityDepDtoOut.java diff --git a/docs/src/contributing/adrs/0005-entity-related-data.md b/docs/src/contributing/adrs/0005-entity-related-data.md new file mode 100644 index 00000000..f563310d --- /dev/null +++ b/docs/src/contributing/adrs/0005-entity-related-data.md @@ -0,0 +1,134 @@ +# Related Entity Data Exposition + +* Status: Proposed +* Deciders: + * maintainers team: Andrés BRAND, Matthieu WALTERSPIELER, Eve BERNHARD, Ferial OUKOUKES, Renny VANDOMBER + * contributors team: `N/A` +* Consulted: Étienne JACQUOT +* maintainers team: IDP Core Maintainers +* contributors team: N/A + + +## Context and Problem Statement + +Our Internal Developer Platform (IDP) utilizes an Entity-Attribute-Value (EAV) storage layout to maintain a highly dynamic component catalog. Frontend graph visualizations and catalog dashboards frequently need to present properties belonging to *related* entities alongside a primary entity (for example, showing a `repository_url` from a linked `GitHubRepository` node while inspecting a `Software` component). + +This ADR is for deciding how we will provide clients with programmatic access to these deeply nested, linked properties while ensuring an outstanding frontend Developer Experience (DX), maintaining real-time data consistency, and strictly protecting our 150ms backend query performance baseline. + +## Decision Drivers + +* Security +* User experience +* Complexity of implementation +* FinOps +* Maintainability +* Modularity +* Readability +* Extensibility +* Scalability + +## Considered Options + +1. Linked (Mirror) Properties + +1.a. Linked (Mirror) Stored Properties Feature (Data Duplication via Templates) + +1.b. Linked (Mirror) Calculated-at-Request Properties Feature (Dynamic Injected Mapping) + +2. Dedicated Property Projection REST Endpoint (Smart Backend, Flat Tree Harvest) + +3. Native GraphQL Server Layer (EAV Schema Compilation) + +## Decision Outcome + +Chosen option: Option 2, "Dedicated Property Projection REST Endpoint," because it comes out best. It delivers the precise payload-trimming advantages of GraphQL and the real-time data accuracy of live fetches, while completely preserving our bounded, single-pass 150ms database execution layout (O(1) database calls) and avoiding complex framework abstractions. + +Unlike Option 1.a, it introduces zero data consistency risks, and unlike Option 1.b, it completely separates graph structural retrieval from standard entity reads, ensuring our primary CRUD APIs stay lean and unburdened by heavy runtime graph lookup logic. + +### Positive Consequences + +* **Sub-Millisecond Object Traversal:** The frontend receives an optimized, pre-flattened key-value payload layout. Developers can access deeply nested attributes instantly using direct paths without writing defensive loops. +* **Bounded Resource Utilization:** Database workloads remain completely predictable. The data pipeline always executes exactly 3 database queries total per batch execution window, protecting database resources. +* **Guaranteed Data Integrity:** Zero data replication means there is no risk of sync delay or stale data states. + +### Negative Consequences + +* **Fixed Response Shape Envelope:** The JSON response layout structure is defined by backend DTO signatures, meaning individual metadata structural keys cannot be arbitrarily pruned out of serialization streams on a whim per client call. + +--- + +## Pros and Cons of the Options + +### 1.a. Linked (Mirror) Stored Properties Feature + +Systematically copy and physically store specified properties from a target related entity onto the source entity row in the EAV database based on configuration rules defined inside the system templates. + +* Good, because read performance is exceptionally fast since all required properties are pulled directly out of a single primary entity row layout (**Performance & Latency**). +* Good, because standard flat REST payloads automatically return everything inside a traditional, predictable dictionary model (**User experience**). +* Bad, because updates require cascade background worker processes to sync mutated strings across the EAV database, risking partial failures and state drift (**Data consistency**). +* Bad, because duplicate data strings are scattered across database collections, wasting disk space allocation (**FinOps**). + +### 1.b. Linked (Mirror) Calculated-at-Request Properties Feature + +Do not store duplicate values in the database. Instead, whenever a standard entity is requested, the system automatically uses the template configuration to dynamically look up the related entity's properties during the runtime processing cycle and injects them directly into the source entity's payload data model before returning it. + +* Good, because it provides the client with a single flat entity structure containing nested data without storing redundant records in the database (**Data consistency**, **User experience**). +* Good, because values are computed live from their true sources, ensuring the frontend never displays stale data (**Data consistency**). +* Bad, because it couples every standard entity read to a runtime relationship evaluation, which degrades the performance of basic database lookups even when users do not need the extra graph properties (**Performance & Latency**). +* Bad, because it risks triggering massive N+1 sequential database loops or complex custom query interceptors during normal entity fetching workflows (**Complexity of implementation**). + +### 2. Dedicated Property Projection REST Endpoint + +Introduce a specialized `POST` query endpoint that accepts a search payload. It uses the `EntityGraphService` to fetch the complete graph tree in memory, harvests the target attributes via a clean recursive pass, and flattens the response into a direct key-value dictionary before serialization. + +We could have an request contract like this: + +```json +{ + "roots": [ + { + "templateIdentifier": "microservice", + "identifier": "ordering-api" + }, + { + "templateIdentifier": "worker-pool", + "identifier": "payment-worker" + } + ], + "depth": 2, + "mode": "STRICT_LINEAGE", + "propertyProjections": [ + { + "relationName": "has_repository", + "targetTemplateIdentifier": "github-repository", + "propertyNames": ["repository_url", "default_branch"] + }, + { + "relationName": "monitored_by", + "targetTemplateIdentifier": "sonarqube-project", + "propertyNames": ["quality_gate_status"] + } + ] +} +``` + +* Good, because it transforms complex nested tree maps into a direct key-value dictionary schema before serialization, eliminating frontend parsing overhead (**User experience**). +* Good, because values are fetched directly from their original source entries in real time with zero data replication drift (**Data consistency**). +* Good, because it maps directly onto our optimized, single-pass batch recursive CTE query structure without adding extra heavy frameworks (**Complexity of implementation**, **Performance & Latency**). +* Good, because it trims away unrequested EAV properties and structural graph nodes before serialization, lowering payload sizes over the wire (**FinOps**). +* Bad, because the final layout wrapper structure is locked by backend DTO signatures rather than dynamic client string parsing text fields (**User experience**). + +### 3. Native GraphQL Server Layer + +Introduce a formal GraphQL server framework interface on top of our EAV model, enabling clients to pick exact fields and navigate cross-entity pathways using nested query strings. + +* Good, because self-documenting graph schemas with built-in autocomplete utilities offer high operational discovery flexibility (**User experience**). +* Bad, because its field-by-field execution model breaks our single bulk CTE query, requiring iterative batch queries per depth level (O(depth) execution footprint) (**Performance & Latency**). +* Bad, because data is returned matching the query shape, forcing UI developers to safely parse nested arrays of edges, targets, and properties in JavaScript (**User experience**). +* Bad, because it requires building a complex schema compiler to dynamically register dynamic GraphQL schemas out of runtime EAV template rules (**Complexity of implementation**). + +--- + +## More Information + +To support complex projection setups, this implementation will support an optional dot-notation key flattening format (e.g., `"github-repository.repository_url"`) right within the response builder layer to further simplify frontend state mapping integration. All query execution metrics will be piped to our standard catalog dashboard trackers to monitor performance stability over time. \ No newline at end of file diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java index 842b8d3a..36d28796 100644 --- a/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java @@ -53,4 +53,20 @@ public interface EntityGraphRepositoryPort { Map findEntityGraph(UUID entityId, int depth, boolean includeProperties, EntityGraphTraversalMode mode); + /// Fetches relationship graphs for multiple root entities in a batch operation. + /// + /// Equivalent to calling [findEntityGraph] for each root entity, but optimized + /// to use a single batch recursive CTE query for better database performance. + /// + /// @param rootIds list of root entity UUIDs from which graphs are traversed + /// @param depth the maximum traversal depth; typically clamped to 1-6 by the + /// service layer + /// @param includeProperties when true, entity properties are eagerly loaded + /// @param mode the graph traversal mode determining which relation directions to + /// follow + /// @return an immutable map of all discovered entities keyed by their UUID, + /// including all root entities and their reachable neighbors + Map findEntityGraphBatch(java.util.List rootIds, int depth, + boolean includeProperties, EntityGraphTraversalMode mode); + } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java index 8aec14f4..37eabf8b 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java @@ -95,6 +95,110 @@ public EntityGraphNode getEntityGraph(String templateIdentifier, String entityId return buildGraphNode(rootEntity.id(), ctx); } + /// Retrieves entity graphs for multiple root entities in a single batch operation. + /// + /// **Contract:** Returns a map of entity identifier strings to EntityGraphNode + /// objects for efficient bulk processing. Applies the same depth clamping, + /// property inclusion, and relation/property filtering as the single-entity + /// variant. + /// + /// @param entityGraphs map of entities keyed by their UUID (as loaded by + /// batch repository call) + /// @param depth the maximum traversal depth + /// @param includeProperties whether to include entity properties + /// @param relationFilter set of relation names to include (empty for all) + /// @param propertyFilter set of property names to include (empty for all) + /// @param mode the graph traversal mode + /// @return a map of entity identifier strings to EntityGraphNode objects + @Transactional(readOnly = true) + public Map getBatchEntityGraphsByIdentifiers( + Map entityGraphs, int depth, boolean includeProperties, + Set relationFilter, Set propertyFilter, EntityGraphTraversalMode mode) { + + if (entityGraphs == null || entityGraphs.isEmpty()) { + return Map.of(); + } + + // Pre-computation Layer + IndexBundle indices = buildIndices(entityGraphs, mode); + + // Context tracking for this batch execution + Set activeStack = new HashSet<>(); + Map memoCache = new HashMap<>(); + + GraphTraversalContext ctx = new GraphTraversalContext(entityGraphs, indices.textToUuidLookup(), + indices.inboundIndex(), includeProperties, propertyFilter, relationFilter, activeStack, + memoCache, mode); + + // Build graph nodes for all entities in the batch + Map result = new HashMap<>(); + for (Map.Entry entry : entityGraphs.entrySet()) { + Entity entity = entry.getValue(); + if (entity != null) { + EntityGraphNode node = buildGraphNode(entry.getKey(), ctx); + result.put(entity.identifier(), node); + } + } + + return result; + } + + /// Loads and builds entity graphs for multiple entity IDs in a single batch operation. + /// + /// **Contract:** Fetches entity graphs from the repository for the given entity IDs + /// using the specified traversal mode and depth, then converts them to EntityGraphNode + /// objects. This is the recommended entry point for batch graph retrieval. + /// + /// @param entityIds list of entity UUIDs to load graphs for + /// @param depth maximum traversal depth; clamped to 1-6 server-side + /// @param includeProperties whether to include entity properties in the graph + /// @param relationFilter set of relation names to include (empty for all) + /// @param propertyFilter set of property names to include (empty for all) + /// @param mode the graph traversal mode (BIDIRECTIONAL, OUTBOUND_ONLY, or DIRECT_LINEAGE) + /// @return a map of entity identifier strings to their EntityGraphNode representations + @Transactional(readOnly = true) + public Map loadAndBuildEntityGraphs(List entityIds, int depth, + boolean includeProperties, Set relationFilter, Set propertyFilter, + EntityGraphTraversalMode mode) { + + if (entityIds == null || entityIds.isEmpty()) { + return Map.of(); + } + + int effectiveDepth = Math.clamp(depth, 1, MAX_DEPTH); + + // Load entity graphs from repository + Map entityGraphs = entityGraphRepositoryPort.findEntityGraphBatch(entityIds, + effectiveDepth, includeProperties, mode); + + if (entityGraphs == null || entityGraphs.isEmpty()) { + return Map.of(); + } + + // Pre-computation Layer + IndexBundle indices = buildIndices(entityGraphs, mode); + + // Context tracking for this batch execution + Set activeStack = new HashSet<>(); + Map memoCache = new HashMap<>(); + + GraphTraversalContext ctx = new GraphTraversalContext(entityGraphs, indices.textToUuidLookup(), + indices.inboundIndex(), includeProperties, propertyFilter, relationFilter, activeStack, + memoCache, mode); + + // Build graph nodes for all entities in the batch + Map result = new HashMap<>(); + for (Map.Entry entry : entityGraphs.entrySet()) { + Entity entity = entry.getValue(); + if (entity != null) { + EntityGraphNode node = buildGraphNode(entry.getKey(), ctx); + result.put(entity.identifier(), node); + } + } + + return result; + } + private EntityGraphNode buildGraphNode(UUID entityUuid, GraphTraversalContext ctx) { Entity entity = ctx.entityMap().get(entityUuid); @@ -252,4 +356,79 @@ private static record EntityCompositeKey(String templateIdentifier, String ident } } + /// Builds entity graphs for multiple entity string identifiers within a template. + /// + /// **Contract:** Retrieves entity graphs from the repository for entities identified by + /// their string identifiers, then converts them to EntityGraphNode objects. This method + /// is useful for batch operations where you have string identifiers rather than UUIDs. + /// + /// @param templateIdentifier the entity template identifier + /// @param entityIdentifiers list of entity string identifiers to load graphs for + /// @param depth maximum traversal depth; clamped to 1-6 server-side + /// @param includeProperties whether to include entity properties in the graph + /// @param relationFilter set of relation names to include (empty for all) + /// @param propertyFilter set of property names to include (empty for all) + /// @param mode the graph traversal mode (BIDIRECTIONAL, OUTBOUND_ONLY, or DIRECT_LINEAGE) + /// @return a map of entity identifier strings to their EntityGraphNode representations + @Transactional(readOnly = true) + public Map getBatchEntityGraphsByIdentifiers(String templateIdentifier, + List entityIdentifiers, int depth, boolean includeProperties, + Set relationFilter, Set propertyFilter, EntityGraphTraversalMode mode) { + + if (entityIdentifiers == null || entityIdentifiers.isEmpty()) { + return Map.of(); + } + + int effectiveDepth = Math.clamp(depth, 1, MAX_DEPTH); + entityTemplateValidationService.validateTemplateExists(templateIdentifier); + + // Fetch entity UUIDs by loading each entity individually + // (Repository doesn't support batch fetch by string identifiers) + List entityUuids = new ArrayList<>(); + for (String identifier : entityIdentifiers) { + entityRepositoryPort.findByTemplateIdentifierAndIdentifier(templateIdentifier, identifier) + .ifPresent(entity -> entityUuids.add(entity.id())); + } + + if (entityUuids.isEmpty()) { + return Map.of(); + } + + // Load entity graphs from repository + Map entityGraphs = entityGraphRepositoryPort.findEntityGraphBatch(entityUuids, + effectiveDepth, includeProperties, mode); + + if (entityGraphs == null || entityGraphs.isEmpty()) { + return Map.of(); + } + + // Pre-computation Layer + IndexBundle indices = buildIndices(entityGraphs, mode); + + // Context tracking for this batch execution + Set activeStack = new HashSet<>(); + Map memoCache = new HashMap<>(); + + GraphTraversalContext ctx = new GraphTraversalContext(entityGraphs, indices.textToUuidLookup(), + indices.inboundIndex(), includeProperties, propertyFilter, relationFilter, activeStack, + memoCache, mode); + + // Build graph nodes for all root entities in the batch + Map result = new HashMap<>(); + for (String identifier : entityIdentifiers) { + UUID entityUuid = null; + for (Map.Entry entry : entityGraphs.entrySet()) { + if (entry.getValue().identifier().equals(identifier)) { + entityUuid = entry.getKey(); + break; + } + } + if (entityUuid != null) { + EntityGraphNode node = buildGraphNode(entityUuid, ctx); + result.put(identifier, node); + } + } + + return result; + } } 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 0eb19c6a..75f4f397 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 @@ -47,6 +47,7 @@ import static org.springframework.http.HttpStatus.OK; import java.util.List; +import java.util.Map; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; @@ -69,11 +70,14 @@ 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_graph.EntityGraphNode; +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphTraversalMode; 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.entity_graph.EntityGraphService; 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; @@ -81,6 +85,7 @@ 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.dto.out.entity.EntityDepDtoOut; 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; @@ -112,6 +117,7 @@ public class EntityController { private final EntityService entityService; + private final EntityGraphService entityGraphService; private final EntityDtoOutMapper entityDtoOutMapper; private final EntityDtoInMapper entityDtoInMapper; private final EntityFilterDslParser entityFilterDslParser; @@ -282,6 +288,62 @@ public void deleteEntity(@NotBlank @PathVariable String templateIdentifier, entityService.deleteEntity(templateIdentifier, entityIdentifier); } + /// Retrieves paginated entities with their dependency graphs up to specified depth. + /// + /// **API contract:** Returns a paginated list of entities with their outbound and + /// inbound relations merged into a single relations object. Each entity includes all + /// reachable nodes up to the specified depth using DIRECT_LINEAGE traversal mode. + /// Results are returned as EntityDepDtoOut DTOs with relations merged from both + /// directions. + /// + /// @param page zero-based page index for pagination navigation + /// @param size number of entities per page for response size control + /// @param templateIdentifier template filter for entity scope limitation + /// @param depth maximum relation traversal depth (default 1, clamped to 1-6) + /// @param q optional filter query string for entity filtering + /// @return paginated entity dependency DTOs with merged relations up to depth + @Operation(summary = "Get entity dependencies", description = "Retrieve entities with their relationship graphs up to specified depth") + @ApiResponse(responseCode = OK_CODE, description = "Entity dependencies retrieved successfully", content = @Content(schema = @Schema(implementation = EntityPageResponse.class))) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_PAGINATION, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @Parameter(name = "page", description = PARAM_PAGE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "0"))) + @Parameter(name = "size", description = PARAM_SIZE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "20"))) + @Parameter(name = "sort", description = PARAM_SORT_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string", defaultValue = "identifier,asc"))) + @Parameter(name = "depth", description = "Maximum relation traversal depth (1-6, default 1)", in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "1"))) + @Parameter(name = "q", description = PARAM_QUERY_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string"))) + @ResponseStatus(OK) + @GetMapping("/{templateIdentifier}/dependencies") + public Page getEntitiesDependencies(@RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, @PathVariable String templateIdentifier, + @RequestParam(defaultValue = "1") int depth, @RequestParam(required = false) String q) { + Pageable pageable = PageRequest.of(page, size); + EntityFilter filter = entityFilterDslParser.parse(q); + Page entities = entityService.getEntitiesByTemplateIdentifier(pageable, templateIdentifier, + filter); + + // Extract entity identifiers for batch graph loading + List entityIdentifiers = entities.getContent().stream().map(Entity::identifier).toList(); + + if (entityIdentifiers.isEmpty()) { + return new PageImpl<>(List.of(), pageable, 0); + } + + // Load entity graphs with DIRECT_LINEAGE mode (includes outbound + inbound relations) + Map entityGraphs = entityGraphService + .getBatchEntityGraphsByIdentifiers(templateIdentifier, entityIdentifiers, depth, false, + java.util.Set.of(), java.util.Set.of(), EntityGraphTraversalMode.DIRECT_LINEAGE); + + // Map to EntityDepDtoOut with merged relations + List dtoOutList = entities.getContent().stream() + .map(entity -> { + EntityGraphNode graphNode = entityGraphs.getOrDefault(entity.identifier(), null); + return entityDtoOutMapper.fromEntityToDependencyDto(entity, graphNode); + }) + .toList(); + + return new PageImpl<>(dtoOutList, pageable, entities.getTotalElements()); + } + /// Searches for entities across all templates using a nested filter query. /// /// **API contract:** Accepts a JSON body with a nested filter tree, pagination, diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityDepDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityDepDtoOut.java new file mode 100644 index 00000000..4fb0610e --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityDepDtoOut.java @@ -0,0 +1,23 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity; + +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +@JsonNaming(SnakeCaseStrategy.class) +public class EntityDepDtoOut { + + private String templateIdentifier; + private String name; + private String identifier; + private Map properties; + private Map> relations; + +} 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 2928d7a8..54ff3ab9 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 @@ -348,4 +348,75 @@ private EntityDtoOut entityDtoOutMapper(Entity entity, Map> mergedRelations = mergeGraphNodeRelations(graphNode); + + return com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityDepDtoOut + .builder().templateIdentifier(entity.templateIdentifier()).name(entity.name()) + .identifier(entity.identifier()).properties(Collections.emptyMap()) + .relations(mergedRelations).build(); + } + + /// Merges outbound and inbound relations from an entity graph node into a + /// single relations map. + /// + /// **Business logic:** Combines outbound and inbound relations from the graph + /// node into a single relations map where each relation name maps to a list of + /// entity summaries. + /// + /// @param graphNode the entity graph node containing relations data + /// @return a map of relation names to lists of entity summaries + private Map> mergeGraphNodeRelations( + com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode graphNode) { + + Map> mergedRelations = new java.util.HashMap<>(); + + // Add outbound relations + if (graphNode.relations() != null) { + for (com.decathlon.idp_core.domain.model.entity_graph.EntityGraphRelation rel : graphNode + .relations()) { + List targets = rel.targets().stream() + .map(node -> new EntitySummaryDto(node.identifier(), node.name())) + .toList(); + mergedRelations.put(rel.name(), targets); + } + } + + // Add inbound relations (if any) + if (graphNode.relationsAsTarget() != null) { + for (com.decathlon.idp_core.domain.model.entity_graph.EntityGraphRelation rel : graphNode + .relationsAsTarget()) { + List targets = rel.targets().stream() + .map(node -> new EntitySummaryDto(node.identifier(), node.name())) + .toList(); + List existing = mergedRelations.getOrDefault(rel.name(), + new ArrayList<>()); + existing.addAll(targets); + mergedRelations.put(rel.name(), existing); + } + } + + return mergedRelations; + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java index 0144157f..894acc57 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java @@ -79,4 +79,43 @@ public Map findEntityGraph(UUID entityId, int depth, boolean inclu } + @Override + @Transactional(readOnly = true) + public Map findEntityGraphBatch(java.util.List rootIds, int depth, + boolean includeProperties, EntityGraphTraversalMode mode) { + + if (rootIds == null || rootIds.isEmpty()) { + log.debug("[EntityGraphAdapter] Empty root IDs list provided, returning empty map"); + return Map.of(); + } + + // Step 1: collect all entity IDs in the graph for all root IDs via batch + // recursive CTE + List graphIds = jpaEntityRepository.findEntityGraphIdentifiersBatch(rootIds, depth, + mode.name()); + + if (graphIds == null || graphIds.isEmpty()) { + log.debug( + "[EntityGraphAdapter] No graph identifiers found for batch roots (null or empty), returning empty map"); + return Map.of(); + } + + // Step 2: extract unique identifiers for batch loading + List entitiesIds = graphIds.stream().distinct().toList(); + + // Step 3: batch-load entities with relations, then optionally properties in a + // separate query. + // Properties are skipped when not requested to avoid the extra round-trip and + // keep payloads lean. + // The two-query split also avoids Hibernate's MultipleBagFetchException. + List jpaEntities = jpaEntityRepository.findAllByIdinWithRelations(entitiesIds); + if (includeProperties) { + jpaEntityRepository.findAllByIdInWithProperties(entitiesIds); + } + + // Step 4: map to domain and key by UUID for O(1) lookup + return jpaEntities.stream().map(mapper::toDomain) + .collect(Collectors.toMap(Entity::id, Function.identity())); + + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java index d2a07286..6551d8cd 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java @@ -157,4 +157,63 @@ void deleteByTemplateIdentifierAndIdentifier( @Param("templateIdentifier") String templateIdentifier, @Param("entityIdentifier") String entityIdentifier); + @Query(value = """ + WITH RECURSIVE entity_graph(id, depth, flow) AS ( + -- 1. ANCHOR MEMBER: Initialize state tokens for multiple root entities + SELECT e.id, 0, 'OUTBOUND' AS flow + FROM idp_core.entity e + WHERE e.id IN :rootIds AND :mode IN ('DIRECT_LINEAGE', 'OUTBOUND_ONLY') + + UNION + + SELECT e.id, 0, 'INBOUND' AS flow + FROM idp_core.entity e + WHERE e.id IN :rootIds AND :mode = 'DIRECT_LINEAGE' + + UNION + + SELECT e.id, 0, 'ANY' AS flow + FROM idp_core.entity e + WHERE e.id IN :rootIds AND :mode = 'BIDIRECTIONAL' + + UNION + + -- 2. RECURSIVE MEMBER: Propagate isolated pathways down the graph footprint + SELECT combined.id, eg.depth + 1, eg.flow + FROM entity_graph eg + JOIN ( + -- Outbound Paths + SELECT er.entity_id AS source_id, rte.target_entity_uuid AS id, 'OUTBOUND' AS flow_match + FROM idp_core.entity_relations er + JOIN idp_core.relation_target_entities rte ON rte.relation_id = er.relation_id + WHERE rte.target_entity_uuid IS NOT NULL + + UNION ALL + + SELECT er.entity_id AS source_id, rte.target_entity_uuid AS id, 'ANY' AS flow_match + FROM idp_core.entity_relations er + JOIN idp_core.relation_target_entities rte ON rte.relation_id = er.relation_id + WHERE rte.target_entity_uuid IS NOT NULL + + UNION ALL + + -- Inbound Paths + SELECT rte.target_entity_uuid AS source_id, er.entity_id AS id, 'INBOUND' AS flow_match + FROM idp_core.relation_target_entities rte + JOIN idp_core.entity_relations er ON er.relation_id = rte.relation_id + + UNION ALL + + SELECT rte.target_entity_uuid AS source_id, er.entity_id AS id, 'ANY' AS flow_match + FROM idp_core.relation_target_entities rte + JOIN idp_core.entity_relations er ON er.relation_id = rte.relation_id + ) combined ON combined.source_id = eg.id AND combined.flow_match = eg.flow + WHERE eg.depth < :depth + ) + -- 3. Return the clean deduplicated set of structural skeleton UUIDs + SELECT DISTINCT id FROM entity_graph; + """, nativeQuery = true) + List findEntityGraphIdentifiersBatch(@Param("rootIds") Collection rootIds, + @Param("depth") int depth, @Param("mode") String mode); + } From 336748fc518163f2ea5e98b491a4756f0144c3e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Thu, 18 Jun 2026 16:36:00 +0200 Subject: [PATCH 50/53] feat(core): add a depth param to get entities --- .../adrs/0005-entity-related-data.md | 134 ------- .../port/EntityGraphRepositoryPort.java | 3 +- .../entity_graph/EntityGraphService.java | 350 ++++++++++-------- .../api/controller/EntityController.java | 86 +++-- .../mapper/entity/EntityDepDtoOutMapper.java | 93 +++++ .../api/mapper/entity/EntityDtoOutMapper.java | 77 ++-- 6 files changed, 375 insertions(+), 368 deletions(-) delete mode 100644 docs/src/contributing/adrs/0005-entity-related-data.md create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDepDtoOutMapper.java diff --git a/docs/src/contributing/adrs/0005-entity-related-data.md b/docs/src/contributing/adrs/0005-entity-related-data.md deleted file mode 100644 index f563310d..00000000 --- a/docs/src/contributing/adrs/0005-entity-related-data.md +++ /dev/null @@ -1,134 +0,0 @@ -# Related Entity Data Exposition - -* Status: Proposed -* Deciders: - * maintainers team: Andrés BRAND, Matthieu WALTERSPIELER, Eve BERNHARD, Ferial OUKOUKES, Renny VANDOMBER - * contributors team: `N/A` -* Consulted: Étienne JACQUOT -* maintainers team: IDP Core Maintainers -* contributors team: N/A - - -## Context and Problem Statement - -Our Internal Developer Platform (IDP) utilizes an Entity-Attribute-Value (EAV) storage layout to maintain a highly dynamic component catalog. Frontend graph visualizations and catalog dashboards frequently need to present properties belonging to *related* entities alongside a primary entity (for example, showing a `repository_url` from a linked `GitHubRepository` node while inspecting a `Software` component). - -This ADR is for deciding how we will provide clients with programmatic access to these deeply nested, linked properties while ensuring an outstanding frontend Developer Experience (DX), maintaining real-time data consistency, and strictly protecting our 150ms backend query performance baseline. - -## Decision Drivers - -* Security -* User experience -* Complexity of implementation -* FinOps -* Maintainability -* Modularity -* Readability -* Extensibility -* Scalability - -## Considered Options - -1. Linked (Mirror) Properties - -1.a. Linked (Mirror) Stored Properties Feature (Data Duplication via Templates) - -1.b. Linked (Mirror) Calculated-at-Request Properties Feature (Dynamic Injected Mapping) - -2. Dedicated Property Projection REST Endpoint (Smart Backend, Flat Tree Harvest) - -3. Native GraphQL Server Layer (EAV Schema Compilation) - -## Decision Outcome - -Chosen option: Option 2, "Dedicated Property Projection REST Endpoint," because it comes out best. It delivers the precise payload-trimming advantages of GraphQL and the real-time data accuracy of live fetches, while completely preserving our bounded, single-pass 150ms database execution layout (O(1) database calls) and avoiding complex framework abstractions. - -Unlike Option 1.a, it introduces zero data consistency risks, and unlike Option 1.b, it completely separates graph structural retrieval from standard entity reads, ensuring our primary CRUD APIs stay lean and unburdened by heavy runtime graph lookup logic. - -### Positive Consequences - -* **Sub-Millisecond Object Traversal:** The frontend receives an optimized, pre-flattened key-value payload layout. Developers can access deeply nested attributes instantly using direct paths without writing defensive loops. -* **Bounded Resource Utilization:** Database workloads remain completely predictable. The data pipeline always executes exactly 3 database queries total per batch execution window, protecting database resources. -* **Guaranteed Data Integrity:** Zero data replication means there is no risk of sync delay or stale data states. - -### Negative Consequences - -* **Fixed Response Shape Envelope:** The JSON response layout structure is defined by backend DTO signatures, meaning individual metadata structural keys cannot be arbitrarily pruned out of serialization streams on a whim per client call. - ---- - -## Pros and Cons of the Options - -### 1.a. Linked (Mirror) Stored Properties Feature - -Systematically copy and physically store specified properties from a target related entity onto the source entity row in the EAV database based on configuration rules defined inside the system templates. - -* Good, because read performance is exceptionally fast since all required properties are pulled directly out of a single primary entity row layout (**Performance & Latency**). -* Good, because standard flat REST payloads automatically return everything inside a traditional, predictable dictionary model (**User experience**). -* Bad, because updates require cascade background worker processes to sync mutated strings across the EAV database, risking partial failures and state drift (**Data consistency**). -* Bad, because duplicate data strings are scattered across database collections, wasting disk space allocation (**FinOps**). - -### 1.b. Linked (Mirror) Calculated-at-Request Properties Feature - -Do not store duplicate values in the database. Instead, whenever a standard entity is requested, the system automatically uses the template configuration to dynamically look up the related entity's properties during the runtime processing cycle and injects them directly into the source entity's payload data model before returning it. - -* Good, because it provides the client with a single flat entity structure containing nested data without storing redundant records in the database (**Data consistency**, **User experience**). -* Good, because values are computed live from their true sources, ensuring the frontend never displays stale data (**Data consistency**). -* Bad, because it couples every standard entity read to a runtime relationship evaluation, which degrades the performance of basic database lookups even when users do not need the extra graph properties (**Performance & Latency**). -* Bad, because it risks triggering massive N+1 sequential database loops or complex custom query interceptors during normal entity fetching workflows (**Complexity of implementation**). - -### 2. Dedicated Property Projection REST Endpoint - -Introduce a specialized `POST` query endpoint that accepts a search payload. It uses the `EntityGraphService` to fetch the complete graph tree in memory, harvests the target attributes via a clean recursive pass, and flattens the response into a direct key-value dictionary before serialization. - -We could have an request contract like this: - -```json -{ - "roots": [ - { - "templateIdentifier": "microservice", - "identifier": "ordering-api" - }, - { - "templateIdentifier": "worker-pool", - "identifier": "payment-worker" - } - ], - "depth": 2, - "mode": "STRICT_LINEAGE", - "propertyProjections": [ - { - "relationName": "has_repository", - "targetTemplateIdentifier": "github-repository", - "propertyNames": ["repository_url", "default_branch"] - }, - { - "relationName": "monitored_by", - "targetTemplateIdentifier": "sonarqube-project", - "propertyNames": ["quality_gate_status"] - } - ] -} -``` - -* Good, because it transforms complex nested tree maps into a direct key-value dictionary schema before serialization, eliminating frontend parsing overhead (**User experience**). -* Good, because values are fetched directly from their original source entries in real time with zero data replication drift (**Data consistency**). -* Good, because it maps directly onto our optimized, single-pass batch recursive CTE query structure without adding extra heavy frameworks (**Complexity of implementation**, **Performance & Latency**). -* Good, because it trims away unrequested EAV properties and structural graph nodes before serialization, lowering payload sizes over the wire (**FinOps**). -* Bad, because the final layout wrapper structure is locked by backend DTO signatures rather than dynamic client string parsing text fields (**User experience**). - -### 3. Native GraphQL Server Layer - -Introduce a formal GraphQL server framework interface on top of our EAV model, enabling clients to pick exact fields and navigate cross-entity pathways using nested query strings. - -* Good, because self-documenting graph schemas with built-in autocomplete utilities offer high operational discovery flexibility (**User experience**). -* Bad, because its field-by-field execution model breaks our single bulk CTE query, requiring iterative batch queries per depth level (O(depth) execution footprint) (**Performance & Latency**). -* Bad, because data is returned matching the query shape, forcing UI developers to safely parse nested arrays of edges, targets, and properties in JavaScript (**User experience**). -* Bad, because it requires building a complex schema compiler to dynamically register dynamic GraphQL schemas out of runtime EAV template rules (**Complexity of implementation**). - ---- - -## More Information - -To support complex projection setups, this implementation will support an optional dot-notation key flattening format (e.g., `"github-repository.repository_url"`) right within the response builder layer to further simplify frontend state mapping integration. All query execution metrics will be piped to our standard catalog dashboard trackers to monitor performance stability over time. \ No newline at end of file diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java index 36d28796..26708e2a 100644 --- a/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java @@ -62,7 +62,8 @@ Map findEntityGraph(UUID entityId, int depth, boolean includePrope /// @param depth the maximum traversal depth; typically clamped to 1-6 by the /// service layer /// @param includeProperties when true, entity properties are eagerly loaded - /// @param mode the graph traversal mode determining which relation directions to + /// @param mode the graph traversal mode determining which relation directions + /// to /// follow /// @return an immutable map of all discovered entities keyed by their UUID, /// including all root entities and their reachable neighbors diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java index 37eabf8b..f7bc322d 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java @@ -8,6 +8,7 @@ import java.util.Objects; import java.util.Set; import java.util.UUID; +import java.util.stream.Collectors; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -66,12 +67,10 @@ public EntityGraphNode getEntityGraph(String templateIdentifier, String entityId int effectiveDepth = Math.clamp(depth, 1, MAX_DEPTH); entityTemplateValidationService.validateTemplateExists(templateIdentifier); - // 1. Resolve root entity Entity rootEntity = entityRepositoryPort .findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); - // 2. Load the graph footprint via optimized DB calls Map entityMap = entityGraphRepositoryPort.findEntityGraph(rootEntity.id(), effectiveDepth, includeProperties, mode); @@ -80,10 +79,8 @@ public EntityGraphNode getEntityGraph(String templateIdentifier, String entityId rootEntity.name(), List.of(), List.of(), List.of()); } - // 3. Pre-computation Layer IndexBundle indices = buildIndices(entityMap, mode); - // Context tracking for this execution tree Set activeStack = new HashSet<>(); Map memoCache = new HashMap<>(); @@ -91,25 +88,11 @@ public EntityGraphNode getEntityGraph(String templateIdentifier, String entityId indices.inboundIndex(), includeProperties, propertyFilter, relationFilter, activeStack, memoCache, mode); - // 4. Trigger recursive tree mapping (O(N) performance, heap-safe) return buildGraphNode(rootEntity.id(), ctx); } - /// Retrieves entity graphs for multiple root entities in a single batch operation. - /// - /// **Contract:** Returns a map of entity identifier strings to EntityGraphNode - /// objects for efficient bulk processing. Applies the same depth clamping, - /// property inclusion, and relation/property filtering as the single-entity - /// variant. - /// - /// @param entityGraphs map of entities keyed by their UUID (as loaded by - /// batch repository call) - /// @param depth the maximum traversal depth - /// @param includeProperties whether to include entity properties - /// @param relationFilter set of relation names to include (empty for all) - /// @param propertyFilter set of property names to include (empty for all) - /// @param mode the graph traversal mode - /// @return a map of entity identifier strings to EntityGraphNode objects + /// Retrieves entity graphs for multiple root entities in a single batch + /// operation. @Transactional(readOnly = true) public Map getBatchEntityGraphsByIdentifiers( Map entityGraphs, int depth, boolean includeProperties, @@ -119,23 +102,33 @@ public Map getBatchEntityGraphsByIdentifiers( return Map.of(); } - // Pre-computation Layer - IndexBundle indices = buildIndices(entityGraphs, mode); - - // Context tracking for this batch execution - Set activeStack = new HashSet<>(); - Map memoCache = new HashMap<>(); - - GraphTraversalContext ctx = new GraphTraversalContext(entityGraphs, indices.textToUuidLookup(), - indices.inboundIndex(), includeProperties, propertyFilter, relationFilter, activeStack, - memoCache, mode); - - // Build graph nodes for all entities in the batch + int effectiveDepth = Math.clamp(depth, 1, MAX_DEPTH); + IndexBundle globalIndices = buildIndices(entityGraphs, mode); Map result = new HashMap<>(); + for (Map.Entry entry : entityGraphs.entrySet()) { Entity entity = entry.getValue(); if (entity != null) { - EntityGraphNode node = buildGraphNode(entry.getKey(), ctx); + // 1. Isolate the footprint of entities reachable strictly by THIS root node + Set reachableFootprint = computeReachableSubGraph(entry.getKey(), entityGraphs, + globalIndices, effectiveDepth, mode); + + // 2. Filter down to a localized sub-map + Map localizedEntityMap = entityGraphs.entrySet().stream() + .filter(e -> reachableFootprint.contains(e.getKey())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + + // 3. Rebuild fresh indices completely clean of cross-contamination + IndexBundle localizedIndices = buildIndices(localizedEntityMap, mode); + + Set isolatedStack = new HashSet<>(); + Map isolatedCache = new HashMap<>(); + + GraphTraversalContext localizedCtx = new GraphTraversalContext(localizedEntityMap, + localizedIndices.textToUuidLookup(), localizedIndices.inboundIndex(), includeProperties, + propertyFilter, relationFilter, isolatedStack, isolatedCache, mode); + + EntityGraphNode node = buildGraphNode(entry.getKey(), localizedCtx); result.put(entity.identifier(), node); } } @@ -143,19 +136,8 @@ public Map getBatchEntityGraphsByIdentifiers( return result; } - /// Loads and builds entity graphs for multiple entity IDs in a single batch operation. - /// - /// **Contract:** Fetches entity graphs from the repository for the given entity IDs - /// using the specified traversal mode and depth, then converts them to EntityGraphNode - /// objects. This is the recommended entry point for batch graph retrieval. - /// - /// @param entityIds list of entity UUIDs to load graphs for - /// @param depth maximum traversal depth; clamped to 1-6 server-side - /// @param includeProperties whether to include entity properties in the graph - /// @param relationFilter set of relation names to include (empty for all) - /// @param propertyFilter set of property names to include (empty for all) - /// @param mode the graph traversal mode (BIDIRECTIONAL, OUTBOUND_ONLY, or DIRECT_LINEAGE) - /// @return a map of entity identifier strings to their EntityGraphNode representations + /// Loads and builds entity graphs for multiple entity IDs in a single batch + /// operation. @Transactional(readOnly = true) public Map loadAndBuildEntityGraphs(List entityIds, int depth, boolean includeProperties, Set relationFilter, Set propertyFilter, @@ -166,8 +148,6 @@ public Map loadAndBuildEntityGraphs(List entityId } int effectiveDepth = Math.clamp(depth, 1, MAX_DEPTH); - - // Load entity graphs from repository Map entityGraphs = entityGraphRepositoryPort.findEntityGraphBatch(entityIds, effectiveDepth, includeProperties, mode); @@ -175,23 +155,29 @@ public Map loadAndBuildEntityGraphs(List entityId return Map.of(); } - // Pre-computation Layer - IndexBundle indices = buildIndices(entityGraphs, mode); - - // Context tracking for this batch execution - Set activeStack = new HashSet<>(); - Map memoCache = new HashMap<>(); - - GraphTraversalContext ctx = new GraphTraversalContext(entityGraphs, indices.textToUuidLookup(), - indices.inboundIndex(), includeProperties, propertyFilter, relationFilter, activeStack, - memoCache, mode); - - // Build graph nodes for all entities in the batch + IndexBundle globalIndices = buildIndices(entityGraphs, mode); Map result = new HashMap<>(); + for (Map.Entry entry : entityGraphs.entrySet()) { Entity entity = entry.getValue(); if (entity != null) { - EntityGraphNode node = buildGraphNode(entry.getKey(), ctx); + Set reachableFootprint = computeReachableSubGraph(entry.getKey(), entityGraphs, + globalIndices, effectiveDepth, mode); + + Map localizedEntityMap = entityGraphs.entrySet().stream() + .filter(e -> reachableFootprint.contains(e.getKey())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + + IndexBundle localizedIndices = buildIndices(localizedEntityMap, mode); + + Set isolatedStack = new HashSet<>(); + Map isolatedCache = new HashMap<>(); + + GraphTraversalContext localizedCtx = new GraphTraversalContext(localizedEntityMap, + localizedIndices.textToUuidLookup(), localizedIndices.inboundIndex(), includeProperties, + propertyFilter, relationFilter, isolatedStack, isolatedCache, mode); + + EntityGraphNode node = buildGraphNode(entry.getKey(), localizedCtx); result.put(entity.identifier(), node); } } @@ -199,6 +185,74 @@ public Map loadAndBuildEntityGraphs(List entityId return result; } + /// Builds entity graphs for multiple entity string identifiers within a + /// template. + @Transactional(readOnly = true) + public Map getBatchEntityGraphsByIdentifiers(String templateIdentifier, + List entityIdentifiers, int depth, boolean includeProperties, + Set relationFilter, Set propertyFilter, EntityGraphTraversalMode mode) { + + if (entityIdentifiers == null || entityIdentifiers.isEmpty()) { + return Map.of(); + } + + int effectiveDepth = Math.clamp(depth, 1, MAX_DEPTH); + entityTemplateValidationService.validateTemplateExists(templateIdentifier); + + List entityUuids = new ArrayList<>(); + for (String identifier : entityIdentifiers) { + entityRepositoryPort.findByTemplateIdentifierAndIdentifier(templateIdentifier, identifier) + .ifPresent(entity -> entityUuids.add(entity.id())); + } + + if (entityUuids.isEmpty()) { + return Map.of(); + } + + Map entityGraphs = entityGraphRepositoryPort.findEntityGraphBatch(entityUuids, + effectiveDepth, includeProperties, mode); + + if (entityGraphs == null || entityGraphs.isEmpty()) { + return Map.of(); + } + + IndexBundle globalIndices = buildIndices(entityGraphs, mode); + Map result = new HashMap<>(); + + for (String identifier : entityIdentifiers) { + UUID entityUuid = null; + for (Map.Entry entry : entityGraphs.entrySet()) { + if (entry.getValue().identifier().equals(identifier)) { + entityUuid = entry.getKey(); + break; + } + } + + if (entityUuid != null) { + Set reachableFootprint = computeReachableSubGraph(entityUuid, entityGraphs, + globalIndices, effectiveDepth, mode); + + Map localizedEntityMap = entityGraphs.entrySet().stream() + .filter(e -> reachableFootprint.contains(e.getKey())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + + IndexBundle localizedIndices = buildIndices(localizedEntityMap, mode); + + Set isolatedStack = new HashSet<>(); + Map isolatedCache = new HashMap<>(); + + GraphTraversalContext localizedCtx = new GraphTraversalContext(localizedEntityMap, + localizedIndices.textToUuidLookup(), localizedIndices.inboundIndex(), includeProperties, + propertyFilter, relationFilter, isolatedStack, isolatedCache, mode); + + EntityGraphNode node = buildGraphNode(entityUuid, localizedCtx); + result.put(identifier, node); + } + } + + return result; + } + private EntityGraphNode buildGraphNode(UUID entityUuid, GraphTraversalContext ctx) { Entity entity = ctx.entityMap().get(entityUuid); @@ -210,9 +264,6 @@ private EntityGraphNode buildGraphNode(UUID entityUuid, GraphTraversalContext ct var nodeId = entity.id().toString(); - // GUARD 1: If the node is currently in active processing up our current line, - // we hit a cyclic loop closure. Break instantly with a stub to prevent infinite - // stack overflow. if (ctx.activeStack().contains(nodeId)) { List stubProperties = resolveProperties(entity, ctx.includeProperties(), ctx.propertyFilter()); @@ -220,18 +271,12 @@ private EntityGraphNode buildGraphNode(UUID entityUuid, GraphTraversalContext ct stubProperties, List.of(), List.of()); } - // GUARD 2: MEMOIZATION CHECK. If this node has already been built down an - // alternate path, - // return the pre-existing reference instantly. This prevents the exponential - // path explosion. if (ctx.memoCache().containsKey(nodeId)) { return ctx.memoCache().get(nodeId); } - // Push to active processing stack ctx.activeStack().add(nodeId); - // Process outbound relationships List outboundRelations = entity.relations().stream() .filter(relation -> ctx.relationFilter().isEmpty() || ctx.relationFilter().contains(relation.name())) @@ -246,18 +291,15 @@ private EntityGraphNode buildGraphNode(UUID entityUuid, GraphTraversalContext ct }).filter(Objects::nonNull).toList())) .filter(rel -> !rel.targets().isEmpty()).toList(); - // Process inbound relationships List inboundRelations = buildRelationsAsTargetFromIndex( entity.identifier(), ctx); List properties = resolveProperties(entity, ctx.includeProperties(), ctx.propertyFilter()); - // Assemble the complete node object EntityGraphNode completedNode = new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), properties, outboundRelations, inboundRelations); - // Save to Cache and pop from active stack before returning ctx.memoCache().put(nodeId, completedNode); ctx.activeStack().remove(nodeId); @@ -266,7 +308,6 @@ private EntityGraphNode buildGraphNode(UUID entityUuid, GraphTraversalContext ct private List buildRelationsAsTargetFromIndex(String targetIdentifier, GraphTraversalContext ctx) { - // Include inbound relations for BIDIRECTIONAL and DIRECT_LINEAGE modes if (ctx.mode() != EntityGraphTraversalMode.BIDIRECTIONAL && ctx.mode() != EntityGraphTraversalMode.DIRECT_LINEAGE) { return List.of(); @@ -304,7 +345,6 @@ private IndexBundle buildIndices(Map entityMap, EntityGraphTravers textToUuidLookup.put(new EntityCompositeKey(entity.templateIdentifier(), entity.identifier()), sourceUuid); - // Build inbound index for BIDIRECTIONAL and DIRECT_LINEAGE modes if (mode == EntityGraphTraversalMode.BIDIRECTIONAL || mode == EntityGraphTraversalMode.DIRECT_LINEAGE) { buildInboundIndexForEntity(entity, sourceUuid, inboundIndex); @@ -335,6 +375,87 @@ private List resolveProperties(Entity entity, boolean includePropertie return entity.properties().stream().filter(p -> propertyFilter.contains(p.name())).toList(); } + private static record ReachableState(UUID id, String flow) { + } + + /// Computes a precise sub-graph footprint matching the recursive database query + /// logic. + private Set computeReachableSubGraph(UUID rootId, Map entityMap, + IndexBundle globalIndices, int maxDepth, EntityGraphTraversalMode mode) { + + Set visited = new HashSet<>(); + Set currentLevel = new HashSet<>(); + + // Replicate SQL CTE Anchor Members + if (mode == EntityGraphTraversalMode.OUTBOUND_ONLY + || mode == EntityGraphTraversalMode.DIRECT_LINEAGE) { + ReachableState anchor = new ReachableState(rootId, "OUTBOUND"); + visited.add(anchor); + currentLevel.add(anchor); + } + if (mode == EntityGraphTraversalMode.DIRECT_LINEAGE) { + ReachableState anchor = new ReachableState(rootId, "INBOUND"); + if (visited.add(anchor)) { + currentLevel.add(anchor); + } + } + if (mode == EntityGraphTraversalMode.BIDIRECTIONAL) { + ReachableState anchor = new ReachableState(rootId, "ANY"); + visited.add(anchor); + currentLevel.add(anchor); + } + + // Level-by-level propagation matching depth limits + for (int d = 0; d < maxDepth; d++) { + Set nextLevel = new HashSet<>(); + for (ReachableState state : currentLevel) { + Entity entity = entityMap.get(state.id()); + if (entity == null) + continue; + + // Propigate Outbound Paths + if ("OUTBOUND".equals(state.flow()) || "ANY".equals(state.flow())) { + for (Relation rel : entity.relations()) { + for (String targetId : rel.targetEntityIdentifiers()) { + UUID targetUuid = globalIndices.textToUuidLookup() + .get(new EntityCompositeKey(rel.targetTemplateIdentifier(), targetId)); + if (targetUuid != null && entityMap.containsKey(targetUuid)) { + ReachableState nextState = new ReachableState(targetUuid, state.flow()); + if (visited.add(nextState)) { + nextLevel.add(nextState); + } + } + } + } + } + + // Propigate Inbound Paths + if ("INBOUND".equals(state.flow()) || "ANY".equals(state.flow())) { + String normalizedId = entity.identifier() == null + ? "" + : entity.identifier().trim().toLowerCase(); + Map> sourcesByRelation = globalIndices.inboundIndex() + .getOrDefault(normalizedId, Map.of()); + for (List sources : sourcesByRelation.values()) { + for (UUID sourceUuid : sources) { + if (entityMap.containsKey(sourceUuid)) { + ReachableState nextState = new ReachableState(sourceUuid, state.flow()); + if (visited.add(nextState)) { + nextLevel.add(nextState); + } + } + } + } + } + } + if (nextLevel.isEmpty()) + break; + currentLevel = nextLevel; + } + + return visited.stream().map(ReachableState::id).collect(Collectors.toSet()); + } + private static record IndexBundle(Map textToUuidLookup, Map>> inboundIndex) { } @@ -343,8 +464,7 @@ private static record GraphTraversalContext(Map entityMap, Map textToUuidLookup, Map>> inboundIndex, boolean includeProperties, Set propertyFilter, Set relationFilter, Set activeStack, - Map memoCache, // High-speed in-memory reuse cache - EntityGraphTraversalMode mode) { + Map memoCache, EntityGraphTraversalMode mode) { } private static record EntityCompositeKey(String templateIdentifier, String identifier) { @@ -355,80 +475,4 @@ private static record EntityCompositeKey(String templateIdentifier, String ident identifier = identifier == null ? "" : identifier.trim().toLowerCase(); } } - - /// Builds entity graphs for multiple entity string identifiers within a template. - /// - /// **Contract:** Retrieves entity graphs from the repository for entities identified by - /// their string identifiers, then converts them to EntityGraphNode objects. This method - /// is useful for batch operations where you have string identifiers rather than UUIDs. - /// - /// @param templateIdentifier the entity template identifier - /// @param entityIdentifiers list of entity string identifiers to load graphs for - /// @param depth maximum traversal depth; clamped to 1-6 server-side - /// @param includeProperties whether to include entity properties in the graph - /// @param relationFilter set of relation names to include (empty for all) - /// @param propertyFilter set of property names to include (empty for all) - /// @param mode the graph traversal mode (BIDIRECTIONAL, OUTBOUND_ONLY, or DIRECT_LINEAGE) - /// @return a map of entity identifier strings to their EntityGraphNode representations - @Transactional(readOnly = true) - public Map getBatchEntityGraphsByIdentifiers(String templateIdentifier, - List entityIdentifiers, int depth, boolean includeProperties, - Set relationFilter, Set propertyFilter, EntityGraphTraversalMode mode) { - - if (entityIdentifiers == null || entityIdentifiers.isEmpty()) { - return Map.of(); - } - - int effectiveDepth = Math.clamp(depth, 1, MAX_DEPTH); - entityTemplateValidationService.validateTemplateExists(templateIdentifier); - - // Fetch entity UUIDs by loading each entity individually - // (Repository doesn't support batch fetch by string identifiers) - List entityUuids = new ArrayList<>(); - for (String identifier : entityIdentifiers) { - entityRepositoryPort.findByTemplateIdentifierAndIdentifier(templateIdentifier, identifier) - .ifPresent(entity -> entityUuids.add(entity.id())); - } - - if (entityUuids.isEmpty()) { - return Map.of(); - } - - // Load entity graphs from repository - Map entityGraphs = entityGraphRepositoryPort.findEntityGraphBatch(entityUuids, - effectiveDepth, includeProperties, mode); - - if (entityGraphs == null || entityGraphs.isEmpty()) { - return Map.of(); - } - - // Pre-computation Layer - IndexBundle indices = buildIndices(entityGraphs, mode); - - // Context tracking for this batch execution - Set activeStack = new HashSet<>(); - Map memoCache = new HashMap<>(); - - GraphTraversalContext ctx = new GraphTraversalContext(entityGraphs, indices.textToUuidLookup(), - indices.inboundIndex(), includeProperties, propertyFilter, relationFilter, activeStack, - memoCache, mode); - - // Build graph nodes for all root entities in the batch - Map result = new HashMap<>(); - for (String identifier : entityIdentifiers) { - UUID entityUuid = null; - for (Map.Entry entry : entityGraphs.entrySet()) { - if (entry.getValue().identifier().equals(identifier)) { - entityUuid = entry.getKey(); - break; - } - } - if (entityUuid != null) { - EntityGraphNode node = buildGraphNode(entityUuid, ctx); - result.put(identifier, node); - } - } - - return result; - } } 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 75f4f397..533b2d4d 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 @@ -84,10 +84,11 @@ 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.dto.out.entity.EntityDepDtoOut; +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.EntityDepDtoOutMapper; 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; @@ -123,18 +124,18 @@ public class EntityController { private final EntityFilterDslParser entityFilterDslParser; private final SearchFilterMapper searchFilterMapper; private final SearchFilterParser searchFilterParser; + private final EntityDepDtoOutMapper entityDepDtoOutMapper; /// Returns paginated entities filtered by template with HTTP pagination /// support. /// /// **API contract:** Provides paginated entity listings for template-specific - /// views. - /// Supports standard REST pagination parameters and an optional `q` filter - /// query. - /// Template validation is handled by the domain service layer. + /// views. Supports standard REST pagination parameters and an optional `q` + /// filter query. Template validation is handled by the domain service layer. /// /// @param page zero-based page index for pagination navigation - /// @param size number of entities per page for response size control + /// @param size number of entities per page for response size + /// control /// @param templateIdentifier template filter for entity scope limitation /// @param q optional filter query string (e.g. /// `name:API;property.language=JAVA`) @@ -164,9 +165,8 @@ public Page getEntities(@RequestParam(defaultValue = "0") int page /// Retrieves a single entity by template and entity identifiers. /// /// **API contract:** Provides specific entity lookup using compound identifier - /// pattern. - /// Returns HTTP 404 if either template or entity doesn't exist, maintaining - /// REST semantics. + /// pattern. Returns HTTP 404 if either template or entity doesn't exist, + /// maintaining REST semantics. /// /// @param templateIdentifier business template identifier for entity scope /// @param entityIdentifier unique business identifier within template context @@ -188,10 +188,8 @@ public EntityDtoOut getEntity(@PathVariable String templateIdentifier, /// Creates a new entity for the specified template with validation. /// /// **API contract:** Accepts entity creation payload and returns created entity - /// with - /// generated identifiers. Validates entity structure against template - /// constraints - /// and returns HTTP 201 on success, HTTP 400 for validation errors. + /// with generated identifiers. Validates entity structure against template + /// constraints and returns HTTP 201 on success, HTTP 400 for validation errors. /// /// @param templateIdentifier target template identifier for entity creation /// context @@ -225,10 +223,9 @@ public EntityDtoOut createEntity(@NotBlank @PathVariable String templateIdentifi /// Updates an existing entity for the specified template. /// /// **API contract:** Accepts entity update payload and returns updated entity. - /// Validates - /// that the entity exists and that the update payload conforms to template - /// constraints. Returns HTTP 200 on success, HTTP 400 for validation errors, - /// HTTP 404 if entity doesn't exist. + /// Validates that the entity exists and that the update payload conforms to + /// template constraints. Returns HTTP 200 on success, HTTP 400 for validation + /// errors, HTTP 404 if entity doesn't exist. /// /// @param templateIdentifier target template identifier for entity update /// context @@ -262,10 +259,10 @@ public EntityDtoOut updateEntity(@NotBlank @PathVariable String templateIdentifi /// Deletes an existing entity identified by template and entity identifiers. /// /// **API contract:** Validates the template and entity exist, cleans up - /// relations in parent - /// entities that reference the deleted entity, then deletes the entity. - /// Returns HTTP 204 on successful deletion, HTTP 404 if entity doesn't exist, - /// HTTP 400 if deletion is not allowed due to existing references. + /// relations in parent entities that reference the deleted entity, then deletes + /// the entity. Returns HTTP 204 on successful deletion, HTTP 404 if entity + /// doesn't exist, HTTP 400 if deletion is not allowed due to existing + /// references. /// /// @param templateIdentifier the template identifier of the entity to delete /// @param entityIdentifier the identifier of the entity to delete @@ -288,18 +285,21 @@ public void deleteEntity(@NotBlank @PathVariable String templateIdentifier, entityService.deleteEntity(templateIdentifier, entityIdentifier); } - /// Retrieves paginated entities with their dependency graphs up to specified depth. + /// Retrieves paginated entities with their dependency graphs up to specified + /// depth. /// - /// **API contract:** Returns a paginated list of entities with their outbound and - /// inbound relations merged into a single relations object. Each entity includes all - /// reachable nodes up to the specified depth using DIRECT_LINEAGE traversal mode. - /// Results are returned as EntityDepDtoOut DTOs with relations merged from both - /// directions. + /// **API contract:** Returns a paginated list of entities with their outbound + /// and inbound relations merged into a single relations object. Each entity + /// includes all reachable nodes up to the specified depth using DIRECT_LINEAGE + /// traversal mode. Results are returned as EntityDepDtoOut DTOs with relations + /// merged from both directions. /// /// @param page zero-based page index for pagination navigation - /// @param size number of entities per page for response size control + /// @param size number of entities per page for response size + /// control /// @param templateIdentifier template filter for entity scope limitation - /// @param depth maximum relation traversal depth (default 1, clamped to 1-6) + /// @param depth maximum relation traversal depth (default 1, + /// clamped to 1-6) /// @param q optional filter query string for entity filtering /// @return paginated entity dependency DTOs with merged relations up to depth @Operation(summary = "Get entity dependencies", description = "Retrieve entities with their relationship graphs up to specified depth") @@ -318,28 +318,28 @@ public Page getEntitiesDependencies(@RequestParam(defaultValue @RequestParam(defaultValue = "1") int depth, @RequestParam(required = false) String q) { Pageable pageable = PageRequest.of(page, size); EntityFilter filter = entityFilterDslParser.parse(q); - Page entities = entityService.getEntitiesByTemplateIdentifier(pageable, templateIdentifier, - filter); + Page entities = entityService.getEntitiesByTemplateIdentifier(pageable, + templateIdentifier, filter); // Extract entity identifiers for batch graph loading - List entityIdentifiers = entities.getContent().stream().map(Entity::identifier).toList(); + List entityIdentifiers = entities.getContent().stream().map(Entity::identifier) + .toList(); if (entityIdentifiers.isEmpty()) { return new PageImpl<>(List.of(), pageable, 0); } - // Load entity graphs with DIRECT_LINEAGE mode (includes outbound + inbound relations) + // Load entity graphs with DIRECT_LINEAGE mode (includes outbound + inbound + // relations) Map entityGraphs = entityGraphService .getBatchEntityGraphsByIdentifiers(templateIdentifier, entityIdentifiers, depth, false, java.util.Set.of(), java.util.Set.of(), EntityGraphTraversalMode.DIRECT_LINEAGE); // Map to EntityDepDtoOut with merged relations - List dtoOutList = entities.getContent().stream() - .map(entity -> { - EntityGraphNode graphNode = entityGraphs.getOrDefault(entity.identifier(), null); - return entityDtoOutMapper.fromEntityToDependencyDto(entity, graphNode); - }) - .toList(); + List dtoOutList = entities.getContent().stream().map(entity -> { + EntityGraphNode graphNode = entityGraphs.getOrDefault(entity.identifier(), null); + return entityDepDtoOutMapper.toDto(graphNode); + }).toList(); return new PageImpl<>(dtoOutList, pageable, entities.getTotalElements()); } @@ -347,11 +347,9 @@ public Page getEntitiesDependencies(@RequestParam(defaultValue /// 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. + /// 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 diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDepDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDepDtoOutMapper.java new file mode 100644 index 00000000..dbc363e2 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDepDtoOutMapper.java @@ -0,0 +1,93 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Component; + +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphRelation; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityDepDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntitySummaryDto; + +@Component +public final class EntityDepDtoOutMapper { + + private EntityDepDtoOutMapper() { + } + + private record TraversalState(Map> relationsMap, + Set visitedNodeIds, Set emittedEdgeSignatures) { + } + + public static EntityDepDtoOut toDto(EntityGraphNode root) { + if (root == null) { + return null; + } + + EntityDepDtoOut dto = EntityDepDtoOut.builder().build(); + dto.setIdentifier(root.identifier()); + dto.setName(root.name()); + dto.setTemplateIdentifier(root.templateIdentifier()); + + dto.setProperties(root.properties().stream() + .collect(Collectors.toMap(p -> p.name(), p -> p.value(), (v1, v2) -> v1))); + + var state = new TraversalState(new HashMap<>(), new HashSet<>(), new HashSet<>()); + + traverse(root, state); + + // Convert the Map> to Map> + Map> finalizedRelations = state.relationsMap().entrySet() + .stream().collect(Collectors.toMap(Map.Entry::getKey, e -> new ArrayList<>(e.getValue()))); + + dto.setRelations(finalizedRelations); + return dto; + } + + private static void traverse(EntityGraphNode node, TraversalState state) { + var nodeId = nodeId(node.templateIdentifier(), node.identifier()); + + if (!state.visitedNodeIds().add(nodeId)) { + return; + } + + // 1. Outbound Relations: Current Node → Target + for (EntityGraphRelation relation : node.relations()) { + for (EntityGraphNode target : relation.targets()) { + var targetId = nodeId(target.templateIdentifier(), target.identifier()); + var signature = nodeId + "|" + targetId + "|" + relation.name(); + + if (state.emittedEdgeSignatures().add(signature)) { + state.relationsMap().computeIfAbsent(relation.name(), k -> new ArrayList<>()) + .add(new EntitySummaryDto(target.identifier(), target.name())); + } + traverse(target, state); + } + } + + // 2. Inbound Relations: Source → Current Node + for (EntityGraphRelation relation : node.relationsAsTarget()) { + for (EntityGraphNode source : relation.targets()) { + var sourceId = nodeId(source.templateIdentifier(), source.identifier()); + var signature = sourceId + "|" + nodeId + "|" + relation.name(); + + if (state.emittedEdgeSignatures().add(signature)) { + state.relationsMap().computeIfAbsent(relation.name(), k -> new ArrayList<>()) + .add(new EntitySummaryDto(source.identifier(), source.name())); + } + traverse(source, state); + } + } + } + + private static String nodeId(String templateIdentifier, String identifier) { + return templateIdentifier + ":" + identifier; + } +} \ No newline at end of file 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 54ff3ab9..533d9431 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 @@ -17,12 +17,14 @@ import com.decathlon.idp_core.domain.model.entity.Property; import com.decathlon.idp_core.domain.model.entity.Relation; import com.decathlon.idp_core.domain.model.entity.RelationAsTargetSummary; +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; 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.service.entity.EntityService; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; import com.decathlon.idp_core.domain.service.relation.RelationService; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityDepDtoOut; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityDtoOut; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntitySummaryDto; @@ -348,72 +350,75 @@ private EntityDtoOut entityDtoOutMapper(Entity entity, Map> mergedRelations = mergeGraphNodeRelations(graphNode); - return com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityDepDtoOut - .builder().templateIdentifier(entity.templateIdentifier()).name(entity.name()) - .identifier(entity.identifier()).properties(Collections.emptyMap()) - .relations(mergedRelations).build(); + return EntityDepDtoOut.builder().templateIdentifier(graphNode.templateIdentifier()) + .name(graphNode.name()).identifier(graphNode.identifier()) + .properties(Collections.emptyMap()).relations(mergedRelations).build(); } - /// Merges outbound and inbound relations from an entity graph node into a - /// single relations map. + /// Extracts ONLY the direct relations from the root entity node. /// - /// **Business logic:** Combines outbound and inbound relations from the graph - /// node into a single relations map where each relation name maps to a list of - /// entity summaries. + /// **Business logic:** Collects only the immediate outbound and inbound + /// relations + /// from the root node, without recursing into nested nodes' relations. This + /// provides + /// a clean view of the root entity's direct dependencies without pollution from + /// unrelated entities' relations. /// - /// @param graphNode the entity graph node containing relations data - /// @return a map of relation names to lists of entity summaries + /// @param graphNode the entity graph node containing relation data + /// @return a map of relation names to lists of entity summaries (direct + /// relations only) private Map> mergeGraphNodeRelations( com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode graphNode) { Map> mergedRelations = new java.util.HashMap<>(); - // Add outbound relations + // Process ONLY the root node's direct outbound relations if (graphNode.relations() != null) { for (com.decathlon.idp_core.domain.model.entity_graph.EntityGraphRelation rel : graphNode .relations()) { + String relationName = rel.name(); List targets = rel.targets().stream() - .map(node -> new EntitySummaryDto(node.identifier(), node.name())) - .toList(); - mergedRelations.put(rel.name(), targets); + .map(node -> new EntitySummaryDto(node.identifier(), node.name())).toList(); + mergedRelations.put(relationName, targets); } } - // Add inbound relations (if any) + // Process ONLY the root node's direct inbound relations (relationsAsTarget) if (graphNode.relationsAsTarget() != null) { for (com.decathlon.idp_core.domain.model.entity_graph.EntityGraphRelation rel : graphNode .relationsAsTarget()) { - List targets = rel.targets().stream() - .map(node -> new EntitySummaryDto(node.identifier(), node.name())) - .toList(); - List existing = mergedRelations.getOrDefault(rel.name(), - new ArrayList<>()); - existing.addAll(targets); - mergedRelations.put(rel.name(), existing); + String relationName = rel.name(); + + // Merge with existing if relation name already exists (from outbound) + List existingTargets = mergedRelations.get(relationName); + List newTargets = rel.targets().stream() + .map(node -> new EntitySummaryDto(node.identifier(), node.name())).toList(); + + if (existingTargets != null) { + List merged = new java.util.ArrayList<>(existingTargets); + merged.addAll(newTargets); + mergedRelations.put(relationName, merged); + } else { + mergedRelations.put(relationName, newTargets); + } } } From a26ddc70d756e4843ac876d2a8be47fe7430f1f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Thu, 18 Jun 2026 16:57:44 +0200 Subject: [PATCH 51/53] feat(core): add a depth param to get entities --- .../adapters/api/mapper/entity/EntityDepDtoOutMapper.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDepDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDepDtoOutMapper.java index dbc363e2..4bc65c4a 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDepDtoOutMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDepDtoOutMapper.java @@ -90,4 +90,4 @@ private static void traverse(EntityGraphNode node, TraversalState state) { private static String nodeId(String templateIdentifier, String identifier) { return templateIdentifier + ":" + identifier; } -} \ No newline at end of file +} From 996d69a330806b0265896adeef9543a4fd0c9ac3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Thu, 18 Jun 2026 18:03:17 +0200 Subject: [PATCH 52/53] feat(core): add a depth param to get entities --- .../mapper/entity/EntityDepDtoOutMapper.java | 51 +++++++++++++++---- 1 file changed, 40 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDepDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDepDtoOutMapper.java index 4bc65c4a..7fc96e93 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDepDtoOutMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDepDtoOutMapper.java @@ -3,6 +3,7 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -19,10 +20,13 @@ public final class EntityDepDtoOutMapper { private EntityDepDtoOutMapper() { + // Utility class — not instantiable } - private record TraversalState(Map> relationsMap, - Set visitedNodeIds, Set emittedEdgeSignatures) { + private record TraversalState(String rootIdentifier, // Stored to easily identify the starting + // anchor + Map> relationsMap, Set visitedNodeIds, + Set emittedEdgeSignatures) { } public static EntityDepDtoOut toDto(EntityGraphNode root) { @@ -30,7 +34,8 @@ public static EntityDepDtoOut toDto(EntityGraphNode root) { return null; } - EntityDepDtoOut dto = EntityDepDtoOut.builder().build(); + EntityDepDtoOut dto = EntityDepDtoOut.builder().identifier(root.identifier()).name(root.name()) + .templateIdentifier(root.templateIdentifier()).build(); dto.setIdentifier(root.identifier()); dto.setName(root.name()); dto.setTemplateIdentifier(root.templateIdentifier()); @@ -38,11 +43,12 @@ public static EntityDepDtoOut toDto(EntityGraphNode root) { dto.setProperties(root.properties().stream() .collect(Collectors.toMap(p -> p.name(), p -> p.value(), (v1, v2) -> v1))); - var state = new TraversalState(new HashMap<>(), new HashSet<>(), new HashSet<>()); + var state = new TraversalState(root.identifier(), // Capture the root anchor ID + new HashMap<>(), new HashSet<>(), new HashSet<>()); traverse(root, state); - // Convert the Map> to Map> to Map> Map> finalizedRelations = state.relationsMap().entrySet() .stream().collect(Collectors.toMap(Map.Entry::getKey, e -> new ArrayList<>(e.getValue()))); @@ -58,35 +64,58 @@ private static void traverse(EntityGraphNode node, TraversalState state) { return; } - // 1. Outbound Relations: Current Node → Target + // 1. Outbound Relations: Current Node is ALWAYS Source, Target is ALWAYS Target for (EntityGraphRelation relation : node.relations()) { for (EntityGraphNode target : relation.targets()) { var targetId = nodeId(target.templateIdentifier(), target.identifier()); var signature = nodeId + "|" + targetId + "|" + relation.name(); if (state.emittedEdgeSignatures().add(signature)) { - state.relationsMap().computeIfAbsent(relation.name(), k -> new ArrayList<>()) - .add(new EntitySummaryDto(target.identifier(), target.name())); + // Identify the dependency node based on relation geometry + EntityGraphNode dependencyNode = identifyDependency(state.rootIdentifier(), node, target); + + state.relationsMap().computeIfAbsent(relation.name(), k -> new LinkedHashSet<>()) + .add(new EntitySummaryDto(dependencyNode.identifier(), dependencyNode.name())); } traverse(target, state); } } - // 2. Inbound Relations: Source → Current Node + // 2. Inbound Relations: Source is ALWAYS Source, Current Node is ALWAYS Target for (EntityGraphRelation relation : node.relationsAsTarget()) { for (EntityGraphNode source : relation.targets()) { var sourceId = nodeId(source.templateIdentifier(), source.identifier()); var signature = sourceId + "|" + nodeId + "|" + relation.name(); if (state.emittedEdgeSignatures().add(signature)) { - state.relationsMap().computeIfAbsent(relation.name(), k -> new ArrayList<>()) - .add(new EntitySummaryDto(source.identifier(), source.name())); + // Identify the dependency node based on relation geometry + EntityGraphNode dependencyNode = identifyDependency(state.rootIdentifier(), source, node); + + state.relationsMap().computeIfAbsent(relation.name(), k -> new LinkedHashSet<>()) + .add(new EntitySummaryDto(dependencyNode.identifier(), dependencyNode.name())); } traverse(source, state); } } } + private static EntityGraphNode identifyDependency(String rootIdentifier, EntityGraphNode source, + EntityGraphNode target) { + // If the root node is the source, we want to look at what it points to (the + // target) + if (source.identifier().equals(rootIdentifier)) { + return target; + } + // If the root node is the target, we want to look at what points to it (the + // source) + if (target.identifier().equals(rootIdentifier)) { + return source; + } + // Deep structural default: if we are out in the graph branches, map the target + // entity + return target; + } + private static String nodeId(String templateIdentifier, String identifier) { return templateIdentifier + ":" + identifier; } From 59d40c9121e9000c92461d6c0dd8f225a438ff25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Fri, 19 Jun 2026 11:38:21 +0200 Subject: [PATCH 53/53] feat(core): add a depth param to get entities --- .../api/controller/EntityController.java | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) 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 533b2d4d..2f474e97 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 @@ -299,7 +299,25 @@ public void deleteEntity(@NotBlank @PathVariable String templateIdentifier, /// control /// @param templateIdentifier template filter for entity scope limitation /// @param depth maximum relation traversal depth (default 1, - /// clamped to 1-6) + /// Retrieves paginated entities with their dependency graphs up to specified + /// depth. + /// + /// **API contract:** Returns a paginated list of entities with their outbound + /// and + /// inbound relations merged into a single relations object. Each entity + /// includes all + /// reachable nodes up to the specified depth using DIRECT_LINEAGE traversal + /// mode. + /// Results are returned as EntityDepDtoOut DTOs with relations merged from both + /// directions. Supports filtering relations by name using a comma-separated + /// list. + /// + /// @param page zero-based page index for pagination navigation + /// @param size number of entities per page for response size control + /// @param templateIdentifier template filter for entity scope limitation + /// @param depth maximum relation traversal depth (default 1, clamped to 1-6) + /// @param relationsFilter comma-separated list of relation names to include + /// (optional, empty means all relations) /// @param q optional filter query string for entity filtering /// @return paginated entity dependency DTOs with merged relations up to depth @Operation(summary = "Get entity dependencies", description = "Retrieve entities with their relationship graphs up to specified depth") @@ -310,12 +328,15 @@ public void deleteEntity(@NotBlank @PathVariable String templateIdentifier, @Parameter(name = "size", description = PARAM_SIZE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "20"))) @Parameter(name = "sort", description = PARAM_SORT_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string", defaultValue = "identifier,asc"))) @Parameter(name = "depth", description = "Maximum relation traversal depth (1-6, default 1)", in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "1"))) + @Parameter(name = "relations_filter", description = "Comma-separated list of relation names to include (optional, empty means all relations)", in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string", example = "component-supported_by-support_group,component-owned_by-product"))) @Parameter(name = "q", description = PARAM_QUERY_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string"))) @ResponseStatus(OK) @GetMapping("/{templateIdentifier}/dependencies") public Page getEntitiesDependencies(@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, @PathVariable String templateIdentifier, - @RequestParam(defaultValue = "1") int depth, @RequestParam(required = false) String q) { + @RequestParam(defaultValue = "1") int depth, + @RequestParam(required = false, defaultValue = "") String relationsFilter, + @RequestParam(required = false) String q) { Pageable pageable = PageRequest.of(page, size); EntityFilter filter = entityFilterDslParser.parse(q); Page entities = entityService.getEntitiesByTemplateIdentifier(pageable, @@ -329,11 +350,15 @@ public Page getEntitiesDependencies(@RequestParam(defaultValue return new PageImpl<>(List.of(), pageable, 0); } + // Parse relations filter from comma-separated list + java.util.Set relationFilterSet = java.util.Arrays.stream(relationsFilter.split(",")) + .map(String::trim).filter(s -> !s.isBlank()).collect(java.util.stream.Collectors.toSet()); + // Load entity graphs with DIRECT_LINEAGE mode (includes outbound + inbound // relations) Map entityGraphs = entityGraphService .getBatchEntityGraphsByIdentifiers(templateIdentifier, entityIdentifiers, depth, false, - java.util.Set.of(), java.util.Set.of(), EntityGraphTraversalMode.DIRECT_LINEAGE); + relationFilterSet, java.util.Set.of(), EntityGraphTraversalMode.DIRECT_LINEAGE); // Map to EntityDepDtoOut with merged relations List dtoOutList = entities.getContent().stream().map(entity -> {