diff --git a/docs/src/concepts/entities.md b/docs/src/concepts/entities.md index c08679f..53591d1 100644 --- a/docs/src/concepts/entities.md +++ b/docs/src/concepts/entities.md @@ -51,11 +51,10 @@ Here's an entity instantiated from the `web-service` template: "depends-on": [ { "identifier": "web-api-1", - "name": "Web API 1" - } + "name": "Web API 1", + "template_identifier": "web-service" ] - }, - "relations_as_target": {} + } } ``` @@ -220,70 +219,45 @@ When creating an entity, you specify relations as an array of objects, each with } ``` -### Relations in Responses - -In API responses, relations are grouped by name and include summary information about each target entity: - -```json -{ - "relations": { - "depends-on": [ - { - "identifier": "web-api-1", - "name": "Web API 1" - }, - { - "identifier": "web-api-2", - "name": "Web API 2" - } - ] - }, - "relations_as_target": { - "depends-on": [ - { - "identifier": "frontend-app", - "name": "Frontend App" - } - ] - } -} -``` - -The `relations_as_target` field shows reverse relationships—other entities that reference this entity. - -### One-to-One Relations (`to_many: false`) +### One-to-Many Relations (`to_many: true`) -For consistency, even single relations are represented as arrays: +When multiple related entities are allowed, list several identifiers: ```json { "relations": [ { - "name": "owned_by", + "name": "components", "target_entity_identifiers": [ - "platform-team" + "frontend", + "backend", + "database" ] } ] } ``` -### One-to-Many Relations (`to_many: true`) +### Relations in Responses -When multiple related entities are allowed, list several identifiers: +In API responses, relations are grouped by name and include summary information about each target entity: ```json { - "relations": [ - { - "name": "components", - "target_entity_identifiers": [ - "frontend", - "backend", - "database" - ] - } - ] + "relations": { + "depends-on": [ + { + "identifier": "web-api-1", + "name": "Web API 1", + "template_identifier": "web-service" + }, + { + "identifier": "web-api-2", + "name": "Web API 2", + "template_identifier": "web-service" + } + ] + } } ``` @@ -386,8 +360,7 @@ curl -X PUT http://localhost:8084/api/v1/entities/web-service/my-web-service \ "protocol": "HTTP", "programmingLanguage": "JAVA" }, - "relations": {}, - "relations_as_target": {} + "relations": {} } ``` diff --git a/docs/src/concepts/entity-filtering.md b/docs/src/concepts/entity-filtering.md index 03c9c63..c0987c4 100644 --- a/docs/src/concepts/entity-filtering.md +++ b/docs/src/concepts/entity-filtering.md @@ -103,7 +103,7 @@ relation.database.name:prod #### Reverse Relation Filters -Use `relations_as_target..` to find entities that _appear as targets_ in a relation of type ``. The `` must be `identifier` or `name` and refers to the **source** entity in that relation. +Use `relations_as_target..` to find entities that have an inbound relation of type ``. The `` must be `identifier` or `name` and refers to the **source** entity in that relation. ```text relations_as_target.owned_by.name:platform-team diff --git a/docs/src/static/swagger.yaml b/docs/src/static/swagger.yaml index 8cce035..1ae99d0 100644 --- a/docs/src/static/swagger.yaml +++ b/docs/src/static/swagger.yaml @@ -22,7 +22,7 @@ tags: - name: Entity dynamic mapping description: Operations related to entity dynamic mapping management paths: - /api/v1/inbound_webhooks/{identifier}: + '/api/v1/inbound_webhooks/{identifier}': get: tags: - Inbound Webhook Management @@ -36,24 +36,25 @@ paths: schema: type: string responses: - "200": + '200': description: Webhook connector found content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/InboundWebhookDtoOut" - "404": + $ref: '#/components/schemas/InboundWebhookDtoOut' + '404': description: Webhook connector not found with the provided identifier content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' put: tags: - Inbound Webhook Management summary: Update an existing webhook connector by identifier - description: Update the details of an existing webhook connector identified by - its unique string identifier + description: >- + Update the details of an existing webhook connector identified by its + unique string identifier operationId: putWebhookConnector parameters: - name: identifier @@ -65,33 +66,33 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/InboundWebhookUpdateDtoIn" + $ref: '#/components/schemas/InboundWebhookUpdateDtoIn' required: true responses: - "200": + '200': description: Webhook connector updated successfully content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/InboundWebhookDtoOut" - "400": + $ref: '#/components/schemas/InboundWebhookDtoOut' + '400': description: Invalid request payload content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/ErrorResponse" - "404": + $ref: '#/components/schemas/ErrorResponse' + '404': description: Webhook connector not found with the provided identifier content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/ErrorResponse" - "409": + $ref: '#/components/schemas/ErrorResponse' + '409': description: Webhook connector name already exists content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' delete: tags: - Inbound Webhook Management @@ -105,15 +106,15 @@ paths: schema: type: string responses: - "204": + '204': description: Webhook connector deleted successfully - "404": + '404': description: Webhook connector not found with the provided identifier content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/ErrorResponse" - /api/v1/entity_dynamic_mappings/{identifier}: + $ref: '#/components/schemas/ErrorResponse' + '/api/v1/entity_dynamic_mappings/{identifier}': get: tags: - Entity dynamic mapping @@ -127,24 +128,25 @@ paths: schema: type: string responses: - "200": + '200': description: Entity dynamic mapping found content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/EntityDynamicMappingDtoOut" - "404": + $ref: '#/components/schemas/EntityDynamicMappingDtoOut' + '404': description: Entity dynamic mapping not found with the provided identifier content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' put: tags: - Entity dynamic mapping summary: Update an existing entity dynamic mapping by identifier - description: Update the details of an existing entity dynamic - mapping identified by its unique string identifier + description: >- + Update the details of an existing entity dynamic mapping identified by + its unique string identifier operationId: updateEntityDynamicMapping parameters: - name: identifier @@ -156,33 +158,33 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/EntityDynamicMappingUpdateDtoIn" + $ref: '#/components/schemas/EntityDynamicMappingUpdateDtoIn' required: true responses: - "200": + '200': description: Entity dynamic mapping updated successfully content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/EntityDynamicMappingDtoOut" - "400": + $ref: '#/components/schemas/EntityDynamicMappingDtoOut' + '400': description: Bad Request content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/ErrorResponse" - "404": + $ref: '#/components/schemas/ErrorResponse' + '404': description: Entity dynamic mapping not found with the provided identifier content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/ErrorResponse" - "409": + $ref: '#/components/schemas/ErrorResponse' + '409': description: Conflict content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' delete: tags: - Entity dynamic mapping @@ -196,15 +198,15 @@ paths: schema: type: string responses: - "204": + '204': description: Entity dynamic mapping deleted successfully - "404": + '404': description: Entity dynamic mapping not found with the provided identifier content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/ErrorResponse" - /api/v1/entity-templates/{identifier}: + $ref: '#/components/schemas/ErrorResponse' + '/api/v1/entity-templates/{identifier}': get: tags: - Entities Templates Management @@ -218,23 +220,24 @@ paths: schema: type: string responses: - "200": + '200': description: Template found content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/EntityTemplateDtoOut" - "404": + $ref: '#/components/schemas/EntityTemplateDtoOut' + '404': description: Template not found with the provided identifier content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' put: tags: - Entities Templates Management 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: @@ -247,21 +250,21 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/EntityTemplateUpdateDtoIn" + $ref: '#/components/schemas/EntityTemplateUpdateDtoIn' required: true responses: - "200": + '200': description: Template update successfully content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/EntityTemplateDtoOut" - "404": + $ref: '#/components/schemas/EntityTemplateDtoOut' + '404': description: Template not found with the provided identifier content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' delete: tags: - Entities Templates Management @@ -275,21 +278,24 @@ paths: schema: type: string responses: - "204": + '204': description: Template deleted successfully - "404": + '404': description: Template not found with the provided identifier content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/ErrorResponse" - /api/v1/entities/{templateIdentifier}/{entityIdentifier}: + $ref: '#/components/schemas/ErrorResponse' + '/api/v1/entities/{templateIdentifier}/{entityIdentifier}': get: tags: - Entities Management summary: Get entity by entity template and identifier - description: Retrieve a specific entity using its string identifier and its - template identifier + description: >- + Retrieve a specific entity using its string identifier and its template + identifier with configurable relationship graph traversal. Supports + fetching both inbound and outbound relations up to a specified depth + using direct lineage mode. operationId: getEntity parameters: - name: templateIdentifier @@ -302,19 +308,39 @@ paths: required: true schema: type: string + - name: relations_depth + in: query + description: >- + Maximum depth to traverse when collecting entity relations. Defaults + to 1. Valid range: 1-6. + required: false + schema: + type: integer + default: 1 + - name: relations_to_display + in: query + description: >- + Comma-separated list of relation names to include in the response. + When omitted, all relations are included. + required: false + schema: + type: array + example: + - depends-on + - relates-to responses: - "200": + '200': description: Entity found content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/EntityDtoOut" - "404": + $ref: '#/components/schemas/EntityDtoOut' + '404': description: Entity not found with the provided identifier content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' put: tags: - Entities Management @@ -338,42 +364,43 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/EntityUpdateDtoIn" + $ref: '#/components/schemas/EntityUpdateDtoIn' required: true responses: - "200": + '200': description: Entity updated successfully content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/EntityDtoOut" - "400": + $ref: '#/components/schemas/EntityDtoOut' + '400': description: Invalid entity data provided content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/ErrorResponse" - "401": + $ref: '#/components/schemas/ErrorResponse' + '401': description: Unauthorized - Missing or invalid token - "403": + '403': description: Insufficient rights - "404": + '404': description: Entity not found with the provided identifier content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/ErrorResponse" - "500": + $ref: '#/components/schemas/ErrorResponse' + '500': description: Unexpected server-side failure content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' delete: tags: - Entities Management summary: Delete an existing entity - description: Delete an entity from the system using its template and 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 @@ -391,36 +418,36 @@ paths: type: string minLength: 1 responses: - "204": + '204': description: Entity deleted successfully - "400": + '400': description: Invalid entity data provided content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/ErrorResponse" - "401": + $ref: '#/components/schemas/ErrorResponse' + '401': description: Unauthorized - Missing or invalid token - "403": + '403': description: Insufficient rights - "404": + '404': description: Entity not found with the provided identifier content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/ErrorResponse" - "409": + $ref: '#/components/schemas/ErrorResponse' + '409': description: Target entity has required relations content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/ErrorResponse" - "500": + $ref: '#/components/schemas/ErrorResponse' + '500': description: Unexpected server-side failure content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' /api/v1/inbound_webhooks: get: tags: @@ -433,78 +460,81 @@ paths: in: query description: Page number for pagination. Defaults to 0. content: - "*/*": + '*/*': schema: type: integer - default: "0" + default: '0' - name: size in: query description: Number of items per page. Defaults to 20. content: - "*/*": + '*/*': schema: type: integer - default: "20" + 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": + '200': description: Paginated webhook connector retrieved successfully content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/WebhookConnectorPageResponse" - "400": + $ref: '#/components/schemas/WebhookConnectorPageResponse' + '400': description: Invalid pagination parameters content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' post: tags: - Inbound Webhook Management summary: Create a new webhook connector configuration - description: Creates a webhook connector configuration used by the generic - inbound webhook endpoint + description: >- + Creates a webhook connector configuration used by the generic inbound + webhook endpoint operationId: createInboundWebhook requestBody: content: application/json: schema: - $ref: "#/components/schemas/InboundWebhookCreateDtoIn" + $ref: '#/components/schemas/InboundWebhookCreateDtoIn' required: true responses: - "201": + '201': description: Webhook connector created content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/InboundWebhookDtoOut" - "400": + $ref: '#/components/schemas/InboundWebhookDtoOut' + '400': description: Invalid webhook connector data provided content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/InboundWebhookDtoOut" - "409": + $ref: '#/components/schemas/InboundWebhookDtoOut' + '409': description: Webhook connector already exists in this entityTemplateIdentifier content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/InboundWebhookDtoOut" + $ref: '#/components/schemas/InboundWebhookDtoOut' /api/v1/entity_dynamic_mappings: get: tags: - Entity dynamic mapping summary: Get paginated entity dynamic mappings - description: Retrieve a paginated list of entity dynamic mappings with optional + description: >- + Retrieve a paginated list of entity dynamic mappings with optional sorting operationId: getEntityDynamicMappingPaginated parameters: @@ -512,72 +542,74 @@ paths: in: query description: Page number for pagination. Defaults to 0. content: - "*/*": + '*/*': schema: type: integer - default: "0" + default: '0' - name: size in: query description: Number of items per page. Defaults to 20. content: - "*/*": + '*/*': schema: type: integer - default: "20" + 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": + '200': description: Paginated entity dynamic mapping retrieved successfully content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/EntityDynamicMappingPageResponse" - "400": + $ref: '#/components/schemas/EntityDynamicMappingPageResponse' + '400': description: Invalid pagination parameters content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' post: tags: - Entity dynamic mapping summary: Create entity dynamic mapping - description: Creates a new entity dynamic mapping used by the generic inbound - webhook endpoint + description: >- + Creates a new entity dynamic mapping used by the generic inbound webhook + endpoint operationId: createDynamicMapping requestBody: content: application/json: schema: - $ref: "#/components/schemas/EntityDynamicMappingCreateDtoIn" + $ref: '#/components/schemas/EntityDynamicMappingCreateDtoIn' required: true responses: - "201": + '201': description: Entity dynamic mapping created content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/EntityDynamicMappingDtoOut" - "400": + $ref: '#/components/schemas/EntityDynamicMappingDtoOut' + '400': description: Invalid entity dynamic mapping data provided content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/EntityDynamicMappingDtoOut" - "409": + $ref: '#/components/schemas/EntityDynamicMappingDtoOut' + '409': description: Identifier already exists content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/EntityDynamicMappingDtoOut" + $ref: '#/components/schemas/EntityDynamicMappingDtoOut' /api/v1/entity-templates: get: tags: @@ -590,40 +622,41 @@ paths: in: query description: Page number for pagination. Defaults to 0. content: - "*/*": + '*/*': schema: type: integer - default: "0" + default: '0' - name: size in: query description: Number of items per page. Defaults to 20. content: - "*/*": + '*/*': schema: type: integer - default: "20" + 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": + '200': description: Paginated templates retrieved successfully content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/TemplatePageResponse" - "400": + $ref: '#/components/schemas/TemplatePageResponse' + '400': description: Invalid pagination parameters content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' post: tags: - Entities Templates Management @@ -634,22 +667,22 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/EntityTemplateCreateDtoIn" + $ref: '#/components/schemas/EntityTemplateCreateDtoIn' required: true responses: - "201": + '201': description: Template created successfully content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/EntityTemplateDtoOut" - "400": + $ref: '#/components/schemas/EntityTemplateDtoOut' + '400': description: Invalid template data provided content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/ErrorResponse" - /api/v1/entities/{templateIdentifier}: + $ref: '#/components/schemas/ErrorResponse' + '/api/v1/entities/{templateIdentifier}': get: tags: - Entities Management @@ -662,19 +695,19 @@ paths: description: Page number for pagination. Defaults to 0. required: false content: - "*/*": + '*/*': schema: type: integer - default: "0" + default: '0' - name: size in: query description: Number of items per page. Defaults to 20. required: false content: - "*/*": + '*/*': schema: type: integer - default: "20" + default: '20' - name: templateIdentifier in: path required: true @@ -688,31 +721,32 @@ paths: 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": + '200': description: Paginated entities retrieved successfully content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/EntityPageResponse" - "400": + $ref: '#/components/schemas/EntityPageResponse' + '400': description: Invalid filter query syntax content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' post: tags: - Entities Management @@ -730,78 +764,80 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/EntityCreateDtoIn" + $ref: '#/components/schemas/EntityCreateDtoIn' required: true responses: - "201": + '201': description: Entity created successfully content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/EntityDtoOut" - "400": + $ref: '#/components/schemas/EntityDtoOut' + '400': description: Invalid entity data provided content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/ErrorResponse" - "401": + $ref: '#/components/schemas/ErrorResponse' + '401': description: Unauthorized - Missing or invalid token - "403": + '403': description: Insufficient rights - "404": + '404': description: Template not found with the provided identifier content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/ErrorResponse" - "409": + $ref: '#/components/schemas/ErrorResponse' + '409': description: Entity already exists in this template content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/ErrorResponse" - "500": + $ref: '#/components/schemas/ErrorResponse' + '500': description: Unexpected server-side failure content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' /api/v1/entities/search: post: tags: - Entities Management summary: Search entities - description: Search for entities across all templates using nested filter - queries. Supports complex logical compositions (AND / OR) of filter - criteria on template, identifier, name, properties, relations, and - reverse relations. + description: >- + Search for entities across all templates using nested filter queries. + Supports complex logical compositions (AND / OR) of filter criteria on + template, identifier, name, properties, relations, and reverse + relations. operationId: searchEntities requestBody: content: application/json: schema: - $ref: "#/components/schemas/EntitySearchRequestDtoIn" + $ref: '#/components/schemas/EntitySearchRequestDtoIn' required: true responses: - "200": + '200': description: Entities retrieved successfully content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/EntityPageResponse" - "400": + $ref: '#/components/schemas/EntityPageResponse' + '400': description: Invalid search filter content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/ErrorResponse" - /api/v1/entities/{templateIdentifier}/{entityIdentifier}/graph: + $ref: '#/components/schemas/ErrorResponse' + '/api/v1/entities/{templateIdentifier}/{entityIdentifier}/graph': get: tags: - Entity Graph summary: Get entity relationship graph as flat nodes and edges - description: Retrieves the entity relationship graph as a 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 @@ -820,8 +856,9 @@ paths: minLength: 1 - name: depth in: query - description: Maximum traversal depth for relationship resolution. Clamped - between 1 and 6. + description: >- + Maximum traversal depth for relationship resolution. Clamped between + 1 and 6. required: false schema: type: integer @@ -829,7 +866,8 @@ paths: default: 1 - name: include_data in: query - description: When true, each graph node includes a data object containing the + description: >- + When true, each graph node includes a data object containing the entity's property values. Defaults to false. required: false schema: @@ -837,7 +875,8 @@ paths: default: false - name: traversal_mode in: query - description: Specifies the traversal mode for the entity graph. Defaults to + description: >- + Specifies the traversal mode for the entity graph. Defaults to DIRECT_LINEAGE. required: false schema: @@ -849,7 +888,8 @@ paths: - OUTBOUND_ONLY - name: relations in: query - description: When provided, only relations whose name matches one of the listed + 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: @@ -858,7 +898,8 @@ paths: type: string - name: properties in: query - description: When provided, each node's data object is restricted to the listed + 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 @@ -867,25 +908,26 @@ paths: items: type: string responses: - "200": + '200': description: Flat entity graph successfully retrieved content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/EntityGraphFlatDtoOut" - "404": + $ref: '#/components/schemas/EntityGraphFlatDtoOut' + '404': description: Entity not found with the provided identifier content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/ErrorResponse" - /api/v1/audit/entities/{templateIdentifier}/{entityIdentifier}: + $ref: '#/components/schemas/ErrorResponse' + '/api/v1/audit/entities/{templateIdentifier}/{entityIdentifier}': get: tags: - Audit summary: Get entity audit history - description: Retrieve the complete audit history for a specific entity, - including all revisions with timestamps and modification types + description: >- + Retrieve the complete audit history for a specific entity, including all + revisions with timestamps and modification types operationId: getEntityAuditHistory parameters: - name: templateIdentifier @@ -901,37 +943,38 @@ paths: type: string minLength: 1 responses: - "200": + '200': description: Successfully retrieved entity audit history content: - "*/*": + '*/*': schema: type: array items: - $ref: "#/components/schemas/EntityAuditDtoOut" - "400": + $ref: '#/components/schemas/EntityAuditDtoOut' + '400': description: Invalid template or entity identifier content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/ErrorResponse" - "401": + $ref: '#/components/schemas/ErrorResponse' + '401': description: Unauthorized - Missing or invalid token - "403": + '403': description: Insufficient rights - "404": - description: Template not found with the provided identifier or Entity not found + '404': + description: >- + Template not found with the provided identifier or Entity not found with the provided identifier content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/ErrorResponse" - "500": + $ref: '#/components/schemas/ErrorResponse' + '500': description: Unexpected server-side failure content: - "*/*": + '*/*': schema: - $ref: "#/components/schemas/ErrorResponse" + $ref: '#/components/schemas/ErrorResponse' components: schemas: InboundWebhookSecurityContractDtoIn: @@ -963,7 +1006,7 @@ components: items: type: string security: - $ref: "#/components/schemas/InboundWebhookSecurityContractDtoIn" + $ref: '#/components/schemas/InboundWebhookSecurityContractDtoIn' required: - name EntityDynamicMappingDtoOut: @@ -980,7 +1023,7 @@ components: description: type: string entity: - $ref: "#/components/schemas/InboundWebhookEntityMappingDtoOut" + $ref: '#/components/schemas/InboundWebhookEntityMappingDtoOut' InboundWebhookDtoOut: type: object properties: @@ -995,9 +1038,9 @@ components: mappings: type: array items: - $ref: "#/components/schemas/EntityDynamicMappingDtoOut" + $ref: '#/components/schemas/EntityDynamicMappingDtoOut' security: - $ref: "#/components/schemas/InboundWebhookSecurityDtoOut" + $ref: '#/components/schemas/InboundWebhookSecurityDtoOut' InboundWebhookEntityMappingDtoOut: type: object properties: @@ -1044,7 +1087,7 @@ components: description: type: string entity: - $ref: "#/components/schemas/EntityMappingDtoIn" + $ref: '#/components/schemas/EntityMappingDtoIn' required: - entity - entity_template_identifier @@ -1082,7 +1125,7 @@ components: example: Service maxLength: 255 minLength: 0 - pattern: ^[a-zA-Z0-9 _-]+$ + pattern: '^[a-zA-Z0-9 _-]+$' description: type: string description: Entity Template description @@ -1091,12 +1134,12 @@ components: type: array description: List of property definitions for this template items: - $ref: "#/components/schemas/PropertyDefinitionDtoIn" + $ref: '#/components/schemas/PropertyDefinitionDtoIn' relations_definitions: type: array description: List of relation definitions for this template items: - $ref: "#/components/schemas/RelationDefinitionDtoIn" + $ref: '#/components/schemas/RelationDefinitionDtoIn' required: - name PropertyDefinitionDtoIn: @@ -1127,7 +1170,7 @@ components: description: Whether this property is required example: true rules: - $ref: "#/components/schemas/PropertyRulesDtoIn" + $ref: '#/components/schemas/PropertyRulesDtoIn' description: Property validation rules required: - description @@ -1155,7 +1198,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 @@ -1223,12 +1266,12 @@ components: type: array description: List of property definitions for this template items: - $ref: "#/components/schemas/PropertyDefinitionDtoOut" + $ref: '#/components/schemas/PropertyDefinitionDtoOut' relations_definitions: type: array description: List of relation definitions for this template items: - $ref: "#/components/schemas/RelationDefinitionDtoOut" + $ref: '#/components/schemas/RelationDefinitionDtoOut' PropertyDefinitionDtoOut: type: object description: Output DTO for property definition @@ -1254,7 +1297,7 @@ components: description: Whether this property is required example: true rules: - $ref: "#/components/schemas/PropertyRulesDtoOut" + $ref: '#/components/schemas/PropertyRulesDtoOut' description: Property validation rules example: Property validation rules PropertyRulesDtoOut: @@ -1279,7 +1322,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 @@ -1335,13 +1378,13 @@ components: type: string description: Map of property name to value for this entity example: - port: "8080" + port: '8080' environment: dev relations: type: array description: List of relations for this entity items: - $ref: "#/components/schemas/RelationDtoIn" + $ref: '#/components/schemas/RelationDtoIn' required: - name RelationDtoIn: @@ -1367,11 +1410,11 @@ components: EntityDtoOut: type: object properties: - template_identifier: + identifier: type: string name: type: string - identifier: + template_identifier: type: string properties: type: object @@ -1381,13 +1424,7 @@ components: additionalProperties: type: array items: - $ref: "#/components/schemas/EntitySummaryDto" - relations_as_target: - type: object - additionalProperties: - type: array - items: - $ref: "#/components/schemas/EntitySummaryDto" + $ref: '#/components/schemas/EntitySummaryDto' EntitySummaryDto: type: object properties: @@ -1395,6 +1432,8 @@ components: type: string name: type: string + template_identifier: + type: string InboundWebhookCreateDtoIn: type: object properties: @@ -1415,7 +1454,7 @@ components: items: type: string security: - $ref: "#/components/schemas/InboundWebhookSecurityContractDtoIn" + $ref: '#/components/schemas/InboundWebhookSecurityContractDtoIn' required: - identifier - name @@ -1437,7 +1476,7 @@ components: description: type: string entity: - $ref: "#/components/schemas/EntityMappingDtoIn" + $ref: '#/components/schemas/EntityMappingDtoIn' required: - entity - entity_template_identifier @@ -1459,7 +1498,7 @@ components: example: Service maxLength: 255 minLength: 0 - pattern: ^[a-zA-Z0-9 _-]+$ + pattern: '^[a-zA-Z0-9 _-]+$' description: type: string description: Entity Template description @@ -1468,12 +1507,12 @@ components: type: array description: List of property definitions for this template items: - $ref: "#/components/schemas/PropertyDefinitionDtoIn" + $ref: '#/components/schemas/PropertyDefinitionDtoIn' relations_definitions: type: array description: List of relation definitions for this template items: - $ref: "#/components/schemas/RelationDefinitionDtoIn" + $ref: '#/components/schemas/RelationDefinitionDtoIn' required: - identifier - name @@ -1497,13 +1536,13 @@ components: type: string description: Map of property name to value for this entity example: - port: "8080" + port: '8080' environment: dev relations: type: array description: List of relations for this entity items: - $ref: "#/components/schemas/RelationDtoIn" + $ref: '#/components/schemas/RelationDtoIn' required: - identifier - name @@ -1513,13 +1552,15 @@ components: properties: query: type: string - description: Free-text search string. When present, returns entities whose + 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 + $ref: '#/components/schemas/FilterNodeDtoIn' + description: >- + Root node of the search filter tree. May be omitted or null to return all entities. page: type: integer @@ -1535,41 +1576,48 @@ components: example: 20 sort: type: string - description: "Sorting criteria in the format: property(,asc|desc). Defaults to - identifier,asc." - example: identifier:asc + description: >- + Sorting criteria in the format: property(,asc|desc). Defaults to + identifier,asc. + example: 'identifier:asc' FilterNodeDtoIn: type: object - description: A node in the search filter tree. Either a logical group (connector - + criteria) or a leaf criterion (field + operation + value). + description: >- + A node in the search filter tree. Either a logical group (connector + + criteria) or a leaf criterion (field + operation + value). properties: connector: type: string - description: "Logical connector for a group node. One of: AND, OR. Required for - group nodes." + description: >- + Logical connector for a group node. One of: AND, OR. Required for + group nodes. example: AND criteria: type: array - description: Child filter nodes for a group node. Required for group nodes (must + description: >- + Child filter nodes for a group node. Required for group nodes (must be non-empty). items: - $ref: "#/components/schemas/FilterNodeDtoIn" + $ref: '#/components/schemas/FilterNodeDtoIn' field: type: string - description: "Field to filter on for a criterion node. Required for leaf nodes. + 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" + relations_as_target.api-link.name example: template operation: type: string - description: "Filter operation for a criterion node. One of: EQ, NEQ, CONTAINS, + 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." + leaf nodes. example: EQ value: type: string - description: Value to compare against for a criterion node. Required for leaf + description: >- + Value to compare against for a criterion node. Required for leaf nodes. example: microservice EntityPageResponse: @@ -1579,19 +1627,19 @@ components: content: type: array items: - $ref: "#/components/schemas/EntityDtoOut" + $ref: '#/components/schemas/EntityDtoOut' pageable: - $ref: "#/components/schemas/PageableObject" + $ref: '#/components/schemas/PageableObject' + last: + type: boolean totalElements: type: integer format: int64 totalPages: type: integer format: int32 - last: - type: boolean sort: - $ref: "#/components/schemas/SortObject" + $ref: '#/components/schemas/SortObject' first: type: boolean numberOfElements: @@ -1616,10 +1664,10 @@ components: pageSize: type: integer format: int32 + sort: + $ref: '#/components/schemas/SortObject' unpaged: type: boolean - sort: - $ref: "#/components/schemas/SortObject" offset: type: integer format: int64 @@ -1639,19 +1687,19 @@ components: content: type: array items: - $ref: "#/components/schemas/InboundWebhookDtoOut" + $ref: '#/components/schemas/InboundWebhookDtoOut' pageable: - $ref: "#/components/schemas/PageableObject" + $ref: '#/components/schemas/PageableObject' + last: + type: boolean totalElements: type: integer format: int64 totalPages: type: integer format: int32 - last: - type: boolean sort: - $ref: "#/components/schemas/SortObject" + $ref: '#/components/schemas/SortObject' first: type: boolean numberOfElements: @@ -1672,19 +1720,19 @@ components: content: type: array items: - $ref: "#/components/schemas/EntityDynamicMappingDtoOut" + $ref: '#/components/schemas/EntityDynamicMappingDtoOut' pageable: - $ref: "#/components/schemas/PageableObject" + $ref: '#/components/schemas/PageableObject' + last: + type: boolean totalElements: type: integer format: int64 totalPages: type: integer format: int32 - last: - type: boolean sort: - $ref: "#/components/schemas/SortObject" + $ref: '#/components/schemas/SortObject' first: type: boolean numberOfElements: @@ -1705,19 +1753,19 @@ components: content: type: array items: - $ref: "#/components/schemas/EntityTemplateDtoOut" + $ref: '#/components/schemas/EntityTemplateDtoOut' pageable: - $ref: "#/components/schemas/PageableObject" + $ref: '#/components/schemas/PageableObject' + last: + type: boolean totalElements: type: integer format: int64 totalPages: type: integer format: int32 - last: - type: boolean sort: - $ref: "#/components/schemas/SortObject" + $ref: '#/components/schemas/SortObject' first: type: boolean numberOfElements: @@ -1753,18 +1801,18 @@ components: type: array description: All entity nodes in the graph items: - $ref: "#/components/schemas/EntityGraphNodeFlatDtoOut" + $ref: '#/components/schemas/EntityGraphNodeFlatDtoOut' edges: type: array description: All directed relation edges in the graph items: - $ref: "#/components/schemas/EntityGraphEdgeDtoOut" + $ref: '#/components/schemas/EntityGraphEdgeDtoOut' EntityGraphNodeFlatDtoOut: type: object properties: id: type: string - description: Unique node identifier composed of templateIdentifier:identifier + description: 'Unique node identifier composed of templateIdentifier:identifier' label: type: string description: Human-readable entity name @@ -1777,7 +1825,8 @@ components: data: type: object additionalProperties: {} - description: Entity property values keyed by property name; present only when + description: >- + Entity property values keyed by property name; present only when include_data=true is requested EntityAuditDtoOut: type: object @@ -1791,17 +1840,17 @@ components: type: string format: date-time description: Timestamp when the revision was created - example: 2026-06-08T14:37:27.743Z + example: '2026-06-08T14:37:27.743Z' revision_type: type: string - description: Type of operation performed (CREATED, UPDATED, DELETED) + description: 'Type of operation performed (CREATED, UPDATED, DELETED)' example: UPDATED modified_by: type: string description: Identifier of the user who performed the modification example: user@example.com snapshot: - $ref: "#/components/schemas/EntitySnapshotDtoOut" + $ref: '#/components/schemas/EntitySnapshotDtoOut' description: Snapshot of the entity state at this revision EntitySnapshotDtoOut: type: object @@ -1823,12 +1872,12 @@ components: type: array description: Properties of the entity at this revision items: - $ref: "#/components/schemas/PropertySnapshotDtoOut" + $ref: '#/components/schemas/PropertySnapshotDtoOut' relations: type: array description: Relations of the entity at this revision items: - $ref: "#/components/schemas/RelationSnapshotDtoOut" + $ref: '#/components/schemas/RelationSnapshotDtoOut' PropertySnapshotDtoOut: type: object description: Snapshot of a property at a specific audit revision @@ -1868,7 +1917,7 @@ components: name: clientId flows: clientCredentials: - tokenUrl: https://example.com/as/token.oauth2 + tokenUrl: "https://example.com/as/token.oauth2" bearer: type: http description: bearer authentication diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/RelationAsTargetSummary.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/RelationAsTargetSummary.java index b38af3e..d0bfd70 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/RelationAsTargetSummary.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/RelationAsTargetSummary.java @@ -11,6 +11,7 @@ /// - Dependency impact analysis before entity deletion /// - Bidirectional relationship navigation /// - Audit trails for relationship changes +/// - Template-aware relation summaries for unified API responses public record RelationAsTargetSummary(String targetEntityIdentifier, String relationName, - String sourceEntityIdentifier, String sourceEntityName) { + String sourceEntityIdentifier, String sourceEntityName, String sourceTemplateIdentifier) { } diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/FlowDirection.java b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/FlowDirection.java new file mode 100644 index 0000000..8a81502 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/FlowDirection.java @@ -0,0 +1,15 @@ +package com.decathlon.idp_core.domain.model.entity_graph; + +/// Internal flow direction states used during graph traversal. +/// +/// Maps to relationship directions (outbound vs inbound) during BFS propagation. +/// This enum is used internally by [EntityGraphHelper] to track traversal flow +/// and should not be exposed in public APIs. +public enum FlowDirection { + /// Follow outbound relationships only (downstream dependencies) + OUTBOUND, + /// Follow inbound relationships only (upstream dependents) + INBOUND, + /// Follow both outbound and inbound relationships + ANY +} diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java index 842b8d3..ea84716 100644 --- a/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java @@ -1,5 +1,6 @@ package com.decathlon.idp_core.domain.port; +import java.util.Collection; import java.util.Map; import java.util.UUID; @@ -50,7 +51,7 @@ public interface EntityGraphRepositoryPort { /// @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, + Map findEntityGraph(Collection rootIds, int depth, boolean includeProperties, EntityGraphTraversalMode mode); } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphHelper.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphHelper.java new file mode 100644 index 0000000..103a460 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphHelper.java @@ -0,0 +1,455 @@ +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 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.model.entity_graph.FlowDirection; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Service +public class EntityGraphHelper { + + /// Bulk graph-building logic. Avoid self-proxy transactional calls. + /// + /// **Design decision:** Extracted as a private non-transactional helper so + /// callers that already own a transaction can reuse the logic directly without + /// triggering a new transaction via the Spring proxy. + /// + /// @param entityIds UUIDs of the root entities to build graphs for + /// @param includeProperties whether to include property data in the nodes + /// @param relationFilter relation names to include; empty means all + /// @param propertyFilter property names to include; empty means all + /// @param mode traversal direction (OUTBOUND, BIDIRECTIONAL, etc.) + /// @param depth traversal depth + /// @return map of entity identifier to its fully resolved EntityGraphNode + @SuppressWarnings("null") + public Map buildGraphNodesForEntityIds(Map entityGraphs, + boolean includeProperties, Set relationFilter, Set propertyFilter, + EntityGraphTraversalMode mode, int depth) { + + 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, depth, 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(entry.getKey(), node); + } + } + + return result; + } + + /// Traces an in-memory reachable footprint starting from a root entity, + /// replicating recursive CTE logic. + /// + /// This executes a breadth-first search (BFS) level-by-level propagation to + /// find + /// all reachable nodes, ensuring that the in-memory tree building only + /// processes + /// nodes connected to the root. + /// + /// @param rootId the starting entity UUID + /// @param entityMap the raw loaded entity registry + /// @param globalIndices fast lookup maps containing keys and inbound relations + /// @param maxDepth depth limit for propagation + /// @param mode the active traversal mode + /// @return a set of UUIDs belonging to the reachable footprint + @SuppressWarnings("null") + private Set computeReachableSubGraph(UUID rootId, Map entityMap, + IndexBundle globalIndices, int maxDepth, EntityGraphTraversalMode mode) { + + Set visited = new HashSet<>(); + Set currentLevel = initializeAnchorStates(rootId, mode, visited); + + // Level-by-level propagation matching depth limits + for (int d = 0; d < maxDepth; d++) { + Set nextLevel = expandCurrentLevel(currentLevel, entityMap, globalIndices, + visited); + if (nextLevel.isEmpty()) + break; + currentLevel = nextLevel; + } + + return visited.stream().map(ReachableState::id).collect(Collectors.toSet()); + } + + /// Establishes the initial anchor states for BFS propagation based on the + /// traversal mode. + /// + /// Mimics the anchor member execution of a recursive SQL query. + /// + /// @param rootId the UUID of the starting entity + /// @param mode the active traversal direction mode + /// @param visited the tracking set to record visited states + /// @return the initialized set of states for the first level of BFS propagation + private Set initializeAnchorStates(UUID rootId, EntityGraphTraversalMode mode, + Set visited) { + Set currentLevel = new HashSet<>(); + + if (mode == EntityGraphTraversalMode.OUTBOUND_ONLY + || mode == EntityGraphTraversalMode.DIRECT_LINEAGE) { + ReachableState anchor = new ReachableState(rootId, FlowDirection.OUTBOUND); + visited.add(anchor); + currentLevel.add(anchor); + } + if (mode == EntityGraphTraversalMode.DIRECT_LINEAGE) { + ReachableState anchor = new ReachableState(rootId, FlowDirection.INBOUND); + if (visited.add(anchor)) { + currentLevel.add(anchor); + } + } + if (mode == EntityGraphTraversalMode.BIDIRECTIONAL) { + ReachableState anchor = new ReachableState(rootId, FlowDirection.ANY); + visited.add(anchor); + currentLevel.add(anchor); + } + + return currentLevel; + } + + /// Propagates outbound and inbound relationship lines for all nodes in the + /// current BFS layer. + /// + /// @param currentLevel active states to expand + /// @param entityMap raw mapping database records + /// @param globalIndices pre-computed reverse mapping lookups + /// @param visited tracking set to avoid re-evaluating visited states + /// @return the set of state steps detected for the next depth level + private Set expandCurrentLevel(Set currentLevel, + Map entityMap, IndexBundle globalIndices, Set visited) { + Set nextLevel = new HashSet<>(); + + for (ReachableState state : currentLevel) { + Entity entity = entityMap.get(state.id()); + if (entity == null) + continue; + + propagateOutboundPaths(state, entity, entityMap, globalIndices, visited, nextLevel); + propagateInboundPaths(state, entity, entityMap, globalIndices, visited, nextLevel); + } + + return nextLevel; + } + + /// Discovers and queues adjacent downstream targets connected through outbound + /// relations. + /// + /// Only executes if the current state allows outbound flows (`OUTBOUND` or + /// `ANY`). + /// + /// @param state the current traversal position and flow direction + /// @param entity the model metadata of the current node + /// @param entityMap raw target lookup reference + /// @param globalIndices lookup indices to resolve template names + /// @param visited state memory to prevent circular steps + /// @param nextLevel output set to collect newly discovered states + private void propagateOutboundPaths(ReachableState state, Entity entity, + Map entityMap, IndexBundle globalIndices, Set visited, + Set nextLevel) { + if (!FlowDirection.OUTBOUND.equals(state.flow()) && !FlowDirection.ANY.equals(state.flow())) { + return; + } + + 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, FlowDirection.OUTBOUND); + if (visited.add(nextState)) { + nextLevel.add(nextState); + } + } + } + } + } + + /// Discovers and queues adjacent upstream sources connected through inbound + /// relations. + /// + /// Resolves incoming references via the pre-built global inbound index. Only + /// executes if the current state allows inbound flows (`INBOUND` or `ANY`). + /// + /// @param state the current traversal position and flow direction + /// @param entity the model metadata of the current node + /// @param entityMap raw target lookup reference + /// @param globalIndices lookup indices to resolve incoming references + /// @param visited state memory to prevent circular steps + /// @param nextLevel output set to collect newly discovered states + private void propagateInboundPaths(ReachableState state, Entity entity, + Map entityMap, IndexBundle globalIndices, Set visited, + Set nextLevel) { + if (!FlowDirection.INBOUND.equals(state.flow()) && !FlowDirection.ANY.equals(state.flow())) { + return; + } + + 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, FlowDirection.INBOUND); + if (visited.add(nextState)) { + nextLevel.add(nextState); + } + } + } + } + } + + /// Recursively constructs an individual [EntityGraphNode] using a Depth-First + /// Search (DFS) strategy. + /// + /// This method implements two critical runtime protections: + /// + /// 1. **Cycle Detection (Active Stack):** If a node is currently in + /// `activeStack`, a circular relationship loop (e.g., A -> B -> A) has been + /// met. It returns a flat stub to prevent infinite stack overflow. + /// 2. **Path Memoization (Cache Check):** If a node has already been built + /// along + /// a different path, its pre-built reference is returned instantly from + /// `memoCache`, eliminating exponential $O(2^{\text{depth}})$ path traversal. + /// + /// @param entityUuid the UUID of the target entity to resolve + /// @param ctx the active traversal configuration, indices, processing + /// stack, and memoization cache + /// @return a fully populated, nested [EntityGraphNode] tree representing the + /// entity and its branches + 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()); + } + + // GUARD 1: If the node is currently in active processing up our current line, + // we hit a cyclic loop closure. Break instantly with a stub to prevent infinite + // stack overflow. + if (ctx.activeStack().contains(entityUuid)) { + List stubProperties = resolveProperties(entity, ctx.includeProperties(), + ctx.propertyFilter()); + return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), + stubProperties, List.of(), List.of()); + } + + // GUARD 2: MEMOIZATION CHECK. If this node has already been built down an + // alternate path, return the pre-existing reference instantly. + // This prevents the exponential path explosion. + if (ctx.memoCache().containsKey(entityUuid)) { + return ctx.memoCache().get(entityUuid); + } + + // Push to active processing stack + ctx.activeStack().add(entityUuid); + + // Process outbound relationships + 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(); + + // Process inbound relationships + List inboundRelations = buildRelationsAsTargetFromIndex( + entity.identifier(), ctx); + + List properties = resolveProperties(entity, ctx.includeProperties(), + ctx.propertyFilter()); + + // Assemble the complete node object + EntityGraphNode completedNode = new EntityGraphNode(entity.templateIdentifier(), + entity.identifier(), entity.name(), properties, outboundRelations, inboundRelations); + + // Save to Cache and pop from active stack before returning + ctx.memoCache().put(entityUuid, completedNode); + ctx.activeStack().remove(entityUuid); + + return completedNode; + } + + /// Looks up and builds nested inbound relationships where the current entity + /// acts as a target. + /// + /// This is only evaluated if the current mode supports incoming relationships + /// (`BIDIRECTIONAL` or `DIRECT_LINEAGE`). Uses the pre-compiled localized + /// inbound map to achieve fast O(1) lookups. + /// + /// @param targetIdentifier the business identifier of the entity acting as a + /// relationship target + /// @param ctx the active traversal context + /// @return a list of inbound [EntityGraphRelation] nodes pointing to this + /// target + private List buildRelationsAsTargetFromIndex(String targetIdentifier, + GraphTraversalContext ctx) { + // Include inbound relations for BIDIRECTIONAL and DIRECT_LINEAGE modes + if (ctx.mode() != EntityGraphTraversalMode.BIDIRECTIONAL + && ctx.mode() != EntityGraphTraversalMode.DIRECT_LINEAGE) { + return List.of(); + } + + 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(); + } + + /// Indexes the raw entity map to support fast O(1) lookups during graph + /// resolution. + /// + /// Generates standard composite key lookups (Template + Business Identifier -> + /// UUID) and registers inbound links if directionality configurations permit. + /// + /// @param entityMap flat database records to index + /// @param mode the active traversal direction mode + /// @return an [IndexBundle] containing text-to-UUID and inbound mapping indexes + public 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); + + // Build inbound index for BIDIRECTIONAL and DIRECT_LINEAGE modes + if (mode == EntityGraphTraversalMode.BIDIRECTIONAL + || mode == EntityGraphTraversalMode.DIRECT_LINEAGE) { + buildInboundIndexForEntity(entity, sourceUuid, inboundIndex); + } + } + return new IndexBundle(textToUuidLookup, inboundIndex); + } + + /// Populates the inbound reference index for a specific entity's outgoing + /// relations. + /// + /// Inverts relationship mapping so that target entities can look up their + /// source + /// origins in O(1) time. + /// + /// @param entity the source entity whose outgoing relations are scanned + /// @param sourceUuid the database UUID of the source entity + /// @param inboundIndex the active reverse lookup index under construction + 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); + } + } + } + + /// Resolves and filters an entity's properties based on the request settings. + /// + /// @param entity the source entity record + /// @param includeProperties whether to fetch property fields + /// @param propertyFilter set of allowed property names (empty = allow all) + /// @return a list of filtered [Property] records + 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, FlowDirection flow) { + } + + 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, // High-speed in-memory reuse cache + 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/domain/service/entity_graph/EntityGraphService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java index 8aec14f..100d896 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java @@ -1,35 +1,45 @@ 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 org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; 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.exception.entity_template.EntityTemplateNotFoundException; 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.EntityFilter; 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.EntityService; 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. +/// Resolves outbound and inbound relations, for a given list of entities, +/// recursively up to a configurable depth, returning a tree of +/// [EntityGraphNode] records containing summary information for each connected +/// entity. +/// +/// Phase 1 - Gathering the Raw information (DB & Footprint): Gets a list of all +/// possibly related entities from the database within a single transaction. +/// Phase 2 - Reachability Pruning (Breadth-First Propagation): It computes a +/// precise reachable footprint to ensure we only traverse nodes that actually +/// connect to our root in the selected direction. Phase 3 - Recursive +/// Construction (DFS with Safety Nets): The helper runs a depth-first traversal +/// starting at the root entity to build the nested EntityGraphNode tree. It +/// prevents stack overflows from circular references and avoid re-evaluating the +/// same nodes via different paths. /// /// **Business purpose:** /// - Visualizing entity dependency graphs in the catalog UI @@ -48,15 +58,42 @@ /// 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; + private final EntityService entityService; + private final EntityGraphRepositoryPort entityGraphRepositoryPort; + private final EntityGraphHelper entityGraphHelper; + + private static final int MAX_DEPTH = 6; + + /// Resolves a depth-limited, fully populated relationship graph for a single + /// root entity. + /// + /// This method validates template existence, fetches the raw relational map in + /// a + /// single optimized DB query, and delegates the recursive tree building to the + /// [EntityGraphHelper]. + /// + /// @param templateIdentifier the template to find the root entity under (e.g., + /// "service") + /// @param entityIdentifier the unique business identifier of the entity + /// @param depth the requested depth of relationship traversal + /// @param includeProperties whether to fetch property fields for the nodes + /// @param relationFilter a set of relation names to restrict the traversal + /// to (empty means all) + /// @param propertyFilter a set of property names to restrict the properties + /// to (empty means all) + /// @param mode the traversal mode (e.g., `OUTBOUND_ONLY`, + /// `BIDIRECTIONAL`) + /// @return the resolved root [EntityGraphNode] with its relationship + /// tree populated @Transactional(readOnly = true) public EntityGraphNode getEntityGraph(String templateIdentifier, String entityIdentifier, @@ -64,6 +101,7 @@ public EntityGraphNode getEntityGraph(String templateIdentifier, String entityId EntityGraphTraversalMode mode) { int effectiveDepth = Math.clamp(depth, 1, MAX_DEPTH); + entityTemplateValidationService.validateTemplateExists(templateIdentifier); // 1. Resolve root entity @@ -72,7 +110,7 @@ public EntityGraphNode getEntityGraph(String templateIdentifier, String entityId .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); // 2. Load the graph footprint via optimized DB calls - Map entityMap = entityGraphRepositoryPort.findEntityGraph(rootEntity.id(), + Map entityMap = entityGraphRepositoryPort.findEntityGraph(Set.of(rootEntity.id()), effectiveDepth, includeProperties, mode); if (entityMap == null || entityMap.isEmpty()) { @@ -80,176 +118,65 @@ public EntityGraphNode getEntityGraph(String templateIdentifier, String entityId rootEntity.name(), List.of(), List.of(), List.of()); } - // 3. Pre-computation Layer - IndexBundle indices = buildIndices(entityMap, mode); - - // Context tracking for this execution tree - Set activeStack = new HashSet<>(); - Map memoCache = new HashMap<>(); + // 3. Delegate to helper for graph building + Map graphNodes = entityGraphHelper.buildGraphNodesForEntityIds(entityMap, + includeProperties, relationFilter, propertyFilter, mode, effectiveDepth); - GraphTraversalContext ctx = new GraphTraversalContext(entityMap, indices.textToUuidLookup(), - indices.inboundIndex(), includeProperties, propertyFilter, relationFilter, activeStack, - memoCache, mode); - - // 4. Trigger recursive tree mapping (O(N) performance, heap-safe) - return buildGraphNode(rootEntity.id(), ctx); + return graphNodes.getOrDefault(rootEntity.id(), + new EntityGraphNode(rootEntity.templateIdentifier(), rootEntity.identifier(), + rootEntity.name(), List.of(), List.of(), List.of())); } - 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(); - - // GUARD 1: If the node is currently in active processing up our current line, - // we hit a cyclic loop closure. Break instantly with a stub to prevent infinite - // stack overflow. - if (ctx.activeStack().contains(nodeId)) { - List stubProperties = resolveProperties(entity, ctx.includeProperties(), - ctx.propertyFilter()); - return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), - stubProperties, List.of(), List.of()); - } - - // GUARD 2: MEMOIZATION CHECK. If this node has already been built down an - // alternate path, - // return the pre-existing reference instantly. This prevents the exponential - // path explosion. - if (ctx.memoCache().containsKey(nodeId)) { - return ctx.memoCache().get(nodeId); - } - - // Push to active processing stack - ctx.activeStack().add(nodeId); - - // Process outbound relationships - List outboundRelations = entity.relations().stream() - .filter(relation -> ctx.relationFilter().isEmpty() - || ctx.relationFilter().contains(relation.name())) - .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(); - - // Process inbound relationships - List inboundRelations = buildRelationsAsTargetFromIndex( - entity.identifier(), ctx); - - List properties = resolveProperties(entity, ctx.includeProperties(), - ctx.propertyFilter()); - - // Assemble the complete node object - EntityGraphNode completedNode = new EntityGraphNode(entity.templateIdentifier(), - entity.identifier(), entity.name(), properties, outboundRelations, inboundRelations); - - // Save to Cache and pop from active stack before returning - ctx.memoCache().put(nodeId, completedNode); - ctx.activeStack().remove(nodeId); - - return completedNode; - } - - private List buildRelationsAsTargetFromIndex(String targetIdentifier, - GraphTraversalContext ctx) { - // Include inbound relations for BIDIRECTIONAL and DIRECT_LINEAGE modes - if (ctx.mode() != EntityGraphTraversalMode.BIDIRECTIONAL - && ctx.mode() != EntityGraphTraversalMode.DIRECT_LINEAGE) { - return List.of(); - } - - 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); + /// Retrieves a paginated list of entity graphs for a given template in a single + /// transaction. + /// + /// **Performance contract:** All queries — pagination, outbound and inbound + /// relation lookups — execute within the same `@Transactional(readOnly = true)` + /// boundary, avoiding split-transaction inconsistencies and N+1 problems. + /// + /// **Business purpose:** Provides complete bidirectional relationship context + /// for paginated entity list responses, so the infrastructure mapper can + /// produce + /// unified `relations` DTOs without any further DB calls. + /// + /// @param pageable pagination and sorting configuration + /// @param templateIdentifier template to scope the entity list + /// @param entityFilter optional filter criteria; `null` means no filtering + /// @param depth the requested depth of relationship traversal + /// @return paginated graph nodes with outbound and inbound relations resolved + /// @throws EntityTemplateNotFoundException when the template does not exist + @SuppressWarnings("null") + @Transactional(readOnly = true) + public Page getEntityGraphPageByTemplate(Pageable pageable, + String templateIdentifier, EntityFilter entityFilter, int depth) { - // Build inbound index for BIDIRECTIONAL and DIRECT_LINEAGE modes - if (mode == EntityGraphTraversalMode.BIDIRECTIONAL - || mode == EntityGraphTraversalMode.DIRECT_LINEAGE) { - buildInboundIndexForEntity(entity, sourceUuid, inboundIndex); - } - } - return new IndexBundle(textToUuidLookup, inboundIndex); - } + // Fetch paginated entities — template existence is validated inside this call + Page entityPage = entityService.getEntitiesByTemplateIdentifier(pageable, + templateIdentifier, entityFilter); - 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); - } + if (entityPage.isEmpty()) { + // Return empty page preserving pagination metadata + return entityPage.map(entity -> new EntityGraphNode(entity.templateIdentifier(), + entity.identifier(), entity.name(), entity.properties(), List.of(), List.of())); } - } - 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(); - } + // Extract UUIDs for the batch graph call + var entityUuids = entityPage.getContent().stream().map(Entity::id).filter(Objects::nonNull) + .toList(); - private static record IndexBundle(Map textToUuidLookup, - Map>> inboundIndex) { - } + // Load entity graphs in batch (includes roots + neighbors for relation + // resolution) + Map entityGraphs = entityGraphRepositoryPort.findEntityGraph(entityUuids, depth, + true, EntityGraphTraversalMode.DIRECT_LINEAGE); - private static record GraphTraversalContext(Map entityMap, - Map textToUuidLookup, - Map>> inboundIndex, boolean includeProperties, - Set propertyFilter, Set relationFilter, Set activeStack, - Map memoCache, // High-speed in-memory reuse cache - EntityGraphTraversalMode mode) { - } + // Call the helper to build graph nodes + Map graphsByUuid = entityGraphHelper.buildGraphNodesForEntityIds( + entityGraphs, true, Set.of(), Set.of(), EntityGraphTraversalMode.DIRECT_LINEAGE, depth); - private static record EntityCompositeKey(String templateIdentifier, String identifier) { - public EntityCompositeKey { - templateIdentifier = templateIdentifier == null - ? "" - : templateIdentifier.trim().toLowerCase(); - identifier = identifier == null ? "" : identifier.trim().toLowerCase(); - } + // Map each Entity to its EntityGraphNode, falling back gracefully if missing + return entityPage.map(entity -> graphsByUuid.getOrDefault(entity.id(), + new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), + entity.properties(), List.of(), List.of()))); } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java index 6bdcc93..d4f7b0b 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 @@ -61,7 +61,7 @@ public class SwaggerDescription { public static final String ENDPOINT_GET_ENTITIES_PAGINATED_DESCRIPTION = "Retrieve a paginated list of entities with optional sorting"; public static final String ENDPOINT_GET_ENTITY_BY_IDENTIFIER_SUMMARY = "Get entity by entity template and identifier"; - public static final String ENDPOINT_GET_ENTITY_BY_IDENTIFIER_DESCRIPTION = "Retrieve a specific entity using its string identifier and its template identifier"; + public static final String ENDPOINT_GET_ENTITY_BY_IDENTIFIER_DESCRIPTION = "Retrieve a specific entity using its string identifier and its template identifier with configurable relationship graph traversal. Supports fetching both inbound and outbound relations up to a specified depth using direct lineage mode."; public static final String ENDPOINT_POST_ENTITY_SUMMARY = "Create a new entity"; public static final String ENDPOINT_POST_ENTITY_DESCRIPTION = "Create a new entity in the system with the provided information"; @@ -222,6 +222,8 @@ public class SwaggerDescription { // --- 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 RELATIONS_DEPTH_DESCRIPTION = "Maximum depth to traverse when collecting entity relations. Defaults to 1. Valid range: 1-6."; + public static final String RELATIONS_TO_DISPLAY_DESCRIPTION = "Comma-separated list of relation names to include in the response. When omitted, all relations are included."; public static final String ENDPOINT_GET_ENTITY_GRAPH_FLAT_SUMMARY = "Get entity relationship graph as flat nodes and edges"; public static final String ENDPOINT_GET_ENTITY_GRAPH_FLAT_DESCRIPTION = "Retrieves the entity relationship graph as a flat nodes-and-edges structure, suitable for frontend visualization tools such as React Flow, Vis.js, and Cytoscape."; public static final String RESPONSE_ENTITY_GRAPH_FLAT_SUCCESS = "Flat entity graph successfully retrieved"; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java index 0eb19c6..61bab34 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 @@ -24,6 +24,8 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_QUERY_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_SIZE_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_SORT_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RELATIONS_DEPTH_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RELATIONS_TO_DISPLAY_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITIES_PAGINATED_SUCCESS; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_CONFLICT; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_CREATED; @@ -47,6 +49,7 @@ import static org.springframework.http.HttpStatus.OK; import java.util.List; +import java.util.Set; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; @@ -69,11 +72,14 @@ import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntityFilter; +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphTraversalMode; import com.decathlon.idp_core.domain.model.search.PaginatedResult; import com.decathlon.idp_core.domain.model.search.PaginationCriteria; import com.decathlon.idp_core.domain.model.search.RawSearchFilterNode; import com.decathlon.idp_core.domain.model.search.SearchFilterNode; import com.decathlon.idp_core.domain.service.entity.EntityService; +import com.decathlon.idp_core.domain.service.entity_graph.EntityGraphService; import com.decathlon.idp_core.domain.service.filter.EntityFilterDslParser; import com.decathlon.idp_core.domain.service.search.SearchFilterParser; import com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerConfiguration.EntityPageResponse; @@ -84,6 +90,7 @@ import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler; import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse; import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.EntityDtoInMapper; +import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.EntityDtoOutFromEntityNodeMapper; import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.EntityDtoOutMapper; import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.SearchFilterMapper; @@ -114,25 +121,27 @@ public class EntityController { private final EntityService entityService; private final EntityDtoOutMapper entityDtoOutMapper; private final EntityDtoInMapper entityDtoInMapper; + private final EntityDtoOutFromEntityNodeMapper entityDtoOutFromEntityNodeMapper; private final EntityFilterDslParser entityFilterDslParser; private final SearchFilterMapper searchFilterMapper; private final SearchFilterParser searchFilterParser; + private final EntityGraphService entityGraphService; /// 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`) /// @return paginated entity DTOs matching the template and optional filter + @SuppressWarnings("null") @Operation(summary = ENDPOINT_GET_ENTITIES_SUMMARY, description = ENDPOINT_GET_ENTITIES_PAGINATED_DESCRIPTION) @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITIES_PAGINATED_SUCCESS, content = @Content(schema = @Schema(implementation = EntityPageResponse.class))) @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_PAGINATION, content = { @@ -150,42 +159,62 @@ public Page getEntities(@RequestParam(defaultValue = "0") int page @RequestParam(required = false) String q) { Pageable pageable = PageRequest.of(page, size); EntityFilter filter = entityFilterDslParser.parse(q); - Page entities = entityService.getEntitiesByTemplateIdentifier(pageable, - templateIdentifier, filter); - return entityDtoOutMapper.fromEntitiesPageToDtoPage(entities, templateIdentifier); + // Single transaction: pagination + batch relation fetch in one DB round trip + Page graphNodes = entityGraphService.getEntityGraphPageByTemplate(pageable, + templateIdentifier, filter, 1); + return entityDtoOutFromEntityNodeMapper.toPageDto(graphNodes, templateIdentifier, 1); } - /// Retrieves a single entity by template and entity identifiers. + /// Retrieves a single entity by template and entity identifiers with + /// relationship graph. /// /// **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 with support for fetching related entities. Returns HTTP 404 if + /// either + /// template or entity doesn't exist, maintaining REST semantics. + /// + /// **Relationship traversal:** Uses direct lineage mode to traverse both + /// inbound + /// and outbound relations up to the specified depth. Relations are flattened + /// into + /// a single map at the root level, keyed by relation name. Can optionally + /// filter + /// which relations to include via `relations_to_display` query parameter. /// /// @param templateIdentifier business template identifier for entity scope /// @param entityIdentifier unique business identifier within template context + /// @param relationsDepth maximum depth to traverse when collecting relations + /// (1-6, defaults to 1) + /// @param relationsToDisplay optional set of relation names to include; omit to + /// include all /// @return entity DTO with full property and relationship data @Operation(summary = ENDPOINT_GET_ENTITY_BY_IDENTIFIER_SUMMARY, description = ENDPOINT_GET_ENTITY_BY_IDENTIFIER_DESCRIPTION) @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_FOUND, content = { @Content(schema = @Schema(implementation = EntityDtoOut.class))}) @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = { @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @Parameter(name = "relations_depth", description = RELATIONS_DEPTH_DESCRIPTION, in = ParameterIn.QUERY, schema = @Schema(type = "integer", defaultValue = "1")) + @Parameter(name = "relations_to_display", description = RELATIONS_TO_DISPLAY_DESCRIPTION, in = ParameterIn.QUERY, schema = @Schema(type = "array", example = "[\"depends-on\", \"relates-to\"]")) @GetMapping("/{templateIdentifier}/{entityIdentifier}") @ResponseStatus(OK) public EntityDtoOut getEntity(@PathVariable String templateIdentifier, - @PathVariable String entityIdentifier) { - Entity entity = entityService.getEntityByTemplateIdentifierAndIdentifier(templateIdentifier, - entityIdentifier); - return entityDtoOutMapper.fromEntity(entity); + @PathVariable String entityIdentifier, + @RequestParam(name = "relations_depth", required = false, defaultValue = "1") Integer relationsDepth, + @RequestParam(name = "relations_to_display", required = false) Set relationsToDisplay) { + + EntityGraphNode entityGraphNode = entityGraphService.getEntityGraph(templateIdentifier, + entityIdentifier, relationsDepth, true, + relationsToDisplay == null ? Set.of() : relationsToDisplay, Set.of(), + EntityGraphTraversalMode.DIRECT_LINEAGE); + return entityDtoOutFromEntityNodeMapper.toDto(entityGraphNode, templateIdentifier, + relationsDepth); } /// 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 +248,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 +284,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 @@ -285,11 +313,9 @@ public void deleteEntity(@NotBlank @PathVariable String templateIdentifier, /// 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/dto/out/entity/EntityDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityDtoOut.java index 13e4c6d..60027cc 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityDtoOut.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityDtoOut.java @@ -1,24 +1,35 @@ package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity; +import java.util.Collections; 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 +/// Unified Entity Data Transfer Object for API responses. +/// +/// Combines outbound relations (where this entity is the source) and inbound +/// relations (where this entity is the target) into a single relations map, +/// keyed by relation name. +/// +/// **Immutability:** All mutable fields are wrapped in unmodifiable collections +/// to prevent external modification through the DTO accessor methods. The compact +/// constructor ensures incoming maps are immediately wrapped before storage. @JsonNaming(SnakeCaseStrategy.class) -public class EntityDtoOut { - - private String templateIdentifier; - private String name; - private String identifier; - private Map properties; - private Map> relations; - private Map> relationsAsTarget; +public record EntityDtoOut(String identifier, String name, String templateIdentifier, + Map properties, Map> relations) { + /// Compact constructor that wraps mutable maps in unmodifiable copies. + /// + /// **Purpose:** Prevents external code from modifying the internal state + /// through the accessor methods. Each call to `properties()` or `relations()` + /// returns an unmodifiable view of the original map. + /// + /// **Performance note:** Unmodifiable wrappers have minimal overhead (single + /// wrapper object) and are preferred over defensive copying. + public EntityDtoOut { + properties = Collections.unmodifiableMap(properties != null ? properties : Map.of()); + relations = Collections.unmodifiableMap(relations != null ? relations : Map.of()); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntitySummaryDto.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntitySummaryDto.java index c7641dc..d8147d2 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntitySummaryDto.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntitySummaryDto.java @@ -3,15 +3,17 @@ import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; - -@Builder -@Data -@AllArgsConstructor +/** + * Represents a summary of an entity for use in collections and relations. + * + * Immutable record containing essential entity information: identifier, name, + * and template identifier for proper entity classification. Used in both entity + * listings and unified relation structures. + */ @JsonNaming(SnakeCaseStrategy.class) -public class EntitySummaryDto { - private String identifier; - private String name; +public record EntitySummaryDto(String identifier, + + String name, + + String templateIdentifier) { } 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 deleted file mode 100644 index b5a12ae..0000000 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/RelationAsTargetSummaryDtoOut.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity; - -import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; -import com.fasterxml.jackson.databind.annotation.JsonNaming; - -/// Output DTO representing an incoming relationship where the entity is the target. -@JsonNaming(SnakeCaseStrategy.class) -public record RelationAsTargetSummaryDtoOut(String targetEntityIdentifier, String relationName, - String sourceEntityIdentifier, String sourceEntityName) { -} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutFromEntityNodeMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutFromEntityNodeMapper.java new file mode 100644 index 0000000..64116c6 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutFromEntityNodeMapper.java @@ -0,0 +1,208 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Component; + +import com.decathlon.idp_core.domain.model.entity.Property; +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_template.EntityTemplate; +import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntitySummaryDto; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class EntityDtoOutFromEntityNodeMapper { + + private final EntityTemplateService entityTemplateService; + + /// Resolves and maps an `EntityGraphNode` tree to its flat output presentation. + /// + /// @param root the starting core node of the relational tree + /// hierarchy + /// @param entityTemplateIdentifier the operational template key for type + /// matching + /// @param maxDepth the strict maximum boundary layer limit + /// allowed for relationship collection + /// @return a fully populated, flat [EntityDtoOut] transfer structure + public EntityDtoOut toDto(EntityGraphNode root, String entityTemplateIdentifier, int maxDepth) { + if (root == null) { + return null; + } + + EntityTemplate entityTemplate = entityTemplateService + .getEntityTemplateByIdentifier(entityTemplateIdentifier); + + return toDto(root, entityTemplate, maxDepth); + } + + /// Internal orchestrator handling property conversion and deep + /// structural flattening. + private EntityDtoOut toDto(EntityGraphNode root, EntityTemplate entityTemplate, int maxDepth) { + if (root == null) { + return null; + } + + Map properties = mapPropertiesFromGraphNode(root, entityTemplate); + + var state = new TraversalState(root.identifier(), new HashMap<>(), new HashMap<>(), + new HashSet<>()); + + // Initiate sequence tracking from baseline layer 0 + traverse(root, 0, maxDepth, state); + + Map> finalizedRelations = state.relationsMap().entrySet() + .stream().collect(Collectors.toMap(Map.Entry::getKey, e -> new ArrayList<>(e.getValue()))); + + return new EntityDtoOut(root.identifier(), root.name(), root.templateIdentifier(), properties, + finalizedRelations); + } + + /// Overloaded fallback extracting the primary node index from a page container. + public EntityDtoOut toDto(Page page, EntityTemplate entityTemplate, + int maxDepth) { + if (page == null || page.getContent().isEmpty()) { + return null; + } + + return toDto(page.getContent().get(0), entityTemplate, maxDepth); + } + + /// Converts a paginated list of domain tree elements into an aligned page of + /// flattened output objects. + /// + /// @param graphNodesPage raw paginated collections returned by internal domain + /// services + /// @param entityTemplate the parent metadata template definition + /// @param maxDepth the maximum layer boundary permitted for extraction + /// @return a paginated series of flat [EntityDtoOut] summaries + public Page toPageDto(Page graphNodesPage, + String entityTemplateIdentifier, int maxDepth) { + if (graphNodesPage == null) { + return Page.empty(); + } + + EntityTemplate entityTemplate = entityTemplateService + .getEntityTemplateByIdentifier(entityTemplateIdentifier); + + return graphNodesPage.map(node -> this.toDto(node, entityTemplate, maxDepth)); + } + + /// Deep recursive traversal driver evaluating node safety matrix steps. This + /// method preserves low cognitive complexity by offloading nested structural + /// loop evaluation down to dedicated directional processing helpers. + private static void traverse(EntityGraphNode node, int currentDepth, int maxDepth, + TraversalState state) { + String nodeId = nodeId(node.templateIdentifier(), node.identifier()); + + // DEPTH-AWARE MATRIX CHECK: Blocks paths only if seen previously at an + // equal/closer depth level. + // This ensures shared nodes discovered at lower depths are allowed to expose + // their branches. + Integer minDepthSeen = state.visitedNodeDepths().get(nodeId); + if (minDepthSeen != null && currentDepth >= minDepthSeen) { + return; + } + state.visitedNodeDepths().put(nodeId, currentDepth); + + // Map outbound and inbound geometries via isolated sub-methods + processOutboundRelations(node, nodeId, currentDepth, maxDepth, state); + processInboundRelations(node, nodeId, currentDepth, maxDepth, state); + } + + /// Evaluates outbound dependency linkages originating from the targeted node + /// scope. + private static void processOutboundRelations(EntityGraphNode node, String nodeId, + int currentDepth, int maxDepth, TraversalState state) { + boolean shouldCollectRelations = currentDepth < maxDepth; + + for (EntityGraphRelation relation : node.relations()) { + for (EntityGraphNode target : relation.targets()) { + String targetId = nodeId(target.templateIdentifier(), target.identifier()); + String signature = nodeId + "|" + targetId + "|" + relation.name(); + + // Guard: Do not add the root entity as a dependency of itself + boolean isNotRoot = !target.identifier().equalsIgnoreCase(state.rootIdentifier()); + + if (shouldCollectRelations && isNotRoot && state.emittedEdgeSignatures().add(signature)) { + state.relationsMap().computeIfAbsent(relation.name(), k -> new LinkedHashSet<>()) + .add(new EntitySummaryDto(target.identifier(), target.name(), + target.templateIdentifier())); + } + + if (currentDepth < maxDepth) { + traverse(target, currentDepth + 1, maxDepth, state); + } + } + } + } + + /// Evaluates inbound backlink entries pointing towards the targeted node scope. + private static void processInboundRelations(EntityGraphNode node, String nodeId, int currentDepth, + int maxDepth, TraversalState state) { + boolean shouldCollectRelations = currentDepth < maxDepth; + + for (EntityGraphRelation relation : node.relationsAsTarget()) { + for (EntityGraphNode source : relation.targets()) { + String sourceId = nodeId(source.templateIdentifier(), source.identifier()); + String signature = sourceId + "|" + nodeId + "|" + relation.name(); + + // Guard: Do not add the root entity as a dependency of itself + boolean isNotRoot = !source.identifier().equalsIgnoreCase(state.rootIdentifier()); + + if (shouldCollectRelations && isNotRoot && state.emittedEdgeSignatures().add(signature)) { + state.relationsMap().computeIfAbsent(relation.name(), k -> new LinkedHashSet<>()) + .add(new EntitySummaryDto(source.identifier(), source.name(), + source.templateIdentifier())); + } + + if (currentDepth < maxDepth) { + traverse(source, currentDepth + 1, maxDepth, state); + } + } + } + } + + private static String nodeId(String templateIdentifier, String identifier) { + return templateIdentifier + ":" + identifier; + } + + /// Safely maps raw domain properties to typed presentation properties. + private Map mapPropertiesFromGraphNode(EntityGraphNode graphNode, + EntityTemplate template) { + if (graphNode.properties() == null || graphNode.properties().isEmpty()) { + return Collections.emptyMap(); + } + + Map definitions = template.propertiesDefinitions().stream() + .filter(Objects::nonNull).collect(Collectors.toMap(def -> def.name(), Function.identity())); + + return graphNode.properties().stream().filter(prop -> prop.value() != null) + .collect(Collectors.toMap(Property::name, + prop -> PropertyValueConverter.convert(prop, definitions.get(prop.name())))); + } + + /// State container capturing cross-layer tracking matrices during execution. + private record TraversalState(String rootIdentifier, + Map> relationsMap, Map visitedNodeDepths, + Set emittedEdgeSignatures) { + } +} 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 2928d7a..97c7146 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 @@ -2,6 +2,7 @@ import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -15,11 +16,9 @@ import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntitySummary; import com.decathlon.idp_core.domain.model.entity.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_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; @@ -70,52 +69,25 @@ public EntityDtoOut fromEntity(Entity entity) { return fromEntityUsingEntityTemplate(entity, entityTemplate); } - /// Maps paginated domain entities to API DTOs with optimized bulk operations. - /// - /// **Performance optimization:** Batches template resolution and relationship - /// lookups - /// to minimize database queries. Builds summary maps for efficient relationship - /// resolution across the entire page. - /// - /// @param entities paginated domain entities from repository layer - /// @param entityTemplateIdentifier template identifier for batch template - /// resolution - /// @return paginated API DTOs with complete relationship data - public Page fromEntitiesPageToDtoPage(Page entities, - String entityTemplateIdentifier) { - - Map pageEntitiesSummaries = buildRelatedEntitiesSummaryMapByPage( - entities); - Map> relationTargetOwnershipsMap = buildRelationsAsTargetSummaryMapByPage( - entities); - - EntityTemplate pageEntityTemplate = entityTemplateService - .getEntityTemplateByIdentifier(entityTemplateIdentifier); - return entities.map(entity -> fromEntityUsingEntityTemplateAndSummaryMap(entity, - pageEntityTemplate, pageEntitiesSummaries, relationTargetOwnershipsMap)); - } - /// Maps a single entity to its DTO using the provided entity template. /// /// @param entity the entity to map /// @param entityTemplate the template for property type mapping - /// @return the mapped DTO + /// @return the mapped DTO with unified relations private EntityDtoOut fromEntityUsingEntityTemplate(Entity entity, EntityTemplate entityTemplate) { Map props = mapPropertiesDto(entity, entityTemplate); List allTargetIdentifiers = getAllTargetIdentifiersFromEntityRelations(entity); Map relatedEntitiesSummaryMap = buildEntitiesSummariesMap( allTargetIdentifiers); - Map> relationMap = mapRelationsDto(entity, - relatedEntitiesSummaryMap); Map> relatedEntitiesByTargetSummaryMap = buildRelationsAsTargetSummaryMapByEntity( entity); - Map> relationAsTargetMap = mapRelationsAsTargetDto(entity, - relatedEntitiesByTargetSummaryMap); - return EntityDtoOut.builder().templateIdentifier(entity.templateIdentifier()) - .name(entity.name()).identifier(entity.identifier()).properties(props) - .relations(relationMap).relationsAsTarget(relationAsTargetMap).build(); + Map> unifiedRelations = buildUnifiedRelationsMap(entity, + relatedEntitiesSummaryMap, relatedEntitiesByTargetSummaryMap); + + return new EntityDtoOut(entity.identifier(), entity.name(), entity.templateIdentifier(), props, + unifiedRelations); } /// Maps a single entity to its DTO using pre-built summary and @@ -123,27 +95,25 @@ private EntityDtoOut fromEntityUsingEntityTemplate(Entity entity, EntityTemplate /// /// @param entity the entity to map /// @param entityTemplate the template for property type mapping - /// @param relatedEntitiesSummaries map of entity summaries for relation targets + /// @param relatedEntitiesSummaries map of entity summaries for relation + /// targets /// @param relationTargetOwnershipsMap map of relations-as-target for the entity - /// @return the mapped DTO + /// @return the mapped DTO with unified relations private EntityDtoOut fromEntityUsingEntityTemplateAndSummaryMap(Entity entity, EntityTemplate entityTemplate, Map relatedEntitiesSummaries, Map> relationTargetOwnershipsMap) { Map props = mapPropertiesDto(entity, entityTemplate); - Map> relationMap = mapRelationsDto(entity, - relatedEntitiesSummaries); - Map> relationAsTargetMap = mapRelationsAsTargetDto(entity, - relationTargetOwnershipsMap); - - return EntityDtoOut.builder().templateIdentifier(entity.templateIdentifier()) - .name(entity.name()).identifier(entity.identifier()).properties(props) - .relations(relationMap).relationsAsTarget(relationAsTargetMap).build(); + Map> unifiedRelations = buildUnifiedRelationsMap(entity, + relatedEntitiesSummaries, relationTargetOwnershipsMap); + + return new EntityDtoOut(entity.identifier(), entity.name(), entity.templateIdentifier(), props, + unifiedRelations); } /// Maps the properties of an entity to a map of property names to typed values, - /// using the entity template for type conversion. - /// Properties with a null value are excluded from the output. + /// using the entity template for type conversion. Properties with a null value + /// are excluded from the output. /// /// @param entity the entity whose properties to map /// @param entityTemplate the template for property type mapping @@ -158,77 +128,67 @@ private Map mapPropertiesDto(Entity entity, EntityTemplate entit return entity.properties().stream().filter(prop -> prop.value() != null) .collect(Collectors.toMap(Property::name, - prop -> convertPropertyValue(prop, propertiesDefinitions.get(prop.name())))); + prop -> PropertyValueConverter.convert(prop, propertiesDefinitions.get(prop.name())))); } - /// Converts a property value to its typed representation based on the property - /// definition. + /// Builds a unified relations map combining outbound and inbound relations. /// - /// @param property the property to convert - /// @param definition the property definition for type information, may be null - /// @return the typed value, falling back to the raw string value - private Object convertPropertyValue(Property property, PropertyDefinition definition) { - String value = property.value(); - if (definition == null) { - return value; - } - PropertyType type = definition.type(); - if (PropertyType.NUMBER.equals(type)) { - try { - return Double.valueOf(value); - } catch (NumberFormatException _) { - return value; - } - } else if (PropertyType.BOOLEAN.equals(type)) { - return Boolean.valueOf(value); - } - return value; - } - - /// Maps the relations of an entity to a map of relation names to lists of - /// target - /// entity summaries. - /// - /// @param entity the entity whose relations to map - /// @param relatedEntitiesSummaries map of entity summaries for relation targets - /// @return a map of relation names to lists of target entity summaries - private Map> mapRelationsDto(Entity entity, - Map relatedEntitiesSummaries) { - return entity.relations() == null - ? Collections.emptyMap() - : entity.relations().stream().collect(Collectors.groupingBy(Relation::name, - Collectors.flatMapping(rel -> rel.targetEntityIdentifiers().stream() - .map(relatedEntitiesSummaries::get).filter(Objects::nonNull), - Collectors.toList()))); - } - - /// Maps the relations-as-target for an entity to a map of relation names to - /// lists of source entity summaries. + /// **Unification logic:** + /// - Outbound relations: keyed by relation name, values are target summaries + /// - Inbound relations: keyed by relation name, values are source summaries + /// - Both directions merged under the same relation key /// - /// @param entity the entity whose relations-as-target to map - /// @param relationTargetOwnershipsMap map of relations-as-target for the entity - /// @return a map of relation names to lists of source entity summaries - private Map> mapRelationsAsTargetDto(Entity entity, + /// @param entity the entity whose relations to unify + /// @param relatedEntitiesSummaries map of target entity summaries (for + /// outbound) + /// @param relationTargetOwnershipsMap map of inbound relations + /// @return unified relations map with combined outbound and inbound relations + private Map> buildUnifiedRelationsMap(Entity entity, + Map relatedEntitiesSummaries, Map> relationTargetOwnershipsMap) { - List relationAsTargetSummaries = relationTargetOwnershipsMap + + HashMap> unifiedRelations = new HashMap<>(); + + // Add outbound relations (entity is source) + if (entity.relations() != null) { + entity.relations().forEach(relation -> { + var relationKey = relation.name(); + var targets = relation.targetEntityIdentifiers().stream().map(relatedEntitiesSummaries::get) + .filter(Objects::nonNull).toList(); + unifiedRelations.merge(relationKey, new ArrayList<>(targets), (existing, incoming) -> { + var merged = new ArrayList<>(existing); + merged.addAll(incoming); + return merged; + }); + }); + } + + // Add inbound relations (entity is target) — merge if key exists + List inboundRelations = relationTargetOwnershipsMap .get(entity.identifier()); - if (relationAsTargetSummaries == null) { - return Collections.emptyMap(); + if (inboundRelations != null) { + inboundRelations.forEach(inboundRelation -> { + String relationKey = inboundRelation.relationName(); + EntitySummaryDto source = new EntitySummaryDto(inboundRelation.sourceEntityIdentifier(), + inboundRelation.sourceEntityName(), inboundRelation.sourceTemplateIdentifier()); + + unifiedRelations.merge(relationKey, List.of(source), (existing, incoming) -> { + List merged = new ArrayList<>(existing); + merged.addAll(incoming); + return merged; + }); + }); } - return relationAsTargetSummaries.stream() - .collect(Collectors.groupingBy(RelationAsTargetSummary::relationName, - Collectors.mapping( - r -> new EntitySummaryDto(r.sourceEntityIdentifier(), r.sourceEntityName()), - Collectors.toList()))); + return unifiedRelations; } /// Builds a map of relation target ownerships for a page of entities, grouping /// by target entity identifier. /// /// @param entitiesPage the page of entities to analyze - /// @return a map from target entity identifier to list of relation-as-target - /// summaries + /// @return a map from target entity identifier to list of + /// relation-as-target summaries private Map> buildRelationsAsTargetSummaryMapByPage( Page entitiesPage) { if (entitiesPage == null || entitiesPage.getContent().isEmpty()) { @@ -246,8 +206,8 @@ private Map> buildRelationsAsTargetSummary /// target entity identifier. /// /// @param entity the entity to analyze - /// @return a map from target entity identifier to list of relation-as-target - /// summaries + /// @return a map from target entity identifier to list of + /// relation-as-target summaries private Map> buildRelationsAsTargetSummaryMapByEntity( Entity entity) { if (entity == null || entity.identifier() == null) { @@ -296,22 +256,25 @@ private Map buildRelatedEntitiesSummaryMapByPage( /// Builds a map of entity summaries for a list of target identifiers. /// + /// Includes template identifier for each entity, enabling frontend clients to + /// distinguish related entity types without additional queries. + /// /// @param targetIdentifiers the list of target entity identifiers - /// @return a map from entity identifier to summary DTO + /// @return a map from entity identifier to summary DTO with template info private Map buildEntitiesSummariesMap(List targetIdentifiers) { return targetIdentifiers.isEmpty() ? Collections.emptyMap() : entityService.getEntitiesSummariesByIdentifiers(targetIdentifiers).stream() .collect(Collectors.toMap(EntitySummary::identifier, - es -> new EntitySummaryDto(es.identifier(), es.name()))); + es -> new EntitySummaryDto(es.identifier(), es.name(), es.templateIdentifier()))); } /// Maps paginated search results to API DTOs with optimized bulk operations. /// /// **Performance optimization:** Batches template resolution across all - /// templates - /// referenced in the page — unlike [#fromEntitiesPageToDtoPage] which is scoped - /// to a single template, this method handles multi-template result sets. + /// templates referenced in the page — unlike [#fromEntitiesPageToDtoPage] which + /// is scoped to a single template, this method handles multi-template result + /// sets. /// /// @param entities paginated domain entities, possibly spanning several /// templates @@ -342,10 +305,10 @@ public Page fromEntitiesSearchPageToDtoPage(Page entities) private EntityDtoOut entityDtoOutMapper(Entity entity, Map summaries, Map> relationsAsTargetMap) { - return EntityDtoOut.builder().templateIdentifier(entity.templateIdentifier()) - .name(entity.name()).identifier(entity.identifier()).properties(Collections.emptyMap()) - .relations(mapRelationsDto(entity, summaries)) - .relationsAsTarget(mapRelationsAsTargetDto(entity, relationsAsTargetMap)).build(); + Map> unifiedRelations = buildUnifiedRelationsMap(entity, + summaries, relationsAsTargetMap); + return new EntityDtoOut(entity.identifier(), entity.name(), entity.templateIdentifier(), + Collections.emptyMap(), unifiedRelations); } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/PropertyValueConverter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/PropertyValueConverter.java new file mode 100644 index 0000000..4245146 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/PropertyValueConverter.java @@ -0,0 +1,56 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity; + +import com.decathlon.idp_core.domain.model.entity.Property; +import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; +import com.decathlon.idp_core.domain.model.enums.PropertyType; + +/// Utility for converting property values to their typed representations. +/// +/// **Purpose:** Centralized property type conversion logic used across multiple +/// infrastructure mappers to ensure consistent type handling and reduce code +/// duplication. +/// +/// **Design:** Stateless utility class with no side effects, suitable for use +/// across multiple adapters and mappers. +public final class PropertyValueConverter { + + private PropertyValueConverter() { + // Prevent instantiation of utility class + } + + /// Converts a property value to its typed representation based on the property + /// definition. + /// + /// **Type conversion strategy:** + /// - `NUMBER`: Attempts to parse as `Double`; falls back to string if invalid + /// - `BOOLEAN`: Parses using `Boolean.valueOf()` + /// - Other types: Returns the raw string value + /// + /// **Null safety:** If no definition is provided, returns the raw string value + /// as fallback for schema evolution tolerance. + /// + /// @param property the property to convert + /// @param definition the property definition for type information (may be null) + /// @return the typed value, or the raw string value if type conversion fails + public static Object convert(Property property, PropertyDefinition definition) { + String value = property.value(); + + if (definition == null) { + return value; + } + + PropertyType type = definition.type(); + + if (PropertyType.NUMBER.equals(type)) { + try { + return Double.valueOf(value); + } catch (NumberFormatException _) { + return value; + } + } else if (PropertyType.BOOLEAN.equals(type)) { + return Boolean.valueOf(value); + } + + return value; + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java index 0144157..ee67eda 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java @@ -1,5 +1,6 @@ package com.decathlon.idp_core.infrastructure.adapters.persistence; +import java.util.Collection; import java.util.List; import java.util.Map; import java.util.UUID; @@ -41,42 +42,85 @@ public class PostgresEntityGraphAdapter implements EntityGraphRepositoryPort { private static final Logger log = LoggerFactory.getLogger(PostgresEntityGraphAdapter.class); + /// Fetches a depth-limited entity relationship graph for one or more root + /// entities. + /// + /// **Purpose:** Implements the persistence contract for graph traversal by + /// executing + /// a recursive CTE query followed by targeted batch loads. This method bridges + /// the + /// Domain Service (which calls this port) with the database, handling all + /// technical + /// concerns: query execution, result materialization, and entity mapping. + /// + /// **Three-step strategy:** + /// 1. **Graph discovery**: Execute recursive CTE to find all reachable entity + /// UUIDs + /// within the depth limit, respecting the traversal mode (OUTBOUND, + /// BIDIRECTIONAL, etc.) + /// 2. **Batch entity load**: Fetch all discovered entities with their relations + /// in a + /// single query to avoid N+1 problems + /// 3. **Optional properties load**: If requested, load properties separately to + /// avoid + /// Hibernate's MultipleBagFetchException when combining multiple `@OneToMany` + /// collections + /// + /// **Why separate queries for properties?** + /// - Hibernate cannot safely join multiple collection-valued associations in a + /// single + /// query without cartesian products + /// - Properties are often not needed (e.g., list endpoints just show names) + /// - Splitting queries keeps payloads lean and avoids unnecessary data transfer + /// + /// @param rootIds the UUIDs of entities to use as traversal roots (single or + /// multiple) + /// @param depth maximum levels to traverse (clamped by domain layer to [1, + /// MAX_DEPTH]) + /// @param includeProperties whether to fetch property data for each entity + /// @param mode traversal direction (OUTBOUND_ONLY, BIDIRECTIONAL, + /// DIRECT_LINEAGE) + /// @return immutable map of all discovered entities keyed by UUID; empty map if + /// no + /// entities found or if input is null/empty + @SuppressWarnings("null") @Override @Transactional(readOnly = true) - public Map findEntityGraph(UUID entityId, int depth, boolean includeProperties, - EntityGraphTraversalMode mode) { + public Map findEntityGraph(Collection rootIds, 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 (rootIds == null || rootIds.isEmpty()) { + log.debug("[EntityGraphAdapter] Empty root IDs provided, returning empty map"); + return Map.of(); + } - if (graphPairs == null || graphPairs.isEmpty()) { + // Step 1: Collect all entity IDs in the graph via batch recursive CTE + // Works for both single and multiple roots + List graphIds = jpaEntityRepository.findEntityIdsInGraph(rootIds, depth, mode.name()); + + if (graphIds == null || graphIds.isEmpty()) { log.debug( - "[EntityGraphAdapter] No graph identifiers found (null or empty), returning empty map"); + "[EntityGraphAdapter] No graph identifiers found for roots (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 2: Extract unique identifiers for batch loading + List uniqueEntityIds = graphIds.stream().distinct().toList(); - // Step 3: batch-load entities with relations, then optionally properties in a + // 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); + List jpaEntities = jpaEntityRepository + .findAllByIdinWithRelations(uniqueEntityIds); if (includeProperties) { - jpaEntityRepository.findAllByIdInWithProperties(entitiesIds); + jpaEntityRepository.findAllByIdInWithProperties(uniqueEntityIds); } - // Step 4: map to domain and key by composite key for O(1) lookup - return jpaEntities.stream().map(mapper::toDomain) + // Step 4: Map to domain and key by UUID for O(1) lookup + return jpaEntities.stream().map(mapper::toDomain).filter(entity -> entity.id() != null) .collect(Collectors.toMap(Entity::id, Function.identity())); - } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java index f0baf61..72aa3f9 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 @@ -11,7 +11,6 @@ import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.history.RevisionRepository; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -22,8 +21,7 @@ public interface JpaEntityRepository extends JpaRepository, - JpaSpecificationExecutor, - RevisionRepository { + JpaSpecificationExecutor { @Query("SELECT e.identifier AS identifier, e.name AS name, e.templateIdentifier AS templateIdentifier FROM EntityJpaEntity e WHERE e.identifier IN :identifiers") List findByIdentifierIn(List identifiers); @@ -55,24 +53,173 @@ Optional findByTemplateIdentifierAndIdentifier(String templateI @Query("SELECT DISTINCT e FROM EntityJpaEntity e LEFT JOIN FETCH e.properties WHERE e.id IN :ids") List findAllByIdInWithProperties(@Param("ids") Collection ids); + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + DELETE FROM PropertyJpaEntity p + WHERE p IN ( + SELECT p2 FROM EntityJpaEntity e JOIN e.properties p2 + WHERE e.templateIdentifier = :templateIdentifier + AND p2.name IN :propertyNames + ) + """) + void deletePropertiesByTemplateIdentifierAndPropertyName( + @Param("templateIdentifier") String templateIdentifier, + @Param("propertyNames") Collection propertyNames); + + /// Deletes all relations of specific types associated with a template. + /// + /// **Purpose:** Removes relations by name for all entities of a given template. + /// This is useful during template updates when certain relation types need to + /// be + /// cleaned up before reloading. + /// + /// **Design:** Uses a nested query to identify relations first, then removes + /// them. + /// The `@Modifying` annotation automatically clears the persistence context and + /// flushes changes to the database. + /// + /// @param templateIdentifier the template identifier to filter entities + /// @param relationNames collection of relation names to delete + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + DELETE FROM RelationJpaEntity r + WHERE r IN ( + SELECT r2 FROM EntityJpaEntity e JOIN e.relations r2 + WHERE e.templateIdentifier = :templateIdentifier + AND r2.name IN :relationNames + ) + """) + void deleteRelationsByTemplateIdentifierAndRelationName( + @Param("templateIdentifier") String templateIdentifier, + @Param("relationNames") Collection relationNames); + + /// Deletes an entity and all its associated relations by template and + /// identifier. + /// + /// **Purpose:** Removes a single entity record from the database along with all + /// relations it participates in (both as source and target via cascading + /// delete). + /// + /// **Design:** Uses the composite key (templateIdentifier, identifier) to + /// uniquely + /// identify the entity, matching the domain model's identity semantics. + /// + /// @param templateIdentifier the template identifier of the entity + /// @param entityIdentifier the identifier of the entity within its template + void deleteByTemplateIdentifierAndIdentifier( + @Param("templateIdentifier") String templateIdentifier, + @Param("entityIdentifier") String entityIdentifier); + + /// Finds all entities with a specific template identifier and identifiers. + /// + /// **Purpose:** Batch lookup of multiple entities within a template using their + /// identifiers. Used during graph traversal to fetch entities in bulk. + /// + /// **Design:** Leverages the composite key (templateIdentifier, identifier) for + /// efficient filtering. Reduces N+1 queries by loading multiple entities in one + /// call. + /// + /// @param templateIdentifier the template identifier to filter by + /// @param identifiers collection of entity identifiers to retrieve + /// @return list of matching entities, or empty list if none found + List findAllByTemplateIdentifierAndIdentifierIn(String templateIdentifier, + List identifiers); + + /// Finds all entities that have relations pointing to a target entity. + /// + /// **Purpose:** Discovers inbound relationships—entities that reference the + /// given + /// target identifier. Essential for bidirectional graph traversal to find + /// dependents. + /// + /// **Design:** Uses a native SQL query for complex join logic across the + /// relation + /// hierarchy (entity → entity_relations → relation_target_entities → entity + /// target). + /// This provides better control over the join strategy than JPQL for this + /// specific + /// multi-level traversal. + /// + /// **Performance:** Avoids N+1 by using a single join query. The DISTINCT + /// clause + /// eliminates duplicates when multiple relations point to the same target. + /// + /// @param targetIdentifier the identifier of the target entity to find + /// referrers for + /// @return list of entities that have relations to the target, or empty list if + /// none + @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); + + /// Discovers all entity UUIDs reachable from root entities within a depth + /// limit. + /// + /// **Purpose:** Executes a recursive Common Table Expression (CTE) to find all + /// entities connected to the root(s) via relationships, respecting depth and + /// traversal mode constraints. Returns only UUIDs for efficient bulk loading. + /// + /// **Algorithm:** Breadth-First Search using recursive SQL CTE with three + /// phases: + /// + /// 1. **Anchor Member**: Initializes state tokens (UUID, depth, flow direction) + /// for + /// root entities. Flow direction matches the traversal mode: + /// - `OUTBOUND_ONLY`: Only OUTBOUND flow + /// - `DIRECT_LINEAGE`: Both OUTBOUND and INBOUND (separate tokens per root) + /// - `BIDIRECTIONAL`: Only ANY flow (follows both directions) + /// + /// 2. **Recursive Member**: Propagates state tokens through the graph: + /// - Matches flow direction (e.g., OUTBOUND token only follows outbound edges) + /// - Stops when depth limit is reached + /// - Respects flow semantics to prevent invalid path combinations + /// + /// 3. **Final Select**: Returns distinct UUIDs of all discovered nodes + /// + /// **Why state tokens?** The flow direction (OUTBOUND/INBOUND/ANY) is stored in + /// the recursive state to enforce traversal rules. For example, in + /// DIRECT_LINEAGE: + /// - One OUTBOUND token flows downstream from root + /// - One INBOUND token flows upstream from root + /// - They never cross over (outbound token cannot follow inbound edge) + /// + /// **Performance:** Returns only UUIDs (no entity hydration). Bulk entity + /// loading + /// happens in a separate query to avoid N+1 problems. The depth limit prevents + /// unbounded traversal in cyclic graphs. + /// + /// @param rootIds collection of root entity UUIDs (single or multiple) + /// @param depth maximum traversal depth (must be >= 1, typically clamped by + /// domain) + /// @param mode traversal mode as string ('OUTBOUND_ONLY', 'DIRECT_LINEAGE', + /// 'BIDIRECTIONAL') + /// @return list of all discovered entity UUIDs in the reachable subgraph, or + /// empty + /// list if no entities are reachable @Query(value = """ WITH RECURSIVE entity_graph(id, depth, flow) AS ( - -- 1. ANCHOR MEMBER: Initialize state tokens for a single root entity + -- 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 = :rootId AND :mode IN ('DIRECT_LINEAGE', 'OUTBOUND_ONLY') + 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 = :rootId AND :mode = 'DIRECT_LINEAGE' + 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 = :rootId AND :mode = 'BIDIRECTIONAL' + WHERE e.id IN :rootIds AND :mode = 'BIDIRECTIONAL' UNION @@ -111,52 +258,6 @@ WITH RECURSIVE entity_graph(id, depth, flow) AS ( -- 3. Return the clean deduplicated set of structural skeleton UUIDs SELECT DISTINCT id FROM entity_graph; """, nativeQuery = true) - List findEntityIdsInGraph(@Param("rootId") UUID rootId, @Param("depth") int depth, - @Param("mode") String mode); - - @Modifying(clearAutomatically = true, flushAutomatically = true) - @Query(""" - DELETE FROM PropertyJpaEntity p - WHERE p IN ( - SELECT p2 FROM EntityJpaEntity e JOIN e.properties p2 - WHERE e.templateIdentifier = :templateIdentifier - AND p2.name IN :propertyNames - ) - """) - void deletePropertiesByTemplateIdentifierAndPropertyName( - @Param("templateIdentifier") String templateIdentifier, - @Param("propertyNames") Collection propertyNames); - - @Modifying(clearAutomatically = true, flushAutomatically = true) - @Query(""" - DELETE FROM RelationJpaEntity r - WHERE r IN ( - SELECT r2 FROM EntityJpaEntity e JOIN e.relations r2 - WHERE e.templateIdentifier = :templateIdentifier - AND r2.name IN :relationNames - ) - """) - void deleteRelationsByTemplateIdentifierAndRelationName( - @Param("templateIdentifier") String templateIdentifier, - @Param("relationNames") Collection relationNames); - - 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); - + List findEntityIdsInGraph(@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 9a97c58..b5024e3 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 @@ -23,6 +23,9 @@ public interface JpaRelationRepository * a native query to efficiently join through the relation_target_entities * table. * + * Includes source entity template identifier for unified relations API + * response. + * * @param targetEntityIdentifiers * List of entity identifiers to search for * @return List of relation summaries where these entities are targets @@ -32,7 +35,8 @@ public interface JpaRelationRepository rte.target_entity_identifier AS targetEntityIdentifier, r.name AS relationName, e.identifier AS sourceEntityIdentifier, - e.name AS sourceEntityName + e.name AS sourceEntityName, + e.template_identifier AS sourceTemplateIdentifier 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 diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 4a3f8b2..b59e769 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -34,8 +34,8 @@ spring: jwt: jwk-set-uri: http://localhost:8080/auth/.well-known/jwks.json app: - security: - mock-enabled: true + # security: + # mock-enabled: true full-refresh-at-startup: true idp-core-prefix-url: http://localhost:8084 logging: diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java index 350e3aa..8241565 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java @@ -22,6 +22,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; @@ -29,7 +30,6 @@ 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; @@ -48,15 +48,12 @@ class EntityGraphServiceTest { @Mock private EntityTemplateValidationService entityTemplateValidationService; + @Spy + private EntityGraphHelper entityGraphHelper = new EntityGraphHelper(); + @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()); @@ -78,7 +75,7 @@ private Relation relation(String name, String targetTemplateIdentifier, String.. /// 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(), + when(entityGraphRepositoryPort.findEntityGraph(any(), anyInt(), anyBoolean(), any(EntityGraphTraversalMode.class))).thenReturn(entityMap); } @@ -105,8 +102,8 @@ void shouldThrowWhenRootEntityNotFound() { assertThatThrownBy(this::callGetEntityGraphForMissing) .isInstanceOf(EntityNotFoundException.class); - verify(entityGraphRepositoryPort, never()).findEntityGraph(any(UUID.class), anyInt(), - anyBoolean(), any(EntityGraphTraversalMode.class)); + verify(entityGraphRepositoryPort, never()).findEntityGraph(any(), anyInt(), anyBoolean(), + any(EntityGraphTraversalMode.class)); } private void callGetEntityGraphForMissing() { @@ -223,7 +220,7 @@ void shouldClampDepthBelowOne() { entityGraphService.getEntityGraph(TEMPLATE, "api", 0, false, Set.of(), Set.of(), EntityGraphTraversalMode.BIDIRECTIONAL); - verify(entityGraphRepositoryPort).findEntityGraph(api.id(), 1, false, + verify(entityGraphRepositoryPort).findEntityGraph(Set.of(api.id()), 1, false, EntityGraphTraversalMode.BIDIRECTIONAL); } @@ -238,7 +235,7 @@ void shouldClampDepthAboveTen() { entityGraphService.getEntityGraph(TEMPLATE, "api", 99, false, Set.of(), Set.of(), EntityGraphTraversalMode.BIDIRECTIONAL); - verify(entityGraphRepositoryPort).findEntityGraph(api.id(), 6, false, + verify(entityGraphRepositoryPort).findEntityGraph(Set.of(api.id()), 6, false, EntityGraphTraversalMode.BIDIRECTIONAL); } } @@ -298,7 +295,7 @@ void shouldResolveMultipleNamedRelations() { Set.of(), Set.of(), EntityGraphTraversalMode.BIDIRECTIONAL); assertThat(result.relations()).hasSize(2); - assertThat(result.relations().stream().map(EntityGraphRelation::name)) + assertThat(result.relations().stream().map(r -> r.name())) .containsExactlyInAnyOrder("uses-db", "depends-on"); } } @@ -344,7 +341,7 @@ void shouldReturnAllRelationsWhenFilterIsEmpty() { Set.of(), EntityGraphTraversalMode.BIDIRECTIONAL); assertThat(result.relations()).hasSize(2); - assertThat(result.relations().stream().map(EntityGraphRelation::name)) + assertThat(result.relations().stream().map(r -> r.name())) .containsExactlyInAnyOrder("depends-on", "owns"); } @@ -570,7 +567,7 @@ void directLineageModeShouldShowInboundAtRootAndOutboundDownstream() { // 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(); + .flatMap(rel -> rel.targets().stream()).map(node -> node.identifier()).toList(); assertThat(inboundIdentifiers).containsExactlyInAnyOrder("consumer", "backend"); // Root should have outbound relation: api -> postgres @@ -596,22 +593,19 @@ void modeShouldBePassedToRepositoryPort() { .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, + + var inOrder = org.mockito.Mockito.inOrder(entityGraphRepositoryPort); + inOrder.verify(entityGraphRepositoryPort).findEntityGraph(Set.of(api.id()), 1, false, + EntityGraphTraversalMode.OUTBOUND_ONLY); + inOrder.verify(entityGraphRepositoryPort).findEntityGraph(Set.of(api.id()), 1, false, + EntityGraphTraversalMode.DIRECT_LINEAGE); + inOrder.verify(entityGraphRepositoryPort).findEntityGraph(Set.of(api.id()), 1, false, EntityGraphTraversalMode.BIDIRECTIONAL); } @@ -641,7 +635,7 @@ void bidirectionalModeShouldTraverseFullGraphMultipleLevels() { // Inbound: consumer -> api, backend -> api assertThat(result.relationsAsTarget()).hasSize(2); var inboundIdentifiers = result.relationsAsTarget().stream() - .flatMap(rel -> rel.targets().stream()).map(EntityGraphNode::identifier).toList(); + .flatMap(rel -> rel.targets().stream()).map(node -> node.identifier()).toList(); assertThat(inboundIdentifiers).containsExactlyInAnyOrder("consumer", "backend"); } 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 c5c5eb2..8bcaad4 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java @@ -1,6 +1,8 @@ package com.decathlon.idp_core.infrastructure.adapters.api.controller; +import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.hasSize; import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; @@ -37,6 +39,7 @@ public class EntityControllerTest extends AbstractIntegrationTest { private static final String ENTITIES_BY_IDENTIFIER_PATH = "/api/v1/entities/{template-identifier}/{identifier}"; private static final String ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH = "/api/v1/entities/{template-identifier}"; private static final String ENTITY_JSON_FILES_TEST_PATH = "integration_test/json/entity/v1/"; + @Autowired private MockMvc mockMvc; @@ -55,8 +58,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(5)) - .andExpect(jsonPath("$.page.total_elements").value(5)) + .andExpect(jsonPath("$.content.length()").value(6)) + .andExpect(jsonPath("$.page.total_elements").value(6)) .andExpect(jsonPath("$.page.total_pages").value(1)) .andExpect(jsonPath("$.page.size").value(15)) .andExpect(jsonPath("$.page.number").value(0)) @@ -114,8 +117,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(5)) - .andExpect(jsonPath("$.page.total_elements").value(5)) + .andExpect(jsonPath("$.content.length()").value(6)) + .andExpect(jsonPath("$.page.total_elements").value(6)) .andExpect(jsonPath("$.page.total_pages").value(1)) .andExpect(jsonPath("$.page.size").value(20)) .andExpect(jsonPath("$.page.number").value(0)) @@ -266,8 +269,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(5)) - .andExpect(jsonPath("$.page.total_elements").value(5)); + .andExpect(status().isOk()).andExpect(jsonPath("$.content.length()").value(6)) + .andExpect(jsonPath("$.page.total_elements").value(6)); } @Test @@ -380,6 +383,88 @@ void getEntityByTemplateAndIdentifier_404_non_existent_template() throws Excepti .accept(APPLICATION_JSON)) .andExpect(status().isNotFound()); } + + @Test + @DisplayName("Should return only depth-1 relations when relations_depth=1") + @WithMockUser + void getEntity_relationsDepth_1() throws Exception { + mockMvc + .perform(get(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, "graph-svc-a") + .param("relations_depth", "1").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(jsonPath("$.identifier").value("graph-svc-a")) + .andExpect(jsonPath("$.relations.uses", hasSize(1))) + .andExpect(jsonPath("$.relations.uses[0].identifier").value("graph-svc-b")) + .andExpect(jsonPath("$.relations.monitors", hasSize(1))) + .andExpect(jsonPath("$.relations.monitors[0].identifier").value("graph-svc-b")); + } + + @Test + @DisplayName("Should return flattened depth-2 relations (a -> b -> c) when relations_depth=2") + @WithMockUser + void getEntity_relationsDepth_2() throws Exception { + mockMvc + .perform(get(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, "graph-svc-a") + .param("relations_depth", "2").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(jsonPath("$.identifier").value("graph-svc-a")) + .andExpect(jsonPath("$.relations.uses", hasSize(2))) + .andExpect(jsonPath("$.relations.uses[*].identifier", + containsInAnyOrder("graph-svc-b", "graph-svc-c"))) + .andExpect(jsonPath("$.relations.monitors", hasSize(1))) + .andExpect(jsonPath("$.relations.monitors[0].identifier").value("graph-svc-b")); + } + + @Test + @DisplayName("Should return flattened depth-3 relations (a -> b -> c -> d) when relations_depth=3") + @WithMockUser + void getEntity_relationsDepth_3() throws Exception { + mockMvc + .perform(get(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, "graph-svc-a") + .param("relations_depth", "3").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(jsonPath("$.identifier").value("graph-svc-a")) + .andExpect(jsonPath("$.relations.uses", hasSize(3))) + .andExpect(jsonPath("$.relations.uses[*].identifier", + containsInAnyOrder("graph-svc-b", "graph-svc-c", "graph-svc-d"))); + } + + @Test + @DisplayName("Should filter out unlisted relations when relations_to_display is provided at depth 2") + @WithMockUser + void getEntity_relationsToDisplay_filterSingleRelation() throws Exception { + mockMvc + .perform(get(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, "graph-svc-a") + .param("relations_depth", "2").param("relations_to_display", "uses") + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(jsonPath("$.identifier").value("graph-svc-a")) + .andExpect(jsonPath("$.relations.uses", hasSize(2))) + .andExpect(jsonPath("$.relations.uses[*].identifier", + containsInAnyOrder("graph-svc-b", "graph-svc-c"))) + .andExpect(jsonPath("$.relations.monitors").doesNotExist()); + } + + @Test + @DisplayName("Should return empty relations map when relations_to_display matches no relations") + @WithMockUser + void getEntity_relationsToDisplay_nonExistentRelation() throws Exception { + mockMvc + .perform(get(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, "graph-svc-a") + .param("relations_depth", "2").param("relations_to_display", "non-existent-relation") + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(jsonPath("$.identifier").value("graph-svc-a")) + .andExpect(jsonPath("$.relations").isEmpty()); + } + + @Test + @DisplayName("Should return both specified relations when multiple relations_to_display are supplied") + @WithMockUser + void getEntity_relationsToDisplay_multipleRelations() throws Exception { + mockMvc + .perform(get(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, "graph-svc-a") + .param("relations_depth", "2").param("relations_to_display", "uses", "monitors") + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(jsonPath("$.identifier").value("graph-svc-a")) + .andExpect(jsonPath("$.relations.uses", hasSize(2))) + .andExpect(jsonPath("$.relations.monitors", hasSize(1))); + } } @Nested 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 96ef86a..c6cb6e3 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 @@ -94,9 +94,9 @@ 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) +INSERT INTO idp_core.relation_target_entities (relation_id, target_entity_identifier, target_entity_uuid) VALUES - ('bb000000-0000-0000-0000-000000000001', 'database-service-1'); + ('bb000000-0000-0000-0000-000000000001', 'database-service-1', '550e8400-e29b-41d4-a716-446655440107'); INSERT INTO idp_core.entity_relations (entity_id, relation_id) VALUES @@ -107,9 +107,9 @@ 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) +INSERT INTO idp_core.relation_target_entities (relation_id, target_entity_identifier, target_entity_uuid) VALUES - ('bb000000-0000-0000-0000-000000000002', 'cache-service-1'); + ('bb000000-0000-0000-0000-000000000002', 'cache-service-1', '550e8400-e29b-41d4-a716-446655440108'); INSERT INTO idp_core.entity_relations (entity_id, relation_id) VALUES @@ -120,9 +120,9 @@ 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) +INSERT INTO idp_core.relation_target_entities (relation_id, target_entity_identifier, target_entity_uuid) VALUES - ('bb000000-0000-0000-0000-000000000003', 'microservice-1'); + ('bb000000-0000-0000-0000-000000000003', 'microservice-1', '550e8400-e29b-41d4-a716-446655440102'); INSERT INTO idp_core.entity_relations (entity_id, relation_id) VALUES @@ -133,9 +133,9 @@ 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) +INSERT INTO idp_core.relation_target_entities (relation_id, target_entity_identifier, target_entity_uuid) VALUES - ('bb000000-0000-0000-0000-000000000006', 'test-team-required'); + ('bb000000-0000-0000-0000-000000000006', 'test-team-required', '550e8400-e29b-41d4-a716-446655440116'); INSERT INTO idp_core.entity_relations (entity_id, relation_id) VALUES 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 index bd93c9c..21eedf7 100644 --- 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 @@ -1,25 +1,23 @@ -- ----------------------------------------------------------------------- --- Graph test data: 3-level chain of entities connected via two relation +-- Graph test data: 4-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 topology: +-- graph-svc-a --[uses]--> graph-svc-b --[uses]--> graph-svc-c --[uses]--> graph-svc-d -- 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) --- --- All statements use ON CONFLICT DO NOTHING to make this script idempotent: --- safe to run via Flyway on startup AND via @Sql before the graph test class. +-- 1. Depth 1 returns: a -[uses]-> b, a -[monitors]-> b +-- 2. Depth 2 returns: a -[uses]-> b, c; a -[monitors]-> b +-- 3. Depth 3 returns: a -[uses]-> b, c, d; a -[monitors]-> b +-- 4. Filter "relations_to_display=uses" excludes "monitors" at any depth -- ----------------------------------------------------------------------- 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') + ('aa000001-0000-0000-0000-000000000003', 'graph-svc-c', 'Graph Service C', 'web-service'), + ('aa000001-0000-0000-0000-000000000004', 'graph-svc-d', 'Graph Service D', 'web-service') ON CONFLICT DO NOTHING; -- Relations owned by graph-svc-a: "uses" → b, "monitors" → b @@ -33,22 +31,29 @@ INSERT INTO relation (id, name, target_template_identifier) VALUES ('bb000002-0000-0000-0000-000000000001', 'uses', 'web-service') ON CONFLICT DO NOTHING; +-- Relation owned by graph-svc-c: "uses" → d +INSERT INTO relation (id, name, target_template_identifier) VALUES + ('bb000003-0000-0000-0000-000000000001', 'uses', 'web-service') +ON CONFLICT DO NOTHING; + -- 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 + ('bb000002-0000-0000-0000-000000000001', 'graph-svc-c', 'aa000001-0000-0000-0000-000000000003'), -- b -[uses]-> c + ('bb000003-0000-0000-0000-000000000001', 'graph-svc-d', 'aa000001-0000-0000-0000-000000000004') -- c -[uses]-> d ON CONFLICT DO NOTHING; -- 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 + ('aa000001-0000-0000-0000-000000000001', 'bb000001-0000-0000-0000-000000000001'), -- a owns "uses" + ('aa000001-0000-0000-0000-000000000001', 'bb000001-0000-0000-0000-000000000002'), -- a owns "monitors" + ('aa000001-0000-0000-0000-000000000002', 'bb000002-0000-0000-0000-000000000001'), -- b owns "uses" + ('aa000001-0000-0000-0000-000000000003', 'bb000003-0000-0000-0000-000000000001') -- c owns "uses" ON CONFLICT DO NOTHING; -- ----------------------------------------------------------------------- --- Property data for graph test entities (used by the property-filter tests). +-- Property data for graph test entities -- ----------------------------------------------------------------------- INSERT INTO property (id, name, value) VALUES @@ -61,10 +66,10 @@ INSERT INTO property (id, name, value) VALUES ON CONFLICT DO NOTHING; 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 + ('aa000001-0000-0000-0000-000000000001', 'cc000001-0000-0000-0000-000000000001'), + ('aa000001-0000-0000-0000-000000000001', 'cc000001-0000-0000-0000-000000000002'), + ('aa000001-0000-0000-0000-000000000002', 'cc000001-0000-0000-0000-000000000003'), + ('aa000001-0000-0000-0000-000000000002', 'cc000001-0000-0000-0000-000000000004'), + ('aa000001-0000-0000-0000-000000000003', 'cc000001-0000-0000-0000-000000000005'), + ('aa000001-0000-0000-0000-000000000003', 'cc000001-0000-0000-0000-000000000006') ON CONFLICT DO NOTHING; diff --git a/src/test/resources/integration_test/json/entity/v1/getEntities_200_identifierEquals.json b/src/test/resources/integration_test/json/entity/v1/getEntities_200_identifierEquals.json index aaf564b..ea6ae38 100644 --- a/src/test/resources/integration_test/json/entity/v1/getEntities_200_identifierEquals.json +++ b/src/test/resources/integration_test/json/entity/v1/getEntities_200_identifierEquals.json @@ -6,13 +6,12 @@ "properties": { "environment": "PROD", "programmingLanguage": "JAVA", "port": 8080 }, "relations": { "database": [ - { "identifier": "database-service-1", "name": "Database Service 1" } + { "identifier": "database-service-1", "name": "Database Service 1", "template_identifier": "database-service" } ], "api-link": [ - { "identifier": "microservice-1", "name": "Microservice 1" } + { "identifier": "microservice-1", "name": "Microservice 1", "template_identifier": "microservice" } ] }, - "relations_as_target": {}, "template_identifier": "web-service" } ], diff --git a/src/test/resources/integration_test/json/entity/v1/getEntities_200_relationsAsTargetIdentifier.json b/src/test/resources/integration_test/json/entity/v1/getEntities_200_relationsAsTargetIdentifier.json index 2b5f2bc..94791a5 100644 --- a/src/test/resources/integration_test/json/entity/v1/getEntities_200_relationsAsTargetIdentifier.json +++ b/src/test/resources/integration_test/json/entity/v1/getEntities_200_relationsAsTargetIdentifier.json @@ -4,12 +4,12 @@ "identifier": "microservice-1", "name": "Microservice 1", "properties": {}, - "relations": {}, - "relations_as_target": { + "relations": { "api-link": [ { "identifier": "web-api-1", - "name": "Web API 1" + "name": "Web API 1", + "template_identifier": "web-service" } ] }, diff --git a/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byRelationNameContains.json b/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byRelationNameContains.json index a5a0b8f..dc60da0 100644 --- a/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byRelationNameContains.json +++ b/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byRelationNameContains.json @@ -10,13 +10,12 @@ }, "relations": { "database": [ - { "identifier": "database-service-1", "name": "Database Service 1" } + { "identifier": "database-service-1", "name": "Database Service 1", "template_identifier": "database-service" } ], "api-link": [ - { "identifier": "microservice-1", "name": "Microservice 1" } + { "identifier": "microservice-1", "name": "Microservice 1", "template_identifier": "microservice" } ] }, - "relations_as_target": {}, "template_identifier": "web-service" } ], diff --git a/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byRelationNameEq.json b/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byRelationNameEq.json index 7259174..d2bf999 100644 --- a/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byRelationNameEq.json +++ b/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byRelationNameEq.json @@ -6,13 +6,12 @@ "properties": { "environment": "PROD", "programmingLanguage": "JAVA", "port": 8080.0 }, "relations": { "database": [ - { "identifier": "database-service-1", "name": "Database Service 1" } + { "identifier": "database-service-1", "name": "Database Service 1", "template_identifier": "database-service" } ], "api-link": [ - { "identifier": "microservice-1", "name": "Microservice 1" } + { "identifier": "microservice-1", "name": "Microservice 1", "template_identifier": "microservice" } ] }, - "relations_as_target": {}, "template_identifier": "web-service" } ], diff --git a/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byRelationsAsTarget.json b/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byRelationsAsTarget.json index 2b5f2bc..94791a5 100644 --- a/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byRelationsAsTarget.json +++ b/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byRelationsAsTarget.json @@ -4,12 +4,12 @@ "identifier": "microservice-1", "name": "Microservice 1", "properties": {}, - "relations": {}, - "relations_as_target": { + "relations": { "api-link": [ { "identifier": "web-api-1", - "name": "Web API 1" + "name": "Web API 1", + "template_identifier": "web-service" } ] }, diff --git a/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byRelationsAsTargetPresence.json b/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byRelationsAsTargetPresence.json index 92fc614..3a51c48 100644 --- a/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byRelationsAsTargetPresence.json +++ b/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byRelationsAsTargetPresence.json @@ -4,10 +4,9 @@ "identifier": "microservice-1", "name": "Microservice 1", "properties": {}, - "relations": {}, - "relations_as_target": { + "relations": { "api-link": [ - { "identifier": "web-api-1", "name": "Web API 1" } + { "identifier": "web-api-1", "name": "Web API 1", "template_identifier": "web-service" } ] }, "template_identifier": "microservice" diff --git a/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byTemplateAndProperty.json b/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byTemplateAndProperty.json index 2d9de92..1155561 100644 --- a/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byTemplateAndProperty.json +++ b/src/test/resources/integration_test/json/entity/v1/searchEntities_200_byTemplateAndProperty.json @@ -10,13 +10,12 @@ }, "relations": { "database": [ - { "identifier": "database-service-1", "name": "Database Service 1" } + { "identifier": "database-service-1", "name": "Database Service 1", "template_identifier": "database-service" } ], "api-link": [ - { "identifier": "microservice-1", "name": "Microservice 1" } + { "identifier": "microservice-1", "name": "Microservice 1", "template_identifier": "microservice" } ] }, - "relations_as_target": {}, "template_identifier": "web-service" }, { @@ -34,7 +33,6 @@ "ownerEmail": "owner@example.com" }, "relations": {}, - "relations_as_target": {}, "template_identifier": "web-service" }, { @@ -52,7 +50,6 @@ "ownerEmail": "owner@example.com" }, "relations": {}, - "relations_as_target": {}, "template_identifier": "web-service" } ], diff --git a/src/test/resources/integration_test/json/entity/v1/searchEntities_200_neq.json b/src/test/resources/integration_test/json/entity/v1/searchEntities_200_neq.json index 642d3c7..dc22b8b 100644 --- a/src/test/resources/integration_test/json/entity/v1/searchEntities_200_neq.json +++ b/src/test/resources/integration_test/json/entity/v1/searchEntities_200_neq.json @@ -15,7 +15,6 @@ "ownerEmail": "owner@example.com" }, "relations": {}, - "relations_as_target": {}, "template_identifier": "web-service" } ], diff --git a/src/test/resources/integration_test/json/entity/v1/searchEntities_200_orTemplates.json b/src/test/resources/integration_test/json/entity/v1/searchEntities_200_orTemplates.json index 1e89d6a..fbbb11c 100644 --- a/src/test/resources/integration_test/json/entity/v1/searchEntities_200_orTemplates.json +++ b/src/test/resources/integration_test/json/entity/v1/searchEntities_200_orTemplates.json @@ -5,17 +5,15 @@ "name": "Batch Job 1", "properties": {}, "relations": {}, - "relations_as_target": {}, "template_identifier": "batch-job" }, { "identifier": "microservice-1", "name": "Microservice 1", "properties": {}, - "relations": {}, - "relations_as_target": { + "relations": { "api-link": [ - { "identifier": "web-api-1", "name": "Web API 1" } + { "identifier": "web-api-1", "name": "Web API 1", "template_identifier": "web-service" } ] }, "template_identifier": "microservice" diff --git a/src/test/resources/integration_test/json/entity/v1/searchEntities_200_startsWith.json b/src/test/resources/integration_test/json/entity/v1/searchEntities_200_startsWith.json index 7259174..d2bf999 100644 --- a/src/test/resources/integration_test/json/entity/v1/searchEntities_200_startsWith.json +++ b/src/test/resources/integration_test/json/entity/v1/searchEntities_200_startsWith.json @@ -6,13 +6,12 @@ "properties": { "environment": "PROD", "programmingLanguage": "JAVA", "port": 8080.0 }, "relations": { "database": [ - { "identifier": "database-service-1", "name": "Database Service 1" } + { "identifier": "database-service-1", "name": "Database Service 1", "template_identifier": "database-service" } ], "api-link": [ - { "identifier": "microservice-1", "name": "Microservice 1" } + { "identifier": "microservice-1", "name": "Microservice 1", "template_identifier": "microservice" } ] }, - "relations_as_target": {}, "template_identifier": "web-service" } ],