diff --git a/.github/instructions/domain.instructions.md b/.github/instructions/domain.instructions.md index 78af57fc..8b5a8727 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,22 @@ 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│ ├── 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/.pre-commit-config.yaml b/.pre-commit-config.yaml index 90382cff..23a9387c 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: diff --git a/docs/src/static/swagger.yaml b/docs/src/static/swagger.yaml index c856af84..e5f70058 100644 --- a/docs/src/static/swagger.yaml +++ b/docs/src/static/swagger.yaml @@ -9,12 +9,14 @@ 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 paths: - /api/v1/entity-templates/{identifier}: + '/api/v1/entity-templates/{identifier}': get: tags: - Entities Templates Management @@ -44,7 +46,8 @@ paths: tags: - 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: @@ -54,11 +57,11 @@ paths: schema: type: string requestBody: - required: true content: application/json: schema: $ref: '#/components/schemas/EntityTemplateUpdateDtoIn' + required: true responses: '200': description: Template update successfully @@ -66,24 +69,110 @@ paths: '*/*': schema: $ref: '#/components/schemas/EntityTemplateDtoOut' - '400': - description: Invalid template data provided + '404': + description: Template not found with the provided identifier content: '*/*': schema: $ref: '#/components/schemas/ErrorResponse' - '401': - description: Unauthorized - Missing or invalid token - '403': - description: Insufficient rights + 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' - '409': - description: Template with this identifier already exists + '/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/EntityDtoOut' + '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: @@ -96,21 +185,53 @@ paths: $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 + - 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' + '409': + description: Target entity has required relations + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Unexpected server-side failure content: '*/*': schema: @@ -141,13 +262,14 @@ paths: default: '20' - name: sort in: query - description: 'Sorting criteria in the format: property(,asc|desc). Defaults to - identifier,asc.' + description: >- + Sorting criteria in the format: property(,asc|desc). Defaults to + identifier,asc. content: '*/*': schema: type: string - default: identifier,asc + default: 'identifier,asc' responses: '200': description: Paginated templates retrieved successfully @@ -168,11 +290,11 @@ paths: 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' + required: true responses: '201': description: Template created successfully @@ -186,7 +308,7 @@ paths: '*/*': schema: $ref: '#/components/schemas/ErrorResponse' - /api/v1/entities/{templateIdentifier}: + '/api/v1/entities/{templateIdentifier}': get: tags: - Entities Management @@ -196,8 +318,8 @@ paths: parameters: - name: page in: query - required: false description: Page number for pagination. Defaults to 0. + required: false content: '*/*': schema: @@ -205,8 +327,8 @@ paths: default: '0' - name: size in: query - required: false description: Number of items per page. Defaults to 20. + required: false content: '*/*': schema: @@ -217,15 +339,27 @@ paths: 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.' + description: >- + Sorting criteria in the format: property(,asc|desc). Defaults to + identifier,asc. content: '*/*': schema: type: string - default: identifier,asc + default: 'identifier,asc' responses: '200': description: Paginated entities retrieved successfully @@ -234,7 +368,7 @@ paths: schema: $ref: '#/components/schemas/EntityPageResponse' '400': - description: Invalid pagination parameters + description: Invalid filter query syntax content: '*/*': schema: @@ -246,217 +380,168 @@ paths: description: Create a new entity in the system with the provided information operationId: createEntity parameters: - - in: path - name: templateIdentifier - required: true - schema: - minLength: 1 - type: string + - 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" - description: Entity created successfully + $ref: '#/components/schemas/EntityDtoOut' '400': + description: Invalid entity data provided content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/ErrorResponse" - description: Invalid entity data provided + $ref: '#/components/schemas/ErrorResponse' '401': description: Unauthorized - Missing or invalid token '403': description: Insufficient rights '404': - content: - "*/*": - schema: - "$ref": "#/components/schemas/ErrorResponse" description: Template not found with the provided identifier - '409': content: - "*/*": + '*/*': schema: - "$ref": "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' + '409': description: Entity already exists in this template - '500': - 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 + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Unexpected server-side failure 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 + 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 + description: Invalid search filter 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 - content: - '*/*': - schema: - $ref: '#/components/schemas/ErrorResponse' - delete: + '/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 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 + DIRECT_LINEAGE. + required: false + schema: type: string - responses: - '204': - description: Entity deleted successfully - '400': - description: Invalid identifiers provided - content: - '*/*': - schema: - $ref: '#/components/schemas/ErrorResponse' - '401': - description: Unauthorized - Missing or invalid token - '403': - description: Insufficient rights - '404': - description: Entity or template not found with the provided identifier - content: - '*/*': - schema: - $ref: '#/components/schemas/ErrorResponse' - '409': - description: Target entity has required relations + 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/ErrorResponse' - '500': - description: Unexpected server-side failure + $ref: '#/components/schemas/EntityGraphFlatDtoOut' + '404': + description: Entity not found with the provided identifier content: '*/*': schema: @@ -472,8 +557,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 @@ -514,9 +599,9 @@ components: example: STRING required: type: boolean + default: false description: Whether this property is required example: true - default: false rules: $ref: '#/components/schemas/PropertyRulesDtoIn' description: Property validation rules @@ -546,7 +631,7 @@ components: 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,14 +668,14 @@ 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 @@ -670,7 +755,7 @@ components: 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 +803,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 @@ -856,41 +878,151 @@ 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' totalElements: @@ -901,29 +1033,56 @@ 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 + empty: + type: boolean + PageableObject: + type: object + properties: sort: $ref: '#/components/schemas/SortObject' - first: + paged: type: boolean - numberOfElements: + pageNumber: + type: integer + format: int32 + pageSize: type: integer format: int32 + unpaged: + type: boolean + 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' totalElements: @@ -934,21 +1093,70 @@ components: format: int32 last: type: boolean - size: - type: integer - format: int32 - number: - type: integer - format: int32 sort: $ref: '#/components/schemas/SortObject' + numberOfElements: + type: integer + format: int32 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 @@ -956,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 diff --git a/pom.xml b/pom.xml index 691826e5..68cb35a5 100644 --- a/pom.xml +++ b/pom.xml @@ -260,7 +260,6 @@ org.flywaydb flyway-maven-plugin - 9.12.0 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 367d89ee..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 @@ -2,6 +2,8 @@ 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 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 198132d6..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 @@ -2,6 +2,8 @@ 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 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 c0759879..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 @@ -9,6 +9,8 @@ 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: 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 8f1f0987..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 @@ -6,6 +6,10 @@ 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; + /// 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/domain/model/entity/Relation.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/Relation.java index e92bd626..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 @@ -10,6 +10,9 @@ 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 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..8b3266b3 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java @@ -0,0 +1,29 @@ +package com.decathlon.idp_core.domain.model.entity_graph; + +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). +/// +/// **Business purpose:** +/// - Visualizing entity dependency graphs +/// - Understanding relationship chains between entities +/// - Providing a hierarchical view of entity connections +/// +/// @param templateIdentifier the template identifier this entity belongs to +/// @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/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..4f3e978f --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphRelation.java @@ -0,0 +1,17 @@ +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 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/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..e9972175 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphTraversalMode.java @@ -0,0 +1,10 @@ +package com.decathlon.idp_core.domain.model.entity_graph; + +/// Defines the traversal mode for entity graph queries. +/// +/// - **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 { + DIRECT_LINEAGE, BIDIRECTIONAL, OUTBOUND_ONLY; +} 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..26708e2a --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java @@ -0,0 +1,73 @@ +package com.decathlon.idp_core.domain.port; + +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_graph.EntityGraphTraversalMode; + +/// 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 entity + /// UUID. + /// + /// 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. + /// + /// **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); + + /// 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/EntityService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java index 5647fc66..735bdf63 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 @@ -73,8 +73,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 @@ -139,16 +139,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 @@ -156,7 +160,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) { @@ -167,8 +172,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); } /// Deletes an existing entity identified by template and entity identifiers. 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..f7bc322d --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java @@ -0,0 +1,478 @@ +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.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; + +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.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.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; + +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 +/// - 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 = 6; + + private final EntityRepositoryPort entityRepositoryPort; + private final EntityGraphRepositoryPort entityGraphRepositoryPort; + private final EntityTemplateValidationService entityTemplateValidationService; + + @Transactional(readOnly = true) + public EntityGraphNode getEntityGraph(String templateIdentifier, String entityIdentifier, + int depth, boolean includeProperties, Set relationFilter, Set propertyFilter, + EntityGraphTraversalMode mode) { + + int effectiveDepth = Math.clamp(depth, 1, MAX_DEPTH); + entityTemplateValidationService.validateTemplateExists(templateIdentifier); + + Entity rootEntity = entityRepositoryPort + .findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) + .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); + + Map entityMap = entityGraphRepositoryPort.findEntityGraph(rootEntity.id(), + effectiveDepth, includeProperties, mode); + + if (entityMap == null || entityMap.isEmpty()) { + return new EntityGraphNode(rootEntity.templateIdentifier(), rootEntity.identifier(), + rootEntity.name(), List.of(), List.of(), List.of()); + } + + IndexBundle indices = buildIndices(entityMap, mode); + + Set activeStack = new HashSet<>(); + Map memoCache = new HashMap<>(); + + GraphTraversalContext ctx = new GraphTraversalContext(entityMap, indices.textToUuidLookup(), + indices.inboundIndex(), includeProperties, propertyFilter, relationFilter, activeStack, + memoCache, mode); + + return buildGraphNode(rootEntity.id(), ctx); + } + + /// Retrieves entity graphs for multiple root entities in a single batch + /// operation. + @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(); + } + + 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) { + // 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); + } + } + + return result; + } + + /// 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, + EntityGraphTraversalMode mode) { + + if (entityIds == null || entityIds.isEmpty()) { + return Map.of(); + } + + int effectiveDepth = Math.clamp(depth, 1, MAX_DEPTH); + Map entityGraphs = entityGraphRepositoryPort.findEntityGraphBatch(entityIds, + effectiveDepth, includeProperties, mode); + + if (entityGraphs == null || entityGraphs.isEmpty()) { + return Map.of(); + } + + IndexBundle globalIndices = buildIndices(entityGraphs, mode); + Map result = new HashMap<>(); + + for (Map.Entry entry : entityGraphs.entrySet()) { + Entity entity = entry.getValue(); + if (entity != null) { + 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); + } + } + + 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); + + var nodeIdDisplay = entityUuid != null ? entityUuid.toString() : "null-entity-"; + if (entity == null) { + return new EntityGraphNode(nodeIdDisplay, nodeIdDisplay, nodeIdDisplay, List.of(), List.of(), + List.of()); + } + + var nodeId = entity.id().toString(); + + 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()); + } + + if (ctx.memoCache().containsKey(nodeId)) { + return ctx.memoCache().get(nodeId); + } + + ctx.activeStack().add(nodeId); + + List outboundRelations = entity.relations().stream() + .filter(relation -> ctx.relationFilter().isEmpty() + || ctx.relationFilter().contains(relation.name())) + .map(relation -> new EntityGraphRelation(relation.name(), + relation.targetEntityIdentifiers().stream().map(targetId -> { + UUID targetUuid = ctx.textToUuidLookup() + .get(new EntityCompositeKey(relation.targetTemplateIdentifier(), targetId)); + if (targetUuid == null) + return null; + + return buildGraphNode(targetUuid, ctx); + }).filter(Objects::nonNull).toList())) + .filter(rel -> !rel.targets().isEmpty()).toList(); + + List inboundRelations = buildRelationsAsTargetFromIndex( + entity.identifier(), ctx); + + List properties = resolveProperties(entity, ctx.includeProperties(), + ctx.propertyFilter()); + + EntityGraphNode completedNode = new EntityGraphNode(entity.templateIdentifier(), + entity.identifier(), entity.name(), properties, outboundRelations, inboundRelations); + + ctx.memoCache().put(nodeId, completedNode); + ctx.activeStack().remove(nodeId); + + return completedNode; + } + + private List buildRelationsAsTargetFromIndex(String targetIdentifier, + GraphTraversalContext ctx) { + if (ctx.mode() != EntityGraphTraversalMode.BIDIRECTIONAL + && ctx.mode() != EntityGraphTraversalMode.DIRECT_LINEAGE) { + return List.of(); + } + + String normalizedTargetIdentifier = targetIdentifier == null + ? "" + : targetIdentifier.trim().toLowerCase(); + Map> sourcesByRelationName = ctx.inboundIndex() + .getOrDefault(normalizedTargetIdentifier, Map.of()); + + if (sourcesByRelationName.isEmpty()) { + return List.of(); + } + + return sourcesByRelationName.entrySet().stream() + .filter(e -> ctx.relationFilter().isEmpty() || ctx.relationFilter().contains(e.getKey())) + .map(e -> { + List targets = e.getValue().stream() + .map(sourceUuid -> buildGraphNode(sourceUuid, ctx)).toList(); + return new EntityGraphRelation(e.getKey(), targets); + }).toList(); + } + + private IndexBundle buildIndices(Map entityMap, EntityGraphTraversalMode mode) { + Map textToUuidLookup = new HashMap<>(); + Map>> inboundIndex = new HashMap<>(); + + for (Map.Entry entry : entityMap.entrySet()) { + UUID sourceUuid = entry.getKey(); + Entity entity = entry.getValue(); + if (entity == null) + continue; + + textToUuidLookup.put(new EntityCompositeKey(entity.templateIdentifier(), entity.identifier()), + sourceUuid); + + if (mode == EntityGraphTraversalMode.BIDIRECTIONAL + || mode == EntityGraphTraversalMode.DIRECT_LINEAGE) { + 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) + return List.of(); + if (propertyFilter.isEmpty()) + return entity.properties(); + 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) { + } + + private static record GraphTraversalContext(Map entityMap, + Map textToUuidLookup, + Map>> inboundIndex, boolean includeProperties, + Set propertyFilter, Set relationFilter, Set activeStack, + Map memoCache, EntityGraphTraversalMode mode) { + } + + 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 117d88a8..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 @@ -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,8 +16,9 @@ /// - 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 { @@ -171,6 +173,26 @@ 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 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"; + 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."; + 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/EntityController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java index 0eb19c6a..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 @@ -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,20 +70,25 @@ 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; 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.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; @@ -112,23 +118,24 @@ public class EntityController { private final EntityService entityService; + private final EntityGraphService entityGraphService; private final EntityDtoOutMapper entityDtoOutMapper; private final EntityDtoInMapper entityDtoInMapper; 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`) @@ -158,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 @@ -182,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 @@ -219,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 @@ -256,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 @@ -282,14 +285,96 @@ 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, + /// 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") + @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 = "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, defaultValue = "") String relationsFilter, + @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); + } + + // 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, + relationFilterSet, 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 entityDepDtoOutMapper.toDto(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, - /// 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/controller/EntityGraphController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java new file mode 100644 index 00000000..9dfad433 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java @@ -0,0 +1,98 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.controller; + +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.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.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; + +import java.util.List; +import java.util.Set; + +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; +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.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_graph.EntityGraphFlatDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse; +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; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +/// REST controller for entity relationship graph operations. +/// +/// 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 +@Validated +@Tag(name = "Entity Graph", description = "Entity relationship graph operations") +public class EntityGraphController { + + 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_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) { + + // 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, relationFilter, propertyFilter, mode); + + return EntityGraphFlatDtoOutMapper.toFlatDto(graphNode); + } + +} 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/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..b5a12aea --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/RelationAsTargetSummaryDtoOut.java @@ -0,0 +1,10 @@ +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/dto/out/entity_graph/EntityGraphEdgeDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_graph/EntityGraphEdgeDtoOut.java new file mode 100644 index 00000000..37805a50 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_graph/EntityGraphEdgeDtoOut.java @@ -0,0 +1,27 @@ +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; +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_graph/EntityGraphFlatDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_graph/EntityGraphFlatDtoOut.java new file mode 100644 index 00000000..be2d23d9 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_graph/EntityGraphFlatDtoOut.java @@ -0,0 +1,28 @@ +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; + +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 +@JsonNaming(SnakeCaseStrategy.class) +public record EntityGraphFlatDtoOut( + + @Schema(description = ENTITY_GRAPH_FLAT_NODES_DESCRIPTION) List nodes, + + @Schema(description = ENTITY_GRAPH_FLAT_EDGES_DESCRIPTION) List edges) { + + 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_graph/EntityGraphNodeFlatDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_graph/EntityGraphNodeFlatDtoOut.java new file mode 100644 index 00000000..74db05fa --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity_graph/EntityGraphNodeFlatDtoOut.java @@ -0,0 +1,40 @@ +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; +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; + +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. +/// +/// 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). + public EntityGraphNodeFlatDtoOut { + data = data == null ? Map.of() : Map.copyOf(data); + } +} 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 e02c4858..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 @@ -43,11 +43,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 @@ -55,8 +57,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 { @@ -67,8 +70,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) { @@ -77,11 +79,6 @@ public ResponseEntity handleTemplateNotFoundException( return ResponseEntity.status(NOT_FOUND).body(errorResponse); } - /// Handles domain exception for malformed filter query strings (`q=` DSL). - /// - /// **HTTP mapping:** Maps domain [InvalidFilterDslException] to HTTP 400 Bad - /// Request - /// so API consumers receive clear feedback about invalid `q` parameter syntax. @ExceptionHandler(InvalidFilterDslException.class) public ResponseEntity handleInvalidFilterDslException( InvalidFilterDslException ex) { @@ -105,8 +102,7 @@ public ResponseEntity handleInvalidSearchQueryException( /// 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) { @@ -118,8 +114,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) { @@ -132,8 +128,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) { @@ -145,8 +141,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) { @@ -193,8 +188,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()); @@ -205,8 +200,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) { @@ -230,8 +224,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) { @@ -245,7 +238,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) { @@ -257,8 +251,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) { @@ -266,20 +259,6 @@ public ResponseEntity handleEntityValidationException( 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 @@ -312,13 +291,27 @@ 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); + } /// Handles domain exception when entity deletion is blocked by required /// relations. /// @@ -439,8 +432,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/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..7fc96e93 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDepDtoOutMapper.java @@ -0,0 +1,122 @@ +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.LinkedHashSet; +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() { + // Utility class — not instantiable + } + + private record TraversalState(String rootIdentifier, // Stored to easily identify the starting + // anchor + Map> relationsMap, Set visitedNodeIds, + Set emittedEdgeSignatures) { + } + + public static EntityDepDtoOut toDto(EntityGraphNode root) { + if (root == null) { + return null; + } + + 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()); + + dto.setProperties(root.properties().stream() + .collect(Collectors.toMap(p -> p.name(), p -> p.value(), (v1, v2) -> v1))); + + var state = new TraversalState(root.identifier(), // Capture the root anchor ID + new HashMap<>(), new HashSet<>(), new HashSet<>()); + + traverse(root, state); + + // Convert 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 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)) { + // 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 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)) { + // 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; + } +} 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..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,4 +350,78 @@ private EntityDtoOut entityDtoOutMapper(Entity entity, Map> mergedRelations = mergeGraphNodeRelations(graphNode); + + return EntityDepDtoOut.builder().templateIdentifier(graphNode.templateIdentifier()) + .name(graphNode.name()).identifier(graphNode.identifier()) + .properties(Collections.emptyMap()).relations(mergedRelations).build(); + } + + /// Extracts ONLY the direct relations from the root entity node. + /// + /// **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 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<>(); + + // 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(relationName, targets); + } + } + + // 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()) { + 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); + } + } + } + + return mergedRelations; + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity_graph/EntityGraphFlatDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity_graph/EntityGraphFlatDtoOutMapper.java new file mode 100644 index 00000000..b09d2f5c --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity_graph/EntityGraphFlatDtoOutMapper.java @@ -0,0 +1,139 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity_graph; + +import java.util.ArrayList; +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; +import com.decathlon.idp_core.domain.service.entity_graph.EntityGraphService; +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 +/// +/// **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. +/// - 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() { + // 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]. + /// + /// 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 + /// @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()); + } + + 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); + + return new EntityGraphFlatDtoOut(List.copyOf(state.nodes()), List.copyOf(state.edges())); + } + + private static void traverse(EntityGraphNode node, TraversalState state) { + + var nodeId = nodeId(node.templateIdentifier(), node.identifier()); + + // Skip this node if already visited to prevent infinite loops in cyclic graphs + if (!state.visitedNodeIds().add(nodeId)) { + return; + } + + state.nodes().add(new EntityGraphNodeFlatDtoOut(nodeId, node.name(), node.templateIdentifier(), + node.identifier(), toDataMap(node))); + + // 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(state, nodeId, targetId, relation.name()); + traverse(target, state); + } + } + + // 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(state, sourceId, nodeId, relation.name()); + traverse(source, state); + } + } + } + + /// 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)); + } + } + + /// Builds the unique node identifier from the entity's composite key. + /// Format: "templateIdentifier:identifier" — mirrors + 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. + /// + /// 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. + /// + /// 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/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 262a9060..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,7 +96,6 @@ public void deleteRelationsByTemplateIdentifierAndRelationName(String templateId relationNames); } - @Override 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/PostgresEntityGraphAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java new file mode 100644 index 00000000..894acc57 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java @@ -0,0 +1,121 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence; + +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_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; +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; + + private static final Logger log = LoggerFactory.getLogger(PostgresEntityGraphAdapter.class); + + @Override + @Transactional(readOnly = true) + public Map findEntityGraph(UUID entityId, int depth, boolean includeProperties, + EntityGraphTraversalMode mode) { + + // 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.findEntityIdsInGraph(entityId, depth, mode.name()); + + 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 entitiesIds = graphPairs.stream().map(pair -> pair).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 composite key for O(1) lookup + return jpaEntities.stream().map(mapper::toDomain) + .collect(Collectors.toMap(Entity::id, Function.identity())); + + } + + @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/mapper/EntityPersistenceMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java index 40dbea1f..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 @@ -1,7 +1,9 @@ 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 org.springframework.stereotype.Component; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.Property; @@ -9,19 +11,140 @@ 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; + +/// 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(); + } + + // ========================================================================= + // Relation Mapping (with identifier ↔ UUID conversion) + // ========================================================================= + + public Relation toDomain(RelationJpaEntity jpa) { + if (jpa == null) { + return null; + } + + // 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) + .filter(Objects::nonNull).toList() + : List.of(); -@Mapper(componentModel = MappingConstants.ComponentModel.SPRING) -public interface EntityPersistenceMapper { + return new Relation(jpa.getId(), jpa.getName(), jpa.getTargetTemplateIdentifier(), + targetIdentifiers); + } - Entity toDomain(EntityJpaEntity jpa); + /// Converts domain relation to JPA entity. Resolves business identifiers to + /// 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; + } - EntityJpaEntity toJpa(Entity domain); + // Batch resolve all target identifiers in a single query, then map in-memory + List targetEntities = domain.targetEntityIdentifiers() != null + ? resolveBatchTargetEntities(domain.targetTemplateIdentifier(), + domain.targetEntityIdentifiers()) + : List.of(); - Property toDomain(PropertyJpaEntity jpa); + return RelationJpaEntity.builder().id(domain.id()).name(domain.name()) + .targetTemplateIdentifier(domain.targetTemplateIdentifier()).targetEntities(targetEntities) + .build(); + } - PropertyJpaEntity toJpa(Property domain); + /// 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(); + } - Relation toDomain(RelationJpaEntity jpa); + // Single batch query to resolve all targets at once + List resolvedEntities = entityRepository + .findAllByTemplateIdentifierAndIdentifierIn(targetTemplateIdentifier, targetIdentifiers); - RelationJpaEntity toJpa(Relation domain); + // 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/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 d135f1f2..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 @@ -7,13 +7,15 @@ 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; -import jakarta.persistence.Index; import jakarta.persistence.JoinColumn; import jakarta.persistence.Table; +import org.hibernate.annotations.BatchSize; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -37,8 +39,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(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..61cfbb7e --- /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); + } +} 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 200b1c3f..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 @@ -36,6 +36,82 @@ Optional findByTemplateIdentifierAndIdentifier(String templateI 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 r + WHERE e.id IN :ids + """) + List findAllByIdinWithRelations(@Param("ids") Collection ids); + + /// Fetch properties for entities that were already loaded. This is called after + /// 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 ( + -- 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 ('DIRECT_LINEAGE', 'OUTBOUND_ONLY') + + UNION + + SELECT e.id, 0, 'INBOUND' AS flow + FROM idp_core.entity e + WHERE e.id = :rootId AND :mode = 'DIRECT_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 + 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 findEntityIdsInGraph(@Param("rootId") UUID rootId, @Param("depth") int depth, + @Param("mode") String mode); + @Modifying(clearAutomatically = true, flushAutomatically = true) @Query(""" DELETE FROM PropertyJpaEntity p @@ -62,17 +138,82 @@ void deleteRelationsByTemplateIdentifierAndRelationName( @Param("templateIdentifier") String templateIdentifier, @Param("relationNames") Collection relationNames); - @Query(""" - SELECT entity - FROM EntityJpaEntity entity - JOIN entity.relations relation - JOIN relation.targetEntityIdentifiers targetEntityIdentifier - WHERE targetEntityIdentifier = :targetIdentifier - """) + 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. + @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( @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); + } 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 13129fc3..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 @@ -14,13 +14,27 @@ @Repository public interface JpaRelationRepository extends JpaRepository { - @Query(""" - SELECT tei AS targetEntityIdentifier, r.name AS relationName, e.identifier AS sourceEntityIdentifier, e.name AS sourceEntityName - FROM EntityJpaEntity e - JOIN e.relations r - JOIN r.targetEntityIdentifiers tei - WHERE tei 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 targetEntityIdentifier, + r.name AS relationName, + e.identifier AS sourceEntityIdentifier, + e.name AS sourceEntityName + 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/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..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 @@ -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 `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 +/// targetEntities). +/// - 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); }; diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 5b32be0b..a1c974c9 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -18,8 +18,20 @@ spring: validate-on-migrate: true jpa: hibernate: - ddl-auto: none # Disable JPA schema auto-generation, use Flyway instead - 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 +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 + org.hibernate.SQL: WARN + org.hibernate.stat: WARN + org.hibernate.type.descriptor.sql.BasicBinder: TRACE 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 new file mode 100644 index 00000000..d2b84540 --- /dev/null +++ b/src/main/resources/db/migration/V5_1__add_target_entity_uuid.sql @@ -0,0 +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); 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..350e3aa7 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java @@ -0,0 +1,702 @@ +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.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; + +import java.util.HashMap; +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; +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.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.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; + +@ExtendWith(MockitoExtension.class) +@DisplayName("EntityGraphService Tests") +class EntityGraphServiceTest { + + @Mock + private EntityRepositoryPort entityRepositoryPort; + + @Mock + private EntityGraphRepositoryPort entityGraphRepositoryPort; + + @Mock + private EntityTemplateValidationService entityTemplateValidationService; + + @InjectMocks + private EntityGraphService entityGraphService; + + // --- 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()); + } + + 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 static final String TEMPLATE = "web-service"; + + /// 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 = buildEntityMap(entities); + when(entityGraphRepositoryPort.findEntityGraph(anyUUID(), anyInt(), anyBoolean(), + any(EntityGraphTraversalMode.class))).thenReturn(entityMap); + } + + /// 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; + } + + // ======================== + @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()); + + 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); + } + } + + // ======================== + @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(api); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, + Set.of(), Set.of(), EntityGraphTraversalMode.BIDIRECTIONAL); + + 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(api, postgres); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, + Set.of(), Set.of(), EntityGraphTraversalMode.BIDIRECTIONAL); + + 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 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"))); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(api); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, + Set.of(), Set.of(), EntityGraphTraversalMode.BIDIRECTIONAL); + + // When target entity is not found in the map, it's filtered out + assertThat(result.relations()).isEmpty(); + } + } + + // ======================== + @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(api, consumer); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, + Set.of(), Set.of(), EntityGraphTraversalMode.BIDIRECTIONAL); + + 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"); + } + } + + // ======================== + @Nested + @DisplayName("Depth Clamping") + class DepthClamping { + + @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(api); + + entityGraphService.getEntityGraph(TEMPLATE, "api", 0, false, Set.of(), Set.of(), + EntityGraphTraversalMode.BIDIRECTIONAL); + + verify(entityGraphRepositoryPort).findEntityGraph(api.id(), 1, false, + EntityGraphTraversalMode.BIDIRECTIONAL); + } + + @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(api); + + entityGraphService.getEntityGraph(TEMPLATE, "api", 99, false, Set.of(), Set.of(), + EntityGraphTraversalMode.BIDIRECTIONAL); + + verify(entityGraphRepositoryPort).findEntityGraph(api.id(), 6, false, + EntityGraphTraversalMode.BIDIRECTIONAL); + } + } + + // ======================== + @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"))); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + // 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(), EntityGraphTraversalMode.BIDIRECTIONAL); + + EntityGraphNode postgresNode = result.relations().get(0).targets().get(0); + assertThat(postgresNode.identifier()).isEqualTo("postgres"); + // At depth=1, postgres is a leaf — no further outbound relations resolved + assertThat(postgresNode.relations()).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"); + } + } + + // ======================== + @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(api, postgres, auth); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, + Set.of(), Set.of(), EntityGraphTraversalMode.BIDIRECTIONAL); + + assertThat(result.relations()).hasSize(2); + assertThat(result.relations().stream().map(EntityGraphRelation::name)) + .containsExactlyInAnyOrder("uses-db", "depends-on"); + } + } + + // ======================== + @Nested + @DisplayName("Relation Filtering") + class RelationFiltering { + + @Test + @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"), 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(a, b, c); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 2, false, + Set.of("depends-on"), Set.of(), EntityGraphTraversalMode.BIDIRECTIONAL); + + assertThat(result.relations()).hasSize(1); + assertThat(result.relations().get(0).name()).isEqualTo("depends-on"); + } + + @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(a, b, c); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 2, false, Set.of(), + Set.of(), EntityGraphTraversalMode.BIDIRECTIONAL); + + 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(api, consumer, unrelated); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, + Set.of("depends-on"), Set.of(), EntityGraphTraversalMode.BIDIRECTIONAL); + + 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(api); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, true, Set.of(), + Set.of("env"), EntityGraphTraversalMode.BIDIRECTIONAL); + + 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(api); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, true, Set.of(), + Set.of(), EntityGraphTraversalMode.BIDIRECTIONAL); + + 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(api); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, + Set.of(), Set.of("env"), EntityGraphTraversalMode.BIDIRECTIONAL); + + assertThat(result.properties()).isEmpty(); + } + } + + // ======================== + @Nested + @DisplayName("Visited Node Guard — OOM Prevention") + class VisitedNodeGuard { + + @Test + @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=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"); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "a")) + .thenReturn(Optional.of(a)); + stubGraph(a, b, c); + + // 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(), EntityGraphTraversalMode.BIDIRECTIONAL); + + 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(a, b); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 5, false, Set.of(), + Set.of(), EntityGraphTraversalMode.BIDIRECTIONAL); + + // 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(); + } + } + + // ======================== + @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 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, backend); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 2, false, + Set.of(), Set.of(), EntityGraphTraversalMode.DIRECT_LINEAGE); + + // 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"); + + // 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 + @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(); + } + } +} 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 0708dd04..73ba9f31 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 @@ -55,8 +55,8 @@ void getEntities_paginated_200() throws Exception { .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)) @@ -114,8 +114,8 @@ void getEntities_invalid_pagination_200() throws Exception { .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(20)) .andExpect(jsonPath("$.page.number").value(0)) @@ -266,8 +266,8 @@ 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)); + .andExpect(status().isOk()).andExpect(jsonPath("$.content.length()").value(5)) + .andExpect(jsonPath("$.page.total_elements").value(5)); } @Test 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..8abc1006 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java @@ -0,0 +1,202 @@ +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__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 +/// +/// 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()); + } + } + + @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 b44a75c0..4d0abf2e 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; 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 734f67b3..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 @@ -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-446655440115', 'default-team', 'Default Team', 'team'), ('550e8400-e29b-41d4-a716-446655440116', 'test-team-required', 'Test Team Required', 'team'), @@ -66,6 +69,7 @@ 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) VALUES ('550e8400-e29b-41d4-a716-446655440100', 'aa000000-0000-0000-0000-000000000001'), @@ -78,6 +82,7 @@ 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) VALUES ('550e8400-e29b-41d4-a716-446655440101', 'aa000000-0000-0000-0000-000000000003'), @@ -88,9 +93,11 @@ VALUES 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) VALUES ('550e8400-e29b-41d4-a716-446655440100', 'bb000000-0000-0000-0000-000000000001'); @@ -99,9 +106,11 @@ VALUES INSERT INTO idp_core.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 idp_core.entity_relations (entity_id, relation_id) VALUES ('550e8400-e29b-41d4-a716-446655440101', 'bb000000-0000-0000-0000-000000000002'); @@ -110,9 +119,11 @@ VALUES INSERT INTO idp_core.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 idp_core.entity_relations (entity_id, relation_id) VALUES ('550e8400-e29b-41d4-a716-446655440100', 'bb000000-0000-0000-0000-000000000003'); @@ -121,9 +132,11 @@ VALUES 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'); 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