From bde7650f7b72915bd9ce8f7c298a0734dd6be84a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Wed, 8 Jul 2026 14:05:30 +0200 Subject: [PATCH 01/26] feat(core): update Entity Dto contract, mapper and controllers tests --- .../model/entity/RelationAsTargetSummary.java | 3 +- .../api/dto/out/entity/EntityDtoOut.java | 33 +++-- .../api/dto/out/entity/EntitySummaryDto.java | 23 ++-- .../api/mapper/entity/EntityDtoOutMapper.java | 114 +++++++++--------- .../repository/JpaRelationRepository.java | 5 +- .../v1/getEntities_200_identifierEquals.json | 5 +- ...ities_200_relationsAsTargetIdentifier.json | 6 +- ...chEntities_200_byRelationNameContains.json | 5 +- .../searchEntities_200_byRelationNameEq.json | 5 +- ...earchEntities_200_byRelationsAsTarget.json | 6 +- ...ities_200_byRelationsAsTargetPresence.json | 5 +- ...rchEntities_200_byTemplateAndProperty.json | 7 +- .../entity/v1/searchEntities_200_neq.json | 1 - .../v1/searchEntities_200_orTemplates.json | 6 +- .../v1/searchEntities_200_startsWith.json | 5 +- 15 files changed, 120 insertions(+), 109 deletions(-) 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/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..c44d8ce 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 @@ -6,19 +6,28 @@ 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 (e.g., "component-supported_by-support_group"). + * + * This eliminates the need for separate outbound/inbound relation sections and + * allows frontend to display distant relations without specifying direction. + * + * **Breaking change:** The `relations_as_target` field has been removed. + * All relations (both directions) are now merged into the `relations` map. + */ @JsonNaming(SnakeCaseStrategy.class) -public class EntityDtoOut { +public record EntityDtoOut( + String identifier, + + String name, + + String templateIdentifier, - private String templateIdentifier; - private String name; - private String identifier; - private Map properties; - private Map> relations; - private Map> relationsAsTarget; + Map properties, + Map> relations) { } 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..b7c6985 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,18 @@ 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/mapper/entity/EntityDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java index 2928d7a..356eddd 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 @@ -15,7 +15,6 @@ 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; @@ -99,23 +98,21 @@ public Page fromEntitiesPageToDtoPage(Page entities, /// /// @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 @@ -125,20 +122,17 @@ private EntityDtoOut fromEntityUsingEntityTemplate(Entity entity, EntityTemplate /// @param entityTemplate the template for property type mapping /// @param relatedEntitiesSummaries map of entity summaries for relation targets /// @param relationTargetOwnershipsMap map of relations-as-target for the entity - /// @return the mapped DTO + /// @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, @@ -185,42 +179,51 @@ private Object convertPropertyValue(Property property, PropertyDefinition defini return value; } - /// Maps the relations of an entity to a map of relation names to lists of - /// target - /// entity summaries. + /// Builds a unified relations map combining outbound and inbound relations. /// - /// @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 + + java.util.HashMap> unifiedRelations = new java.util.HashMap<>(); + + // Add outbound relations (entity is source) + if (entity.relations() != null) { + entity.relations().forEach(relation -> { + String relationKey = relation.name(); + List targets = relation.targetEntityIdentifiers().stream() + .map(relatedEntitiesSummaries::get).filter(Objects::nonNull).toList(); + unifiedRelations.put(relationKey, new ArrayList<>(targets)); + }); + } + + // 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 @@ -296,14 +299,17 @@ private Map buildRelatedEntitiesSummaryMapByPage( /// Builds a map of entity summaries for a list of target identifiers. /// + /// Includes template identifier for each entity, enabling frontend to determine + /// relation direction 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. @@ -342,10 +348,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/persistence/repository/JpaRelationRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaRelationRepository.java index 9a97c58..44542c1 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,8 @@ 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 +34,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/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" } ], From 5de8436ea8fccdad3e1e868d93c020fec26b54fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Wed, 8 Jul 2026 14:08:22 +0200 Subject: [PATCH 02/26] feat(core): update Entity Dto contract, mapper and controllers tests --- .../adapters/api/dto/out/entity/EntityDtoOut.java | 8 +------- .../adapters/api/dto/out/entity/EntitySummaryDto.java | 3 +-- .../api/mapper/entity/EntityDtoOutMapper.java | 11 ++++++----- .../persistence/repository/JpaRelationRepository.java | 3 ++- 4 files changed, 10 insertions(+), 15 deletions(-) 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 c44d8ce..a96d798 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 @@ -13,15 +13,9 @@ * relations (where this entity is the target) into a single relations map, * keyed by relation name (e.g., "component-supported_by-support_group"). * - * This eliminates the need for separate outbound/inbound relation sections and - * allows frontend to display distant relations without specifying direction. - * - * **Breaking change:** The `relations_as_target` field has been removed. - * All relations (both directions) are now merged into the `relations` map. */ @JsonNaming(SnakeCaseStrategy.class) -public record EntityDtoOut( - String identifier, +public record EntityDtoOut(String identifier, String name, 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 b7c6985..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 @@ -11,8 +11,7 @@ * listings and unified relation structures. */ @JsonNaming(SnakeCaseStrategy.class) -public record EntitySummaryDto( - String identifier, +public record EntitySummaryDto(String identifier, String name, 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 356eddd..24f87a4 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 @@ -111,8 +111,8 @@ private EntityDtoOut fromEntityUsingEntityTemplate(Entity entity, EntityTemplate Map> unifiedRelations = buildUnifiedRelationsMap(entity, relatedEntitiesSummaryMap, relatedEntitiesByTargetSummaryMap); - return new EntityDtoOut(entity.identifier(), entity.name(), entity.templateIdentifier(), - props, unifiedRelations); + return new EntityDtoOut(entity.identifier(), entity.name(), entity.templateIdentifier(), props, + unifiedRelations); } /// Maps a single entity to its DTO using pre-built summary and @@ -131,8 +131,8 @@ private EntityDtoOut fromEntityUsingEntityTemplateAndSummaryMap(Entity entity, Map> unifiedRelations = buildUnifiedRelationsMap(entity, relatedEntitiesSummaries, relationTargetOwnershipsMap); - return new EntityDtoOut(entity.identifier(), entity.name(), entity.templateIdentifier(), - props, unifiedRelations); + 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, @@ -187,7 +187,8 @@ private Object convertPropertyValue(Property property, PropertyDefinition defini /// - Both directions merged under the same relation key /// /// @param entity the entity whose relations to unify - /// @param relatedEntitiesSummaries map of target entity summaries (for outbound) + /// @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, 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 44542c1..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,7 +23,8 @@ 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. + * Includes source entity template identifier for unified relations API + * response. * * @param targetEntityIdentifiers * List of entity identifiers to search for From c7efad0ceb2b18c8982e25e17a0829216969b5de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Wed, 8 Jul 2026 14:47:12 +0200 Subject: [PATCH 03/26] feat(core): update Entity Dto contract, mapper and controllers tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Andrés Brand --- .../api/mapper/entity/EntityDtoOutMapper.java | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) 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 24f87a4..4960a5e 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 @@ -200,10 +200,14 @@ private Map> buildUnifiedRelationsMap(Entity enti // Add outbound relations (entity is source) if (entity.relations() != null) { entity.relations().forEach(relation -> { - String relationKey = relation.name(); - List targets = relation.targetEntityIdentifiers().stream() + var relationKey = relation.name(); + var targets = relation.targetEntityIdentifiers().stream() .map(relatedEntitiesSummaries::get).filter(Objects::nonNull).toList(); - unifiedRelations.put(relationKey, new ArrayList<>(targets)); + unifiedRelations.merge(relationKey, new ArrayList<>(targets), (existing, incoming) -> { + var merged = new ArrayList<>(existing); + merged.addAll(incoming); + return merged; + }); }); } @@ -300,9 +304,8 @@ private Map buildRelatedEntitiesSummaryMapByPage( /// Builds a map of entity summaries for a list of target identifiers. /// - /// Includes template identifier for each entity, enabling frontend to determine - /// relation direction without additional queries. - /// + /// 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 with template info private Map buildEntitiesSummariesMap(List targetIdentifiers) { From 6be5966d7210668b729bc9b3494cb4b5bf7c05f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Wed, 8 Jul 2026 15:23:50 +0200 Subject: [PATCH 04/26] feat(core): update Entity Dto contract, mapper and controllers tests --- .../adapters/api/mapper/entity/EntityDtoOutMapper.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 4960a5e..b803e80 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 @@ -201,8 +201,8 @@ private Map> buildUnifiedRelationsMap(Entity enti if (entity.relations() != null) { entity.relations().forEach(relation -> { var relationKey = relation.name(); - var targets = relation.targetEntityIdentifiers().stream() - .map(relatedEntitiesSummaries::get).filter(Objects::nonNull).toList(); + 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); From a104d92a91bdcaa40f0513441af92701f32a75a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Thu, 9 Jul 2026 14:34:15 +0200 Subject: [PATCH 05/26] feat(core): update Entity Dto contract, mapper and controllers tests --- .../dto/out/entity/RelationAsTargetSummaryDtoOut.java | 10 ---------- .../adapters/api/mapper/entity/EntityDtoOutMapper.java | 3 ++- 2 files changed, 2 insertions(+), 11 deletions(-) delete mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/RelationAsTargetSummaryDtoOut.java 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/EntityDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java index b803e80..2af6fb5 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 @@ -8,6 +8,7 @@ import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; +import java.util.HashMap; import org.springframework.data.domain.Page; import org.springframework.stereotype.Component; @@ -195,7 +196,7 @@ private Map> buildUnifiedRelationsMap(Entity enti Map relatedEntitiesSummaries, Map> relationTargetOwnershipsMap) { - java.util.HashMap> unifiedRelations = new java.util.HashMap<>(); + HashMap> unifiedRelations = new HashMap<>(); // Add outbound relations (entity is source) if (entity.relations() != null) { From de07bd3fac807c2008003df0df1fed0d99f15e32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Thu, 9 Jul 2026 17:38:50 +0200 Subject: [PATCH 06/26] feat(core): update Entity Dto contract, mapper and controllers tests --- docs/src/concepts/entities.md | 33 ++----------------- .../api/mapper/entity/EntityDtoOutMapper.java | 2 +- 2 files changed, 3 insertions(+), 32 deletions(-) diff --git a/docs/src/concepts/entities.md b/docs/src/concepts/entities.md index c08679f..3816063 100644 --- a/docs/src/concepts/entities.md +++ b/docs/src/concepts/entities.md @@ -54,8 +54,7 @@ Here's an entity instantiated from the `web-service` template: "name": "Web API 1" } ] - }, - "relations_as_target": {} + } } ``` @@ -237,37 +236,10 @@ In API responses, relations are grouped by name and include summary information "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`) - -For consistency, even single relations are represented as arrays: - -```json -{ - "relations": [ - { - "name": "owned_by", - "target_entity_identifiers": [ - "platform-team" - ] - } - ] -} -``` - ### One-to-Many Relations (`to_many: true`) When multiple related entities are allowed, list several identifiers: @@ -386,8 +358,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/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 2af6fb5..661f557 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,13 +2,13 @@ import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; -import java.util.HashMap; import org.springframework.data.domain.Page; import org.springframework.stereotype.Component; From a14ee993c8d208a55e0a88bc10df7b96deb5abfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Thu, 9 Jul 2026 17:57:25 +0200 Subject: [PATCH 07/26] feat(core): update Entity Dto contract, mapper and controllers tests --- docs/src/concepts/entities.md | 38 +++++++++++++-------------- docs/src/concepts/entity-filtering.md | 2 +- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/docs/src/concepts/entities.md b/docs/src/concepts/entities.md index 3816063..cb725d6 100644 --- a/docs/src/concepts/entities.md +++ b/docs/src/concepts/entities.md @@ -219,6 +219,25 @@ When creating an entity, you specify relations as an array of objects, each with } ``` +### One-to-Many Relations (`to_many: true`) + +When multiple related entities are allowed, list several identifiers: + +```json +{ + "relations": [ + { + "name": "components", + "target_entity_identifiers": [ + "frontend", + "backend", + "database" + ] + } + ] +} +``` + ### Relations in Responses In API responses, relations are grouped by name and include summary information about each target entity: @@ -240,25 +259,6 @@ In API responses, relations are grouped by name and include summary information } ``` -### One-to-Many Relations (`to_many: true`) - -When multiple related entities are allowed, list several identifiers: - -```json -{ - "relations": [ - { - "name": "components", - "target_entity_identifiers": [ - "frontend", - "backend", - "database" - ] - } - ] -} -``` - --- ## Retrieving Entities diff --git a/docs/src/concepts/entity-filtering.md b/docs/src/concepts/entity-filtering.md index 03c9c63..433b8f4 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 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 From 61ee0743447ab9aa452aa384f4708a10c7c12d93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Mon, 13 Jul 2026 11:20:45 +0200 Subject: [PATCH 08/26] feat(core): update Entity Dto contract, mapper and controllers tests --- .../port/EntityGraphRepositoryPort.java | 3 + .../entity_graph/EntityGraphService.java | 210 +++++++++++ .../api/controller/EntityController.java | 11 +- .../api/mapper/entity/EntityDtoOutMapper.java | 101 +++++- .../PostgresEntityGraphAdapter.java | 45 ++- .../repository/JpaEntityRepository.java | 338 ++++++++++-------- .../test/R__2_Insert_entities_test_data.sql | 16 +- 7 files changed, 553 insertions(+), 171 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java index 842b8d3..705a2a4 100644 --- a/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java @@ -53,4 +53,7 @@ public interface EntityGraphRepositoryPort { Map findEntityGraph(UUID entityId, int depth, boolean includeProperties, EntityGraphTraversalMode mode); + Map findEntityGraphBatch(java.util.List rootIds, int depth, + boolean includeProperties, EntityGraphTraversalMode mode); + } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java index 8aec14f..afa6f9e 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java @@ -8,12 +8,18 @@ import java.util.Objects; import java.util.Set; import java.util.UUID; +import java.util.stream.Collectors; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + 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.EntityFilter; 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; @@ -21,6 +27,7 @@ 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; @@ -57,6 +64,7 @@ public class EntityGraphService { private final EntityRepositoryPort entityRepositoryPort; private final EntityGraphRepositoryPort entityGraphRepositoryPort; private final EntityTemplateValidationService entityTemplateValidationService; + private final EntityService entityService; @Transactional(readOnly = true) public EntityGraphNode getEntityGraph(String templateIdentifier, String entityIdentifier, @@ -95,6 +103,208 @@ public EntityGraphNode getEntityGraph(String templateIdentifier, String entityId return buildGraphNode(rootEntity.id(), ctx); } + /// Loads and builds entity graphs for multiple entity IDs in a single + /// batch operation. + @Transactional(readOnly = true) + public Map getEntityGraphBulk(List entityIds, int depth, + boolean includeProperties, Set relationFilter, Set propertyFilter, + EntityGraphTraversalMode mode) { + + if (entityIds == null || entityIds.isEmpty()) { + return Map.of(); + } + + return buildGraphNodesForEntityIds(entityIds, depth, includeProperties, relationFilter, + propertyFilter, mode); + } + + /// Shared bulk graph-building logic used by both [#getEntityGraphBulk] and + /// [#getEntityGraphPageByTemplate] to 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 depth traversal depth clamped to [1, MAX_DEPTH] + /// @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_ONLY, BIDIRECTIONAL, etc.) + /// @return map of entity identifier to its fully resolved EntityGraphNode + @SuppressWarnings("null") + private Map buildGraphNodesForEntityIds(List entityIds, int depth, + boolean includeProperties, Set relationFilter, Set propertyFilter, + EntityGraphTraversalMode mode) { + + int effectiveDepth = Math.clamp(depth, 1, MAX_DEPTH); + Map entityGraphs = entityGraphRepositoryPort.findEntityGraphBatch(entityIds, + effectiveDepth, includeProperties, mode); + + if (entityGraphs == null || entityGraphs.isEmpty()) { + return Map.of(); + } + + IndexBundle globalIndices = buildIndices(entityGraphs, mode); + Map result = new HashMap<>(); + + for (Map.Entry entry : entityGraphs.entrySet()) { + Entity entity = entry.getValue(); + if (entity != null) { + Set reachableFootprint = computeReachableSubGraph(entry.getKey(), entityGraphs, + globalIndices, effectiveDepth, mode); + + Map localizedEntityMap = entityGraphs.entrySet().stream() + .filter(e -> reachableFootprint.contains(e.getKey())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + + IndexBundle localizedIndices = buildIndices(localizedEntityMap, mode); + + Set isolatedStack = new HashSet<>(); + Map isolatedCache = new HashMap<>(); + + GraphTraversalContext localizedCtx = new GraphTraversalContext(localizedEntityMap, + localizedIndices.textToUuidLookup(), localizedIndices.inboundIndex(), includeProperties, + propertyFilter, relationFilter, isolatedStack, isolatedCache, mode); + + EntityGraphNode node = buildGraphNode(entry.getKey(), localizedCtx); + result.put(entity.identifier(), node); + } + } + + return result; + } + + /// 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 + /// @return paginated graph nodes with outbound and inbound relations resolved + /// @throws EntityTemplateNotFoundException when the template does not exist + @Transactional(readOnly = true) + public Page getEntityGraphPageByTemplate(Pageable pageable, + String templateIdentifier, EntityFilter entityFilter) { + + // Fetch paginated entities — template existence is validated inside this call + Page entityPage = entityService.getEntitiesByTemplateIdentifier(pageable, + templateIdentifier, entityFilter); + + 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())); + } + + // Extract UUIDs for the batch graph call + var entityUuids = entityPage.getContent().stream() + .map(Entity::id) + .filter(Objects::nonNull) + .toList(); + + // Call the private helper directly — avoids self-proxy transactional issue + // while still executing within the current transaction boundary. + Map graphsByIdentifier = buildGraphNodesForEntityIds(entityUuids, 1, + true, Set.of(), Set.of(), EntityGraphTraversalMode.DIRECT_LINEAGE); + + // Map each Entity to its EntityGraphNode, falling back gracefully if missing + return entityPage.map(entity -> graphsByIdentifier.getOrDefault(entity.identifier(), + new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), + entity.properties(), List.of(), List.of()))); + } + + /// Computes a precise sub-graph footprint + // matching the recursive database query logic. + @SuppressWarnings("null") + private Set computeReachableSubGraph(UUID rootId, Map entityMap, + IndexBundle globalIndices, int maxDepth, EntityGraphTraversalMode mode) { + + Set visited = new HashSet<>(); + Set currentLevel = new HashSet<>(); + + // Replicate SQL CTE Anchor Members + if (mode == EntityGraphTraversalMode.OUTBOUND_ONLY + || mode == EntityGraphTraversalMode.DIRECT_LINEAGE) { + ReachableState anchor = new ReachableState(rootId, "OUTBOUND"); + visited.add(anchor); + currentLevel.add(anchor); + } + if (mode == EntityGraphTraversalMode.DIRECT_LINEAGE) { + ReachableState anchor = new ReachableState(rootId, "INBOUND"); + if (visited.add(anchor)) { + currentLevel.add(anchor); + } + } + if (mode == EntityGraphTraversalMode.BIDIRECTIONAL) { + ReachableState anchor = new ReachableState(rootId, "ANY"); + visited.add(anchor); + currentLevel.add(anchor); + } + + // Level-by-level propagation matching depth limits + // Level-by-level propagation matching depth limits + for (int d = 0; d < maxDepth; d++) { + Set nextLevel = new HashSet<>(); + for (ReachableState state : currentLevel) { + Entity entity = entityMap.get(state.id()); + if (entity == null) + continue; + + // Propagate Outbound Paths + if ("OUTBOUND".equals(state.flow()) || "ANY".equals(state.flow())) { + for (Relation rel : entity.relations()) { + for (String targetId : rel.targetEntityIdentifiers()) { + UUID targetUuid = globalIndices.textToUuidLookup() + .get(new EntityCompositeKey(rel.targetTemplateIdentifier(), targetId)); + if (targetUuid != null && entityMap.containsKey(targetUuid)) { + ReachableState nextState = new ReachableState(targetUuid, "OUTBOUND"); + if (visited.add(nextState)) { + nextLevel.add(nextState); + } + } + } + } + } + + // Propagate Inbound Paths + if ("INBOUND".equals(state.flow()) || "ANY".equals(state.flow())) { + String normalizedId = entity.identifier() == null + ? "" + : entity.identifier().trim().toLowerCase(); + Map> sourcesByRelation = globalIndices.inboundIndex() + .getOrDefault(normalizedId, Map.of()); + + for (List sources : sourcesByRelation.values()) { + for (UUID sourceUuid : sources) { + if (entityMap.containsKey(sourceUuid)) { + ReachableState nextState = new ReachableState(sourceUuid, "INBOUND"); + if (visited.add(nextState)) { + nextLevel.add(nextState); + } + } + } + } + } + } + if (nextLevel.isEmpty()) + break; + currentLevel = nextLevel; + } + + return visited.stream().map(ReachableState::id).collect(Collectors.toSet()); + } + + private static record ReachableState(UUID id, String flow) { + } private EntityGraphNode buildGraphNode(UUID entityUuid, GraphTraversalContext ctx) { Entity entity = ctx.entityMap().get(entityUuid); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java index 0eb19c6..88afb19 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 @@ -69,11 +69,13 @@ import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntityFilter; +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; 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; @@ -117,6 +119,7 @@ public class EntityController { 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. @@ -133,7 +136,8 @@ public class EntityController { /// @param q optional filter query string (e.g. /// `name:API;property.language=JAVA`) /// @return paginated entity DTOs matching the template and optional filter - @Operation(summary = ENDPOINT_GET_ENTITIES_SUMMARY, description = ENDPOINT_GET_ENTITIES_PAGINATED_DESCRIPTION) +@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 = { @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) @@ -150,9 +154,10 @@ 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, + // Single transaction: pagination + batch relation fetch in one DB round trip + Page graphNodes = entityGraphService.getEntityGraphPageByTemplate(pageable, templateIdentifier, filter); - return entityDtoOutMapper.fromEntitiesPageToDtoPage(entities, templateIdentifier); + return entityDtoOutMapper.fromGraphNodesPage(graphNodes, templateIdentifier); } /// Retrieves a single entity by template and entity identifiers. 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 661f557..31df8b3 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java @@ -17,10 +17,13 @@ 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.RelationAsTargetSummary; +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.model.enums.PropertyType; 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.entity_template.EntityTemplateService; import com.decathlon.idp_core.domain.service.relation.RelationService; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityDtoOut; @@ -70,29 +73,91 @@ public EntityDtoOut fromEntity(Entity entity) { return fromEntityUsingEntityTemplate(entity, entityTemplate); } - /// Maps paginated domain entities to API DTOs with optimized bulk operations. + /// Maps a page of pre-fetched graph nodes to paginated [EntityDtoOut] DTOs. /// - /// **Performance optimization:** Batches template resolution and relationship - /// lookups - /// to minimize database queries. Builds summary maps for efficient relationship - /// resolution across the entire page. + /// **Pure mapping:** All DB queries were already executed inside + /// [EntityGraphService#getEntityGraphPageByTemplate] within a single + /// transaction. This method only transforms domain models to API DTOs — + /// no further repository calls are made. /// - /// @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, + /// @param graphNodes paginated graph nodes with resolved bidirectional + /// relations + /// @param entityTemplateIdentifier template identifier for property type + /// mapping + /// @return paginated API DTOs with unified outbound and inbound relations + public Page fromGraphNodesPage(Page graphNodes, String entityTemplateIdentifier) { - Map pageEntitiesSummaries = buildRelatedEntitiesSummaryMapByPage( - entities); - Map> relationTargetOwnershipsMap = buildRelationsAsTargetSummaryMapByPage( - entities); - - EntityTemplate pageEntityTemplate = entityTemplateService + EntityTemplate template = entityTemplateService .getEntityTemplateByIdentifier(entityTemplateIdentifier); - return entities.map(entity -> fromEntityUsingEntityTemplateAndSummaryMap(entity, - pageEntityTemplate, pageEntitiesSummaries, relationTargetOwnershipsMap)); + + return graphNodes.map(graphNode -> fromGraphNode(graphNode, template)); + } + + /// Maps a single [EntityGraphNode] to an [EntityDtoOut]. + /// + /// **Unification:** Merges `graphNode.relations()` (outbound) and + /// `graphNode.relationsAsTarget()` (inbound) into a single `relations` map + /// keyed by relation name. When both directions share the same relation name, + /// their entity summaries are merged into one list. + /// + /// @param graphNode domain graph node with bidirectional relation data + /// @param template entity template for property type resolution + /// @return API DTO with unified relations map + private EntityDtoOut fromGraphNode(EntityGraphNode graphNode, EntityTemplate template) { + Map props = mapPropertiesFromGraphNode(graphNode, template); + Map> unifiedRelations = buildUnifiedRelationsFromGraphNode( + graphNode); + + return new EntityDtoOut(graphNode.identifier(), graphNode.name(), + graphNode.templateIdentifier(), props, unifiedRelations); + } + + /// Maps properties from a graph node using the template for type conversion. + private Map mapPropertiesFromGraphNode(EntityGraphNode graphNode, + EntityTemplate template) { + if (graphNode.properties() == null || graphNode.properties().isEmpty()) { + return Collections.emptyMap(); + } + + Map definitions = template.propertiesDefinitions().stream() + .collect(Collectors.toMap(PropertyDefinition::name, Function.identity())); + + return graphNode.properties().stream().filter(prop -> prop.value() != null) + .collect(Collectors.toMap(Property::name, + prop -> convertPropertyValue(prop, definitions.get(prop.name())))); + } + + /// Builds a unified relations map from a graph node combining both directions. + /// + /// Outbound relations (`graphNode.relations()`) and inbound relations + /// (`graphNode.relationsAsTarget()`) are merged under the same relation name + /// key when present. + private Map> buildUnifiedRelationsFromGraphNode( + EntityGraphNode graphNode) { + + Map> unified = new HashMap<>(); + + // Outbound: this entity is the source + graphNode.relations().forEach(relation -> unified.put(relation.name(), + toEntitySummaryDtos(relation))); + + // Inbound: this entity is the target — merge under the same key if present + graphNode.relationsAsTarget().forEach(relation -> unified.merge(relation.name(), + toEntitySummaryDtos(relation), (existing, incoming) -> { + var merged = new ArrayList<>(existing); + merged.addAll(incoming); + return merged; + })); + + return unified; + } + + /// Converts the target nodes of an [EntityGraphRelation] to [EntitySummaryDto] + /// list. + private List toEntitySummaryDtos(EntityGraphRelation relation) { + return relation.targets().stream().map(target -> new EntitySummaryDto(target.identifier(), + target.name(), target.templateIdentifier())).toList(); } /// Maps a single entity to its DTO using the provided entity template. 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..9ec4e5f 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 @@ -41,13 +41,13 @@ public class PostgresEntityGraphAdapter implements EntityGraphRepositoryPort { private static final Logger log = LoggerFactory.getLogger(PostgresEntityGraphAdapter.class); + @SuppressWarnings("null") @Override @Transactional(readOnly = true) public Map findEntityGraph(UUID entityId, int depth, boolean includeProperties, EntityGraphTraversalMode mode) { - // Step 1: collect all (identifier, template_identifier) pairs via recursive - // CTE. + // 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 @@ -74,6 +74,47 @@ public Map findEntityGraph(UUID entityId, int depth, boolean inclu } // Step 4: map to domain and key by composite key for O(1) lookup + return jpaEntities.stream().map(mapper::toDomain) + .filter(entity -> entity.id() != null) + .collect(Collectors.toMap(Entity::id, Function.identity())); + + } + + @Override + @Transactional(readOnly = true) + public Map findEntityGraphBatch(List rootIds, int depth, + boolean includeProperties, EntityGraphTraversalMode mode) { + + if (rootIds == null || rootIds.isEmpty()) { + log.debug("[EntityGraphAdapter] Empty root IDs list provided, returning empty map"); + return Map.of(); + } + + // Step 1: collect all entity IDs in the graph for all root IDs via batch + // recursive CTE + // Execute the native call safely + List graphIds = jpaEntityRepository.findEntityGraphIdentifiersBatch(rootIds, depth, mode.name()); + + if (graphIds == null || graphIds.isEmpty()) { + log.debug( + "[EntityGraphAdapter] No graph identifiers found for batch roots (null or empty), returning empty map"); + return Map.of(); + } + + // Step 2: extract unique identifiers for batch loading + List entitiesIds = graphIds.stream().distinct().toList(); + + // Step 3: batch-load entities with relations, then optionally properties in a + // separate query. + // Properties are skipped when not requested to avoid the extra round-trip and + // keep payloads lean. + // The two-query split also avoids Hibernate's MultipleBagFetchException. + List jpaEntities = jpaEntityRepository.findAllByIdinWithRelations(entitiesIds); + if (includeProperties) { + jpaEntityRepository.findAllByIdInWithProperties(entitiesIds); + } + + // Step 4: map to domain and key by UUID for O(1) lookup return jpaEntities.stream().map(mapper::toDomain) .collect(Collectors.toMap(Entity::id, Function.identity())); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java index f0baf61..265d8e0 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,152 +11,210 @@ 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; import com.decathlon.idp_core.domain.model.entity.EntitySummary; import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.EntityJpaEntity; +import jakarta.persistence.QueryHint; + @Repository public interface JpaEntityRepository - extends - JpaRepository, - JpaSpecificationExecutor, - RevisionRepository { - - @Query("SELECT e.identifier AS identifier, e.name AS name, e.templateIdentifier AS templateIdentifier FROM EntityJpaEntity e WHERE e.identifier IN :identifiers") - List findByIdentifierIn(List identifiers); - - @Query("SELECT e.identifier AS identifier, e.name AS name, e.templateIdentifier AS templateIdentifier FROM EntityJpaEntity e JOIN e.relations r WHERE r.id IN :relationIds") - List findByRelationIdIn(List relationIds); - - Optional findByTemplateIdentifierAndIdentifier(String templateIdentifier, - String identifier); - - Optional findByTemplateIdentifierAndName(String templateIdentifier, String name); - - Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable); - - /// Batch fetch entities by identifiers with eager loading of relations and - /// properties. Uses two separate queries to avoid Hibernate's - /// MultipleBagFetchException. First fetches entities with relations, then - /// fetches properties separately. - @Query(""" - SELECT DISTINCT e - FROM EntityJpaEntity e - LEFT JOIN FETCH e.relations r - WHERE e.id IN :ids - """) - List findAllByIdinWithRelations(@Param("ids") Collection ids); - - /// Fetch properties for entities that were already loaded. This is called after - /// findAllByIdInWithRelations to complete the entity graph. - @Query("SELECT DISTINCT e FROM EntityJpaEntity e LEFT JOIN FETCH e.properties WHERE e.id IN :ids") - List findAllByIdInWithProperties(@Param("ids") Collection ids); - - @Query(value = """ - WITH RECURSIVE entity_graph(id, depth, flow) AS ( - -- 1. ANCHOR MEMBER: Initialize state tokens for a single root entity - SELECT e.id, 0, 'OUTBOUND' AS flow - FROM idp_core.entity e - WHERE e.id = :rootId AND :mode IN ('DIRECT_LINEAGE', 'OUTBOUND_ONLY') - - UNION - - SELECT e.id, 0, 'INBOUND' AS flow - FROM idp_core.entity e - WHERE e.id = :rootId AND :mode = 'DIRECT_LINEAGE' - - UNION - - SELECT e.id, 0, 'ANY' AS flow - FROM idp_core.entity e - WHERE e.id = :rootId AND :mode = 'BIDIRECTIONAL' - - UNION - - -- 2. RECURSIVE MEMBER: Propagate isolated pathways down the graph footprint - SELECT combined.id, eg.depth + 1, eg.flow - FROM entity_graph eg - JOIN ( - -- Outbound Paths - SELECT er.entity_id AS source_id, rte.target_entity_uuid AS id, 'OUTBOUND' AS flow_match - FROM idp_core.entity_relations er - JOIN idp_core.relation_target_entities rte ON rte.relation_id = er.relation_id - WHERE rte.target_entity_uuid IS NOT NULL - - UNION ALL - - SELECT er.entity_id AS source_id, rte.target_entity_uuid AS id, 'ANY' AS flow_match - FROM idp_core.entity_relations er - JOIN idp_core.relation_target_entities rte ON rte.relation_id = er.relation_id - WHERE rte.target_entity_uuid IS NOT NULL - - UNION ALL - - -- Inbound Paths - SELECT rte.target_entity_uuid AS source_id, er.entity_id AS id, 'INBOUND' AS flow_match - FROM idp_core.relation_target_entities rte - JOIN idp_core.entity_relations er ON er.relation_id = rte.relation_id - - UNION ALL - - SELECT rte.target_entity_uuid AS source_id, er.entity_id AS id, 'ANY' AS flow_match - FROM idp_core.relation_target_entities rte - JOIN idp_core.entity_relations er ON er.relation_id = rte.relation_id - ) combined ON combined.source_id = eg.id AND combined.flow_match = eg.flow - WHERE eg.depth < :depth - ) - -- 3. Return the clean deduplicated set of structural skeleton UUIDs - SELECT DISTINCT id FROM entity_graph; - """, nativeQuery = true) - List findEntityIdsInGraph(@Param("rootId") UUID rootId, @Param("depth") int depth, - @Param("mode") String mode); - - @Modifying(clearAutomatically = true, flushAutomatically = true) - @Query(""" - DELETE FROM PropertyJpaEntity p - 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); - + extends + JpaRepository, + JpaSpecificationExecutor { + + @Query("SELECT e.identifier AS identifier, e.name AS name, e.templateIdentifier AS templateIdentifier FROM EntityJpaEntity e WHERE e.identifier IN :identifiers") + List findByIdentifierIn(List identifiers); + + @Query("SELECT e.identifier AS identifier, e.name AS name, e.templateIdentifier AS templateIdentifier FROM EntityJpaEntity e JOIN e.relations r WHERE r.id IN :relationIds") + List findByRelationIdIn(List relationIds); + + Optional findByTemplateIdentifierAndIdentifier(String templateIdentifier, + String identifier); + + Optional findByTemplateIdentifierAndName(String templateIdentifier, String name); + + Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable); + + /// Batch fetch entities by identifiers with eager loading of relations and + /// properties. Uses two separate queries to avoid Hibernate's + /// MultipleBagFetchException. First fetches entities with relations, then + /// fetches properties separately. + @Query(""" + SELECT DISTINCT e + FROM EntityJpaEntity e + LEFT JOIN FETCH e.relations r + WHERE e.id IN :ids + """) + List findAllByIdinWithRelations(@Param("ids") Collection ids); + + /// Fetch properties for entities that were already loaded. This is called after + /// findAllByIdInWithRelations to complete the entity graph. + @Query("SELECT DISTINCT e FROM EntityJpaEntity e LEFT JOIN FETCH e.properties WHERE e.id IN :ids") + List findAllByIdInWithProperties(@Param("ids") Collection ids); + + @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); + + void deleteByTemplateIdentifierAndIdentifier( + @Param("templateIdentifier") String templateIdentifier, + @Param("entityIdentifier") String entityIdentifier); + + 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); + + @Query(value = """ + WITH RECURSIVE entity_graph(id, depth, flow) AS ( + -- 1. ANCHOR MEMBER: Initialize state tokens for a single root entity + SELECT e.id, 0, 'OUTBOUND' AS flow + FROM idp_core.entity e + WHERE e.id = :rootId AND :mode IN ('DIRECT_LINEAGE', 'OUTBOUND_ONLY') + + UNION + + SELECT e.id, 0, 'INBOUND' AS flow + FROM idp_core.entity e + WHERE e.id = :rootId AND :mode = 'DIRECT_LINEAGE' + + UNION + + SELECT e.id, 0, 'ANY' AS flow + FROM idp_core.entity e + WHERE e.id = :rootId AND :mode = 'BIDIRECTIONAL' + + UNION + + -- 2. RECURSIVE MEMBER: Propagate isolated pathways down the graph footprint + SELECT combined.id, eg.depth + 1, eg.flow + FROM entity_graph eg + JOIN ( + -- Outbound Paths + SELECT er.entity_id AS source_id, rte.target_entity_uuid AS id, 'OUTBOUND' AS flow_match + FROM idp_core.entity_relations er + JOIN idp_core.relation_target_entities rte ON rte.relation_id = er.relation_id + WHERE rte.target_entity_uuid IS NOT NULL + + UNION ALL + + SELECT er.entity_id AS source_id, rte.target_entity_uuid AS id, 'ANY' AS flow_match + FROM idp_core.entity_relations er + JOIN idp_core.relation_target_entities rte ON rte.relation_id = er.relation_id + WHERE rte.target_entity_uuid IS NOT NULL + + UNION ALL + + -- Inbound Paths + SELECT rte.target_entity_uuid AS source_id, er.entity_id AS id, 'INBOUND' AS flow_match + FROM idp_core.relation_target_entities rte + JOIN idp_core.entity_relations er ON er.relation_id = rte.relation_id + + UNION ALL + + SELECT rte.target_entity_uuid AS source_id, er.entity_id AS id, 'ANY' AS flow_match + FROM idp_core.relation_target_entities rte + JOIN idp_core.entity_relations er ON er.relation_id = rte.relation_id + ) combined ON combined.source_id = eg.id AND combined.flow_match = eg.flow + WHERE eg.depth < :depth + ) + -- 3. Return the clean deduplicated set of structural skeleton UUIDs + SELECT DISTINCT id FROM entity_graph; + """, nativeQuery = true) + List findEntityIdsInGraph(@Param("rootId") UUID rootId, @Param("depth") int depth, + @Param("mode") String mode); + + @Query(value = """ + WITH RECURSIVE entity_graph(id, depth, flow) AS ( + -- 1. ANCHOR MEMBER: Initialize state tokens for multiple root entities + SELECT e.id, 0, 'OUTBOUND' AS flow + FROM idp_core.entity e + WHERE e.id IN :rootIds AND :mode IN ('DIRECT_LINEAGE', 'OUTBOUND_ONLY') + + UNION + + SELECT e.id, 0, 'INBOUND' AS flow + FROM idp_core.entity e + WHERE e.id IN :rootIds AND :mode = 'DIRECT_LINEAGE' + + UNION + + SELECT e.id, 0, 'ANY' AS flow + FROM idp_core.entity e + WHERE e.id IN :rootIds AND :mode = 'BIDIRECTIONAL' + + UNION + + -- 2. RECURSIVE MEMBER: Propagate isolated pathways down the graph footprint + SELECT combined.id, eg.depth + 1, eg.flow + FROM entity_graph eg + JOIN ( + -- Outbound Paths + SELECT er.entity_id AS source_id, rte.target_entity_uuid AS id, 'OUTBOUND' AS flow_match + FROM idp_core.entity_relations er + JOIN idp_core.relation_target_entities rte ON rte.relation_id = er.relation_id + WHERE rte.target_entity_uuid IS NOT NULL + + UNION ALL + + SELECT er.entity_id AS source_id, rte.target_entity_uuid AS id, 'ANY' AS flow_match + FROM idp_core.entity_relations er + JOIN idp_core.relation_target_entities rte ON rte.relation_id = er.relation_id + WHERE rte.target_entity_uuid IS NOT NULL + + UNION ALL + + -- Inbound Paths + SELECT rte.target_entity_uuid AS source_id, er.entity_id AS id, 'INBOUND' AS flow_match + FROM idp_core.relation_target_entities rte + JOIN idp_core.entity_relations er ON er.relation_id = rte.relation_id + + UNION ALL + + SELECT rte.target_entity_uuid AS source_id, er.entity_id AS id, 'ANY' AS flow_match + FROM idp_core.relation_target_entities rte + JOIN idp_core.entity_relations er ON er.relation_id = rte.relation_id + ) combined ON combined.source_id = eg.id AND combined.flow_match = eg.flow + WHERE eg.depth < :depth + ) + -- 3. Return the clean deduplicated set of structural skeleton UUIDs + SELECT DISTINCT id FROM entity_graph; + """, nativeQuery = true) + List findEntityGraphIdentifiersBatch(@Param("rootIds") Collection rootIds, + @Param("depth") int depth, @Param("mode") String mode); } diff --git a/src/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 From 3cfe92989bbdc35e525bf84861e110e7b7f2b1e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Mon, 13 Jul 2026 19:04:29 +0200 Subject: [PATCH 09/26] feat(core): update Entity Dto contract, mapper and controllers tests --- .../entity_graph/EntityGraphHelper.java | 363 ++++++++++++++++++ .../entity_graph/EntityGraphService.java | 362 ++--------------- 2 files changed, 386 insertions(+), 339 deletions(-) create mode 100644 src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphHelper.java 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..b8e9602 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphHelper.java @@ -0,0 +1,363 @@ +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 com.decathlon.idp_core.domain.model.entity.Entity; +import com.decathlon.idp_core.domain.model.entity.Property; +import com.decathlon.idp_core.domain.model.entity.Relation; +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphRelation; +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphTraversalMode; +import com.decathlon.idp_core.domain.port.EntityGraphRepositoryPort; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class EntityGraphHelper { + + + private static final String FLOW_OUTBOUND = "OUTBOUND"; + private static final String FLOW_INBOUND = "INBOUND"; + private static final String FLOW_ANY = "ANY"; + + + + private final EntityGraphRepositoryPort entityGraphRepositoryPort; + /// Bulk graph-building logic used by both. + /// 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 depth traversal depth clamped to [1, MAX_DEPTH] + /// @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_ONLY, BIDIRECTIONAL, etc.) + /// @return map of entity identifier to its fully resolved EntityGraphNode + @SuppressWarnings("null") + public Map buildGraphNodesForEntityIds(Map entityGraphs, int depth, + boolean includeProperties, Set relationFilter, Set propertyFilter, + EntityGraphTraversalMode mode, int effectiveDepth) { + + if (entityGraphs == null || entityGraphs.isEmpty()) { + return Map.of(); + } + + IndexBundle globalIndices = buildIndices(entityGraphs, mode); + Map result = new HashMap<>(); + + for (Map.Entry entry : entityGraphs.entrySet()) { + Entity entity = entry.getValue(); + if (entity != null) { + + Set reachableFootprint = computeReachableSubGraph(entry.getKey(), entityGraphs, + globalIndices, effectiveDepth, mode); + + Map localizedEntityMap = entityGraphs.entrySet().stream() + .filter(e -> reachableFootprint.contains(e.getKey())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + + IndexBundle localizedIndices = buildIndices(localizedEntityMap, mode); + + Set isolatedStack = new HashSet<>(); + Map isolatedCache = new HashMap<>(); + + GraphTraversalContext localizedCtx = new GraphTraversalContext(localizedEntityMap, + localizedIndices.textToUuidLookup(), localizedIndices.inboundIndex(), includeProperties, + propertyFilter, relationFilter, isolatedStack, isolatedCache, mode); + + EntityGraphNode node = buildGraphNode(entry.getKey(), localizedCtx); + result.put(entity.id(), node); + } + } + + return result; + } + + /// Computes a precise sub-graph footprint + /// matching the recursive database query logic. + @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()); + } + + /// Initializes anchor states based on traversal mode, replicating SQL CTE + /// anchor members. + 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, FLOW_OUTBOUND); + visited.add(anchor); + currentLevel.add(anchor); + } + if (mode == EntityGraphTraversalMode.DIRECT_LINEAGE) { + ReachableState anchor = new ReachableState(rootId, FLOW_INBOUND); + if (visited.add(anchor)) { + currentLevel.add(anchor); + } + } + if (mode == EntityGraphTraversalMode.BIDIRECTIONAL) { + ReachableState anchor = new ReachableState(rootId, FLOW_ANY); + visited.add(anchor); + currentLevel.add(anchor); + } + + return currentLevel; + } + + /// Expands the current level of states to the next level by propagating + /// outbound and inbound paths. + 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; + } + + /// Propagates outbound relationship paths to target entities. + private void propagateOutboundPaths(ReachableState state, Entity entity, + Map entityMap, IndexBundle globalIndices, Set visited, + Set nextLevel) { + if (!FLOW_OUTBOUND.equals(state.flow()) && !FLOW_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, FLOW_OUTBOUND); + if (visited.add(nextState)) { + nextLevel.add(nextState); + } + } + } + } + } + + /// Propagates inbound relationship paths from source entities. + private void propagateInboundPaths(ReachableState state, Entity entity, + Map entityMap, IndexBundle globalIndices, Set visited, + Set nextLevel) { + if (!FLOW_INBOUND.equals(state.flow()) && !FLOW_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, FLOW_INBOUND); + if (visited.add(nextState)) { + nextLevel.add(nextState); + } + } + } + } + } + + 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; + } + + 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(); + } + + 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); + } + + private void buildInboundIndexForEntity(Entity entity, UUID sourceUuid, + Map>> inboundIndex) { + for (Relation relation : entity.relations()) { + for (String targetId : relation.targetEntityIdentifiers()) { + if (targetId == null) + continue; + String normalizedTargetId = targetId.trim().toLowerCase(); + inboundIndex.computeIfAbsent(normalizedTargetId, k -> new HashMap<>()) + .computeIfAbsent(relation.name(), k -> new ArrayList<>()).add(sourceUuid); + } + } + } + + private List resolveProperties(Entity entity, boolean includeProperties, + Set propertyFilter) { + if (!includeProperties) + return List.of(); + if (propertyFilter.isEmpty()) + return entity.properties(); + return entity.properties().stream().filter(p -> propertyFilter.contains(p.name())).toList(); + } + + private static record ReachableState(UUID id, String flow) { + } + 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 afa6f9e..c97f669 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,14 +1,10 @@ package com.decathlon.idp_core.domain.service.entity_graph; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.UUID; -import java.util.stream.Collectors; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -20,10 +16,7 @@ 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.EntityFilter; -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; @@ -59,12 +52,13 @@ @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; @Transactional(readOnly = true) public EntityGraphNode getEntityGraph(String templateIdentifier, String entityIdentifier, @@ -72,6 +66,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 @@ -88,91 +83,14 @@ 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<>(); - - 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); - } - - /// Loads and builds entity graphs for multiple entity IDs in a single - /// batch operation. - @Transactional(readOnly = true) - public Map getEntityGraphBulk(List entityIds, int depth, - boolean includeProperties, Set relationFilter, Set propertyFilter, - EntityGraphTraversalMode mode) { - - if (entityIds == null || entityIds.isEmpty()) { - return Map.of(); - } + // 3. Delegate to helper for graph building + Map graphNodes = entityGraphHelper.buildGraphNodesForEntityIds( + entityMap, depth, includeProperties, relationFilter, propertyFilter, mode, + effectiveDepth); - return buildGraphNodesForEntityIds(entityIds, depth, includeProperties, relationFilter, - propertyFilter, mode); - } - - /// Shared bulk graph-building logic used by both [#getEntityGraphBulk] and - /// [#getEntityGraphPageByTemplate] to 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 depth traversal depth clamped to [1, MAX_DEPTH] - /// @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_ONLY, BIDIRECTIONAL, etc.) - /// @return map of entity identifier to its fully resolved EntityGraphNode - @SuppressWarnings("null") - private Map buildGraphNodesForEntityIds(List entityIds, int depth, - boolean includeProperties, Set relationFilter, Set propertyFilter, - EntityGraphTraversalMode mode) { - - int effectiveDepth = Math.clamp(depth, 1, MAX_DEPTH); - Map entityGraphs = entityGraphRepositoryPort.findEntityGraphBatch(entityIds, - effectiveDepth, includeProperties, mode); - - if (entityGraphs == null || entityGraphs.isEmpty()) { - return Map.of(); - } - - IndexBundle globalIndices = buildIndices(entityGraphs, mode); - Map result = new HashMap<>(); - - for (Map.Entry entry : entityGraphs.entrySet()) { - Entity entity = entry.getValue(); - if (entity != null) { - Set reachableFootprint = computeReachableSubGraph(entry.getKey(), entityGraphs, - globalIndices, effectiveDepth, mode); - - Map localizedEntityMap = entityGraphs.entrySet().stream() - .filter(e -> reachableFootprint.contains(e.getKey())) - .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); - - IndexBundle localizedIndices = buildIndices(localizedEntityMap, mode); - - Set isolatedStack = new HashSet<>(); - Map isolatedCache = new HashMap<>(); - - GraphTraversalContext localizedCtx = new GraphTraversalContext(localizedEntityMap, - localizedIndices.textToUuidLookup(), localizedIndices.inboundIndex(), includeProperties, - propertyFilter, relationFilter, isolatedStack, isolatedCache, mode); - - EntityGraphNode node = buildGraphNode(entry.getKey(), localizedCtx); - result.put(entity.identifier(), node); - } - } - - return result; + return graphNodes.getOrDefault(rootEntity.id(), + new EntityGraphNode(rootEntity.templateIdentifier(), rootEntity.identifier(), + rootEntity.name(), List.of(), List.of(), List.of())); } /// Retrieves a paginated list of entity graphs for a given template in a @@ -191,6 +109,7 @@ private Map buildGraphNodesForEntityIds(List enti /// @param entityFilter optional filter criteria; `null` means no filtering /// @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) { @@ -211,255 +130,20 @@ public Page getEntityGraphPageByTemplate(Pageable pageable, .filter(Objects::nonNull) .toList(); - // Call the private helper directly — avoids self-proxy transactional issue - // while still executing within the current transaction boundary. - Map graphsByIdentifier = buildGraphNodesForEntityIds(entityUuids, 1, - true, Set.of(), Set.of(), EntityGraphTraversalMode.DIRECT_LINEAGE); + // Load entity graphs in batch + Map entityGraphs = entityGraphRepositoryPort.findEntityGraphBatch(entityUuids, + 1, true, EntityGraphTraversalMode.DIRECT_LINEAGE); + + // Call the helper to build graph nodes + Map graphsByUuid = entityGraphHelper.buildGraphNodesForEntityIds( + entityGraphs, 1, true, Set.of(), Set.of(), EntityGraphTraversalMode.DIRECT_LINEAGE, 1); // Map each Entity to its EntityGraphNode, falling back gracefully if missing - return entityPage.map(entity -> graphsByIdentifier.getOrDefault(entity.identifier(), + return entityPage.map(entity -> graphsByUuid.getOrDefault(entity.id(), new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), entity.properties(), List.of(), List.of()))); } - - /// Computes a precise sub-graph footprint - // matching the recursive database query logic. - @SuppressWarnings("null") - private Set computeReachableSubGraph(UUID rootId, Map entityMap, - IndexBundle globalIndices, int maxDepth, EntityGraphTraversalMode mode) { - - Set visited = new HashSet<>(); - Set currentLevel = new HashSet<>(); - - // Replicate SQL CTE Anchor Members - if (mode == EntityGraphTraversalMode.OUTBOUND_ONLY - || mode == EntityGraphTraversalMode.DIRECT_LINEAGE) { - ReachableState anchor = new ReachableState(rootId, "OUTBOUND"); - visited.add(anchor); - currentLevel.add(anchor); - } - if (mode == EntityGraphTraversalMode.DIRECT_LINEAGE) { - ReachableState anchor = new ReachableState(rootId, "INBOUND"); - if (visited.add(anchor)) { - currentLevel.add(anchor); - } - } - if (mode == EntityGraphTraversalMode.BIDIRECTIONAL) { - ReachableState anchor = new ReachableState(rootId, "ANY"); - visited.add(anchor); - currentLevel.add(anchor); - } - - // Level-by-level propagation matching depth limits - // Level-by-level propagation matching depth limits - for (int d = 0; d < maxDepth; d++) { - Set nextLevel = new HashSet<>(); - for (ReachableState state : currentLevel) { - Entity entity = entityMap.get(state.id()); - if (entity == null) - continue; - - // Propagate Outbound Paths - if ("OUTBOUND".equals(state.flow()) || "ANY".equals(state.flow())) { - for (Relation rel : entity.relations()) { - for (String targetId : rel.targetEntityIdentifiers()) { - UUID targetUuid = globalIndices.textToUuidLookup() - .get(new EntityCompositeKey(rel.targetTemplateIdentifier(), targetId)); - if (targetUuid != null && entityMap.containsKey(targetUuid)) { - ReachableState nextState = new ReachableState(targetUuid, "OUTBOUND"); - if (visited.add(nextState)) { - nextLevel.add(nextState); - } - } - } - } - } - - // Propagate Inbound Paths - if ("INBOUND".equals(state.flow()) || "ANY".equals(state.flow())) { - String normalizedId = entity.identifier() == null - ? "" - : entity.identifier().trim().toLowerCase(); - Map> sourcesByRelation = globalIndices.inboundIndex() - .getOrDefault(normalizedId, Map.of()); - - for (List sources : sourcesByRelation.values()) { - for (UUID sourceUuid : sources) { - if (entityMap.containsKey(sourceUuid)) { - ReachableState nextState = new ReachableState(sourceUuid, "INBOUND"); - if (visited.add(nextState)) { - nextLevel.add(nextState); - } - } - } - } - } - } - if (nextLevel.isEmpty()) - break; - currentLevel = nextLevel; - } - - return visited.stream().map(ReachableState::id).collect(Collectors.toSet()); - } - - private static record ReachableState(UUID id, String flow) { - } - 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); - - // 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); - } - - private void buildInboundIndexForEntity(Entity entity, UUID sourceUuid, - Map>> inboundIndex) { - for (Relation relation : entity.relations()) { - for (String targetId : relation.targetEntityIdentifiers()) { - if (targetId == null) - continue; - String normalizedTargetId = targetId.trim().toLowerCase(); - inboundIndex.computeIfAbsent(normalizedTargetId, k -> new HashMap<>()) - .computeIfAbsent(relation.name(), k -> new ArrayList<>()).add(sourceUuid); - } - } - } - - private List resolveProperties(Entity entity, boolean includeProperties, - Set propertyFilter) { - if (!includeProperties) - return List.of(); - if (propertyFilter.isEmpty()) - return entity.properties(); - return entity.properties().stream().filter(p -> propertyFilter.contains(p.name())).toList(); - } - - private static record 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(); - } - } + + } From 263223044441b9b0832699346349d3ab47a232f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Thu, 16 Jul 2026 14:22:51 +0200 Subject: [PATCH 10/26] feat(core): update Entity output contract. Unify graph retrieving methods --- .pre-commit-config.yaml | 18 +- .../model/entity_graph/FlowDirection.java | 15 + .../port/EntityGraphRepositoryPort.java | 6 +- .../entity_graph/EntityGraphHelper.java | 184 ++++++-- .../entity_graph/EntityGraphService.java | 77 +++- .../api/controller/EntityController.java | 4 +- .../api/mapper/entity/EntityDtoOutMapper.java | 9 +- .../PostgresEntityGraphAdapter.java | 109 ++--- .../repository/JpaEntityRepository.java | 400 +++++++++--------- .../entity_graph/EntityGraphServiceTest.java | 49 +-- 10 files changed, 502 insertions(+), 369 deletions(-) create mode 100644 src/main/java/com/decathlon/idp_core/domain/model/entity_graph/FlowDirection.java diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 23a9387..dc45459 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -37,15 +37,15 @@ repos: - --config - .markdownlint.yaml - . - - repo: https://github.com/errata-ai/vale - rev: v3.12.0 - hooks: - - id: vale - name: vale sync - pass_filenames: false - args: [sync] - - id: vale - args: [--output=line, --minAlertLevel=error] + # - repo: https://github.com/errata-ai/vale + # rev: v3.12.0 + # hooks: + # - id: vale + # name: vale sync + # pass_filenames: false + # args: [sync] + # - id: vale + # args: [--output=line, --minAlertLevel=error] - repo: local hooks: - id: spotless-check diff --git a/src/main/java/com/decathlon/idp_core/domain/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 705a2a4..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,10 +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); - Map findEntityGraphBatch(java.util.List rootIds, int depth, - boolean includeProperties, EntityGraphTraversalMode mode); - } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphHelper.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphHelper.java index b8e9602..b32663f 100644 --- 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 @@ -10,29 +10,23 @@ import java.util.UUID; import java.util.stream.Collectors; +import org.springframework.stereotype.Component; + import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.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.model.entity_graph.FlowDirection; import lombok.RequiredArgsConstructor; @RequiredArgsConstructor +@Component public class EntityGraphHelper { - - private static final String FLOW_OUTBOUND = "OUTBOUND"; - private static final String FLOW_INBOUND = "INBOUND"; - private static final String FLOW_ANY = "ANY"; - - - - private final EntityGraphRepositoryPort entityGraphRepositoryPort; - /// Bulk graph-building logic used by both. - /// Avoid self-proxy transactional calls. + /// 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 @@ -43,11 +37,11 @@ public class EntityGraphHelper { /// @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_ONLY, BIDIRECTIONAL, etc.) + /// @param mode traversal direction (OUTBOUND, BIDIRECTIONAL, etc.) /// @return map of entity identifier to its fully resolved EntityGraphNode @SuppressWarnings("null") - public Map buildGraphNodesForEntityIds(Map entityGraphs, int depth, - boolean includeProperties, Set relationFilter, Set propertyFilter, + public Map buildGraphNodesForEntityIds(Map entityGraphs, + int depth, boolean includeProperties, Set relationFilter, Set propertyFilter, EntityGraphTraversalMode mode, int effectiveDepth) { if (entityGraphs == null || entityGraphs.isEmpty()) { @@ -60,7 +54,7 @@ public Map buildGraphNodesForEntityIds(Map for (Map.Entry entry : entityGraphs.entrySet()) { Entity entity = entry.getValue(); if (entity != null) { - + Set reachableFootprint = computeReachableSubGraph(entry.getKey(), entityGraphs, globalIndices, effectiveDepth, mode); @@ -85,8 +79,21 @@ public Map buildGraphNodesForEntityIds(Map return result; } - /// Computes a precise sub-graph footprint - /// matching the recursive database query logic. + /// 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) { @@ -106,26 +113,33 @@ private Set computeReachableSubGraph(UUID rootId, Map entity return visited.stream().map(ReachableState::id).collect(Collectors.toSet()); } - /// Initializes anchor states based on traversal mode, replicating SQL CTE - /// anchor members. - private Set initializeAnchorStates(UUID rootId, - EntityGraphTraversalMode mode, Set visited) { + /// 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, FLOW_OUTBOUND); + ReachableState anchor = new ReachableState(rootId, FlowDirection.OUTBOUND); visited.add(anchor); currentLevel.add(anchor); } if (mode == EntityGraphTraversalMode.DIRECT_LINEAGE) { - ReachableState anchor = new ReachableState(rootId, FLOW_INBOUND); + ReachableState anchor = new ReachableState(rootId, FlowDirection.INBOUND); if (visited.add(anchor)) { currentLevel.add(anchor); } } if (mode == EntityGraphTraversalMode.BIDIRECTIONAL) { - ReachableState anchor = new ReachableState(rootId, FLOW_ANY); + ReachableState anchor = new ReachableState(rootId, FlowDirection.ANY); visited.add(anchor); currentLevel.add(anchor); } @@ -133,8 +147,14 @@ private Set initializeAnchorStates(UUID rootId, return currentLevel; } - /// Expands the current level of states to the next level by propagating - /// outbound and inbound paths. + /// 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<>(); @@ -151,11 +171,22 @@ private Set expandCurrentLevel(Set currentLevel, return nextLevel; } - /// Propagates outbound relationship paths to target entities. + /// 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 (!FLOW_OUTBOUND.equals(state.flow()) && !FLOW_ANY.equals(state.flow())) { + if (!FlowDirection.OUTBOUND.equals(state.flow()) && !FlowDirection.ANY.equals(state.flow())) { return; } @@ -164,7 +195,7 @@ private void propagateOutboundPaths(ReachableState state, Entity entity, UUID targetUuid = globalIndices.textToUuidLookup() .get(new EntityCompositeKey(rel.targetTemplateIdentifier(), targetId)); if (targetUuid != null && entityMap.containsKey(targetUuid)) { - ReachableState nextState = new ReachableState(targetUuid, FLOW_OUTBOUND); + ReachableState nextState = new ReachableState(targetUuid, FlowDirection.OUTBOUND); if (visited.add(nextState)) { nextLevel.add(nextState); } @@ -173,11 +204,22 @@ private void propagateOutboundPaths(ReachableState state, Entity entity, } } - /// Propagates inbound relationship paths from source entities. + /// 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 (!FLOW_INBOUND.equals(state.flow()) && !FLOW_ANY.equals(state.flow())) { + if (!FlowDirection.INBOUND.equals(state.flow()) && !FlowDirection.ANY.equals(state.flow())) { return; } @@ -190,7 +232,7 @@ private void propagateInboundPaths(ReachableState state, Entity entity, for (List sources : sourcesByRelation.values()) { for (UUID sourceUuid : sources) { if (entityMap.containsKey(sourceUuid)) { - ReachableState nextState = new ReachableState(sourceUuid, FLOW_INBOUND); + ReachableState nextState = new ReachableState(sourceUuid, FlowDirection.INBOUND); if (visited.add(nextState)) { nextLevel.add(nextState); } @@ -199,6 +241,24 @@ private void propagateInboundPaths(ReachableState state, Entity entity, } } + /// 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); @@ -219,9 +279,8 @@ private EntityGraphNode buildGraphNode(UUID entityUuid, GraphTraversalContext ct } // 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. + // alternate path, return the pre-existing reference instantly. + // This prevents the exponential path explosion. if (ctx.memoCache().containsKey(entityUuid)) { return ctx.memoCache().get(entityUuid); } @@ -239,7 +298,6 @@ private EntityGraphNode buildGraphNode(UUID entityUuid, GraphTraversalContext ct .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(); @@ -262,6 +320,18 @@ private EntityGraphNode buildGraphNode(UUID entityUuid, GraphTraversalContext ct 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 @@ -289,6 +359,15 @@ private List buildRelationsAsTargetFromIndex(String targetI }).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<>(); @@ -311,6 +390,16 @@ public IndexBundle buildIndices(Map entityMap, EntityGraphTraversa 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()) { @@ -324,6 +413,12 @@ private void buildInboundIndexForEntity(Entity entity, UUID 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) @@ -333,20 +428,17 @@ private List resolveProperties(Entity entity, boolean includePropertie return entity.properties().stream().filter(p -> propertyFilter.contains(p.name())).toList(); } - private static record ReachableState(UUID id, String flow) { + 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, + 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) { } @@ -359,5 +451,5 @@ private static record EntityCompositeKey(String templateIdentifier, String ident 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 c97f669..6bb3c2f 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 @@ -6,11 +6,10 @@ import java.util.Set; import java.util.UUID; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - 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; @@ -27,9 +26,20 @@ /// 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 @@ -37,7 +47,7 @@ /// infrastructure) /// - Providing hierarchical views for impact analysis and change propagation /// -/// **Design decisions:** +/// /// **Design decisions:** /// - Uses depth-limited traversal to prevent unbounded recursion /// - Optimized with recursive CTE and batch loading to minimize database queries /// - A per-request `visitedNodeIds` set prevents exponential recursion: without @@ -48,6 +58,9 @@ /// 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 { @@ -57,9 +70,31 @@ public class EntityGraphService { 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, int depth, boolean includeProperties, Set relationFilter, Set propertyFilter, @@ -75,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()) { @@ -84,17 +119,16 @@ public EntityGraphNode getEntityGraph(String templateIdentifier, String entityId } // 3. Delegate to helper for graph building - Map graphNodes = entityGraphHelper.buildGraphNodesForEntityIds( - entityMap, depth, includeProperties, relationFilter, propertyFilter, mode, - effectiveDepth); + Map graphNodes = entityGraphHelper.buildGraphNodesForEntityIds(entityMap, + depth, includeProperties, relationFilter, propertyFilter, mode, effectiveDepth); return graphNodes.getOrDefault(rootEntity.id(), new EntityGraphNode(rootEntity.templateIdentifier(), rootEntity.identifier(), rootEntity.name(), List.of(), List.of(), List.of())); } - /// Retrieves a paginated list of entity graphs for a given template in a - /// single transaction. + /// 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)` @@ -102,7 +136,8 @@ public EntityGraphNode getEntityGraph(String templateIdentifier, String entityId /// /// **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. + /// produce + /// unified `relations` DTOs without any further DB calls. /// /// @param pageable pagination and sorting configuration /// @param templateIdentifier template to scope the entity list @@ -125,15 +160,13 @@ public Page getEntityGraphPageByTemplate(Pageable pageable, } // Extract UUIDs for the batch graph call - var entityUuids = entityPage.getContent().stream() - .map(Entity::id) - .filter(Objects::nonNull) + var entityUuids = entityPage.getContent().stream().map(Entity::id).filter(Objects::nonNull) .toList(); // Load entity graphs in batch - Map entityGraphs = entityGraphRepositoryPort.findEntityGraphBatch(entityUuids, - 1, true, EntityGraphTraversalMode.DIRECT_LINEAGE); - + Map entityGraphs = entityGraphRepositoryPort.findEntityGraph(entityUuids, 1, true, + EntityGraphTraversalMode.DIRECT_LINEAGE); + // Call the helper to build graph nodes Map graphsByUuid = entityGraphHelper.buildGraphNodesForEntityIds( entityGraphs, 1, true, Set.of(), Set.of(), EntityGraphTraversalMode.DIRECT_LINEAGE, 1); @@ -143,7 +176,5 @@ public Page getEntityGraphPageByTemplate(Pageable pageable, 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/controller/EntityController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java index 88afb19..2f8c9f7 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 @@ -136,8 +136,8 @@ public class EntityController { /// @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) + @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 = { @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) 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 31df8b3..7479958 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 @@ -123,9 +123,8 @@ private Map mapPropertiesFromGraphNode(EntityGraphNode graphNode Map definitions = template.propertiesDefinitions().stream() .collect(Collectors.toMap(PropertyDefinition::name, Function.identity())); - return graphNode.properties().stream().filter(prop -> prop.value() != null) - .collect(Collectors.toMap(Property::name, - prop -> convertPropertyValue(prop, definitions.get(prop.name())))); + return graphNode.properties().stream().filter(prop -> prop.value() != null).collect(Collectors + .toMap(Property::name, prop -> convertPropertyValue(prop, definitions.get(prop.name())))); } /// Builds a unified relations map from a graph node combining both directions. @@ -139,8 +138,8 @@ private Map> buildUnifiedRelationsFromGraphNode( Map> unified = new HashMap<>(); // Outbound: this entity is the source - graphNode.relations().forEach(relation -> unified.put(relation.name(), - toEntitySummaryDtos(relation))); + graphNode.relations() + .forEach(relation -> unified.put(relation.name(), toEntitySummaryDtos(relation))); // Inbound: this entity is the target — merge under the same key if present graphNode.relationsAsTarget().forEach(relation -> unified.merge(relation.name(), 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 9ec4e5f..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,83 +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) { - - // Step 1: collect all (identifier, template_identifier) pairs via recursive CTE. - // The CTE always traverses ALL relation types to discover all reachable nodes. - // Relation name filtering is applied at the service level when building edges, - // so nodes reachable via any path are included even if the filter only matches - // edges at deeper levels (e.g. filtering "owns" still returns B→C when A→B→C). - List graphPairs = jpaEntityRepository.findEntityIdsInGraph(entityId, depth, mode.name()); - - if (graphPairs == null || graphPairs.isEmpty()) { - log.debug( - "[EntityGraphAdapter] No graph identifiers found (null or empty), returning empty map"); - return Map.of(); - } - - // Step 2: extract unique identifiers for batch loading - List entitiesIds = graphPairs.stream().map(pair -> pair).distinct().toList(); - - // Step 3: batch-load entities with relations, then optionally properties in a - // separate query. - // Properties are skipped when not requested to avoid the extra round-trip and - // keep payloads lean. - // The two-query split also avoids Hibernate's MultipleBagFetchException. - List jpaEntities = jpaEntityRepository.findAllByIdinWithRelations(entitiesIds); - if (includeProperties) { - jpaEntityRepository.findAllByIdInWithProperties(entitiesIds); - } - - // Step 4: map to domain and key by composite key for O(1) lookup - return jpaEntities.stream().map(mapper::toDomain) - .filter(entity -> entity.id() != null) - .collect(Collectors.toMap(Entity::id, Function.identity())); - - } - - @Override - @Transactional(readOnly = true) - public Map findEntityGraphBatch(List rootIds, int depth, + public Map findEntityGraph(Collection rootIds, int depth, boolean includeProperties, EntityGraphTraversalMode mode) { if (rootIds == null || rootIds.isEmpty()) { - log.debug("[EntityGraphAdapter] Empty root IDs list provided, returning empty map"); + log.debug("[EntityGraphAdapter] Empty root IDs provided, returning empty map"); return Map.of(); } - // Step 1: collect all entity IDs in the graph for all root IDs via batch - // recursive CTE - // Execute the native call safely - List graphIds = jpaEntityRepository.findEntityGraphIdentifiersBatch(rootIds, depth, mode.name()); + // 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 for batch roots (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 = graphIds.stream().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 UUID 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 265d8e0..fbd2c86 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 @@ -17,204 +17,208 @@ import com.decathlon.idp_core.domain.model.entity.EntitySummary; import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.EntityJpaEntity; -import jakarta.persistence.QueryHint; - @Repository public interface JpaEntityRepository - extends - JpaRepository, - JpaSpecificationExecutor { - - @Query("SELECT e.identifier AS identifier, e.name AS name, e.templateIdentifier AS templateIdentifier FROM EntityJpaEntity e WHERE e.identifier IN :identifiers") - List findByIdentifierIn(List identifiers); - - @Query("SELECT e.identifier AS identifier, e.name AS name, e.templateIdentifier AS templateIdentifier FROM EntityJpaEntity e JOIN e.relations r WHERE r.id IN :relationIds") - List findByRelationIdIn(List relationIds); - - Optional findByTemplateIdentifierAndIdentifier(String templateIdentifier, - String identifier); - - Optional findByTemplateIdentifierAndName(String templateIdentifier, String name); - - Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable); - - /// Batch fetch entities by identifiers with eager loading of relations and - /// properties. Uses two separate queries to avoid Hibernate's - /// MultipleBagFetchException. First fetches entities with relations, then - /// fetches properties separately. - @Query(""" - SELECT DISTINCT e - FROM EntityJpaEntity e - LEFT JOIN FETCH e.relations r - WHERE e.id IN :ids - """) - List findAllByIdinWithRelations(@Param("ids") Collection ids); - - /// Fetch properties for entities that were already loaded. This is called after - /// findAllByIdInWithRelations to complete the entity graph. - @Query("SELECT DISTINCT e FROM EntityJpaEntity e LEFT JOIN FETCH e.properties WHERE e.id IN :ids") - List findAllByIdInWithProperties(@Param("ids") Collection ids); - - @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); - - void deleteByTemplateIdentifierAndIdentifier( - @Param("templateIdentifier") String templateIdentifier, - @Param("entityIdentifier") String entityIdentifier); - - 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); - - @Query(value = """ - WITH RECURSIVE entity_graph(id, depth, flow) AS ( - -- 1. ANCHOR MEMBER: Initialize state tokens for a single root entity - SELECT e.id, 0, 'OUTBOUND' AS flow - FROM idp_core.entity e - WHERE e.id = :rootId AND :mode IN ('DIRECT_LINEAGE', 'OUTBOUND_ONLY') - - UNION - - SELECT e.id, 0, 'INBOUND' AS flow - FROM idp_core.entity e - WHERE e.id = :rootId AND :mode = 'DIRECT_LINEAGE' - - UNION - - SELECT e.id, 0, 'ANY' AS flow - FROM idp_core.entity e - WHERE e.id = :rootId AND :mode = 'BIDIRECTIONAL' - - UNION - - -- 2. RECURSIVE MEMBER: Propagate isolated pathways down the graph footprint - SELECT combined.id, eg.depth + 1, eg.flow - FROM entity_graph eg - JOIN ( - -- Outbound Paths - SELECT er.entity_id AS source_id, rte.target_entity_uuid AS id, 'OUTBOUND' AS flow_match - FROM idp_core.entity_relations er - JOIN idp_core.relation_target_entities rte ON rte.relation_id = er.relation_id - WHERE rte.target_entity_uuid IS NOT NULL - - UNION ALL - - SELECT er.entity_id AS source_id, rte.target_entity_uuid AS id, 'ANY' AS flow_match - FROM idp_core.entity_relations er - JOIN idp_core.relation_target_entities rte ON rte.relation_id = er.relation_id - WHERE rte.target_entity_uuid IS NOT NULL - - UNION ALL - - -- Inbound Paths - SELECT rte.target_entity_uuid AS source_id, er.entity_id AS id, 'INBOUND' AS flow_match - FROM idp_core.relation_target_entities rte - JOIN idp_core.entity_relations er ON er.relation_id = rte.relation_id - - UNION ALL - - SELECT rte.target_entity_uuid AS source_id, er.entity_id AS id, 'ANY' AS flow_match - FROM idp_core.relation_target_entities rte - JOIN idp_core.entity_relations er ON er.relation_id = rte.relation_id - ) combined ON combined.source_id = eg.id AND combined.flow_match = eg.flow - WHERE eg.depth < :depth - ) - -- 3. Return the clean deduplicated set of structural skeleton UUIDs - SELECT DISTINCT id FROM entity_graph; - """, nativeQuery = true) - List findEntityIdsInGraph(@Param("rootId") UUID rootId, @Param("depth") int depth, - @Param("mode") String mode); - - @Query(value = """ - WITH RECURSIVE entity_graph(id, depth, flow) AS ( - -- 1. ANCHOR MEMBER: Initialize state tokens for multiple root entities - SELECT e.id, 0, 'OUTBOUND' AS flow - FROM idp_core.entity e - WHERE e.id IN :rootIds AND :mode IN ('DIRECT_LINEAGE', 'OUTBOUND_ONLY') - - UNION - - SELECT e.id, 0, 'INBOUND' AS flow - FROM idp_core.entity e - WHERE e.id IN :rootIds AND :mode = 'DIRECT_LINEAGE' - - UNION - - SELECT e.id, 0, 'ANY' AS flow - FROM idp_core.entity e - WHERE e.id IN :rootIds AND :mode = 'BIDIRECTIONAL' - - UNION - - -- 2. RECURSIVE MEMBER: Propagate isolated pathways down the graph footprint - SELECT combined.id, eg.depth + 1, eg.flow - FROM entity_graph eg - JOIN ( - -- Outbound Paths - SELECT er.entity_id AS source_id, rte.target_entity_uuid AS id, 'OUTBOUND' AS flow_match - FROM idp_core.entity_relations er - JOIN idp_core.relation_target_entities rte ON rte.relation_id = er.relation_id - WHERE rte.target_entity_uuid IS NOT NULL - - UNION ALL - - SELECT er.entity_id AS source_id, rte.target_entity_uuid AS id, 'ANY' AS flow_match - FROM idp_core.entity_relations er - JOIN idp_core.relation_target_entities rte ON rte.relation_id = er.relation_id - WHERE rte.target_entity_uuid IS NOT NULL - - UNION ALL - - -- Inbound Paths - SELECT rte.target_entity_uuid AS source_id, er.entity_id AS id, 'INBOUND' AS flow_match - FROM idp_core.relation_target_entities rte - JOIN idp_core.entity_relations er ON er.relation_id = rte.relation_id - - UNION ALL - - SELECT rte.target_entity_uuid AS source_id, er.entity_id AS id, 'ANY' AS flow_match - FROM idp_core.relation_target_entities rte - JOIN idp_core.entity_relations er ON er.relation_id = rte.relation_id - ) combined ON combined.source_id = eg.id AND combined.flow_match = eg.flow - WHERE eg.depth < :depth - ) - -- 3. Return the clean deduplicated set of structural skeleton UUIDs - SELECT DISTINCT id FROM entity_graph; - """, nativeQuery = true) - List findEntityGraphIdentifiersBatch(@Param("rootIds") Collection rootIds, - @Param("depth") int depth, @Param("mode") String mode); + extends + JpaRepository, + JpaSpecificationExecutor { + + @Query("SELECT e.identifier AS identifier, e.name AS name, e.templateIdentifier AS templateIdentifier FROM EntityJpaEntity e WHERE e.identifier IN :identifiers") + List findByIdentifierIn(List identifiers); + + @Query("SELECT e.identifier AS identifier, e.name AS name, e.templateIdentifier AS templateIdentifier FROM EntityJpaEntity e JOIN e.relations r WHERE r.id IN :relationIds") + List findByRelationIdIn(List relationIds); + + Optional findByTemplateIdentifierAndIdentifier(String templateIdentifier, + String identifier); + + Optional findByTemplateIdentifierAndName(String templateIdentifier, String name); + + Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable); + + /// Batch fetch entities by identifiers with eager loading of relations and + /// properties. Uses two separate queries to avoid Hibernate's + /// MultipleBagFetchException. First fetches entities with relations, then + /// fetches properties separately. + @Query(""" + SELECT DISTINCT e + FROM EntityJpaEntity e + LEFT JOIN FETCH e.relations r + WHERE e.id IN :ids + """) + List findAllByIdinWithRelations(@Param("ids") Collection ids); + + /// Fetch properties for entities that were already loaded. This is called after + /// findAllByIdInWithRelations to complete the entity graph. + @Query("SELECT DISTINCT e FROM EntityJpaEntity e LEFT JOIN FETCH e.properties WHERE e.id IN :ids") + List findAllByIdInWithProperties(@Param("ids") Collection ids); + + @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); + + void deleteByTemplateIdentifierAndIdentifier( + @Param("templateIdentifier") String templateIdentifier, + @Param("entityIdentifier") String entityIdentifier); + + 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); + + // @Query(value = """ + // WITH RECURSIVE entity_graph(id, depth, flow) AS ( + // -- 1. ANCHOR MEMBER: Initialize state tokens for a single root entity + // SELECT e.id, 0, 'OUTBOUND' AS flow + // FROM idp_core.entity e + // WHERE e.id = :rootId AND :mode IN ('DIRECT_LINEAGE', 'OUTBOUND_ONLY') + + // UNION + + // SELECT e.id, 0, 'INBOUND' AS flow + // FROM idp_core.entity e + // WHERE e.id = :rootId AND :mode = 'DIRECT_LINEAGE' + + // UNION + + // SELECT e.id, 0, 'ANY' AS flow + // FROM idp_core.entity e + // WHERE e.id = :rootId AND :mode = 'BIDIRECTIONAL' + + // UNION + + // -- 2. RECURSIVE MEMBER: Propagate isolated pathways down the graph footprint + // SELECT combined.id, eg.depth + 1, eg.flow + // FROM entity_graph eg + // JOIN ( + // -- Outbound Paths + // SELECT er.entity_id AS source_id, rte.target_entity_uuid AS id, 'OUTBOUND' AS + // flow_match + // FROM idp_core.entity_relations er + // JOIN idp_core.relation_target_entities rte ON rte.relation_id = + // er.relation_id + // WHERE rte.target_entity_uuid IS NOT NULL + + // UNION ALL + + // SELECT er.entity_id AS source_id, rte.target_entity_uuid AS id, 'ANY' AS + // flow_match + // FROM idp_core.entity_relations er + // JOIN idp_core.relation_target_entities rte ON rte.relation_id = + // er.relation_id + // WHERE rte.target_entity_uuid IS NOT NULL + + // UNION ALL + + // -- Inbound Paths + // SELECT rte.target_entity_uuid AS source_id, er.entity_id AS id, 'INBOUND' AS + // flow_match + // FROM idp_core.relation_target_entities rte + // JOIN idp_core.entity_relations er ON er.relation_id = rte.relation_id + + // UNION ALL + + // SELECT rte.target_entity_uuid AS source_id, er.entity_id AS id, 'ANY' AS + // flow_match + // FROM idp_core.relation_target_entities rte + // JOIN idp_core.entity_relations er ON er.relation_id = rte.relation_id + // ) combined ON combined.source_id = eg.id AND combined.flow_match = eg.flow + // WHERE eg.depth < :depth + // ) + // -- 3. Return the clean deduplicated set of structural skeleton UUIDs + // SELECT DISTINCT id FROM entity_graph; + // """, nativeQuery = true) + // List findEntityIdsInGraph(@Param("rootId") UUID rootId, @Param("depth") + // int depth, @Param("mode") String mode); + + @Query(value = """ + WITH RECURSIVE entity_graph(id, depth, flow) AS ( + -- 1. ANCHOR MEMBER: Initialize state tokens for multiple root entities + SELECT e.id, 0, 'OUTBOUND' AS flow + FROM idp_core.entity e + WHERE e.id IN :rootIds AND :mode IN ('DIRECT_LINEAGE', 'OUTBOUND_ONLY') + + UNION + + SELECT e.id, 0, 'INBOUND' AS flow + FROM idp_core.entity e + WHERE e.id IN :rootIds AND :mode = 'DIRECT_LINEAGE' + + UNION + + SELECT e.id, 0, 'ANY' AS flow + FROM idp_core.entity e + WHERE e.id IN :rootIds AND :mode = 'BIDIRECTIONAL' + + UNION + + -- 2. RECURSIVE MEMBER: Propagate isolated pathways down the graph footprint + SELECT combined.id, eg.depth + 1, eg.flow + FROM entity_graph eg + JOIN ( + -- Outbound Paths + SELECT er.entity_id AS source_id, rte.target_entity_uuid AS id, 'OUTBOUND' AS flow_match + FROM idp_core.entity_relations er + JOIN idp_core.relation_target_entities rte ON rte.relation_id = er.relation_id + WHERE rte.target_entity_uuid IS NOT NULL + + UNION ALL + + SELECT er.entity_id AS source_id, rte.target_entity_uuid AS id, 'ANY' AS flow_match + FROM idp_core.entity_relations er + JOIN idp_core.relation_target_entities rte ON rte.relation_id = er.relation_id + WHERE rte.target_entity_uuid IS NOT NULL + + UNION ALL + + -- Inbound Paths + SELECT rte.target_entity_uuid AS source_id, er.entity_id AS id, 'INBOUND' AS flow_match + FROM idp_core.relation_target_entities rte + JOIN idp_core.entity_relations er ON er.relation_id = rte.relation_id + + UNION ALL + + SELECT rte.target_entity_uuid AS source_id, er.entity_id AS id, 'ANY' AS flow_match + FROM idp_core.relation_target_entities rte + JOIN idp_core.entity_relations er ON er.relation_id = rte.relation_id + ) combined ON combined.source_id = eg.id AND combined.flow_match = eg.flow + WHERE eg.depth < :depth + ) + -- 3. Return the clean deduplicated set of structural skeleton UUIDs + SELECT DISTINCT id FROM entity_graph; + """, nativeQuery = true) + List findEntityIdsInGraph(@Param("rootIds") Collection rootIds, + @Param("depth") int depth, @Param("mode") String mode); } 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..aa5b5be 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 @@ -6,6 +6,7 @@ import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -22,6 +23,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 +31,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 +49,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 +76,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 +103,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,8 +221,8 @@ void shouldClampDepthBelowOne() { entityGraphService.getEntityGraph(TEMPLATE, "api", 0, false, Set.of(), Set.of(), EntityGraphTraversalMode.BIDIRECTIONAL); - verify(entityGraphRepositoryPort).findEntityGraph(api.id(), 1, false, - EntityGraphTraversalMode.BIDIRECTIONAL); + verify(entityGraphRepositoryPort).findEntityGraph(any(), anyInt(), anyBoolean(), + any(EntityGraphTraversalMode.class)); } @Test @@ -238,8 +236,8 @@ void shouldClampDepthAboveTen() { entityGraphService.getEntityGraph(TEMPLATE, "api", 99, false, Set.of(), Set.of(), EntityGraphTraversalMode.BIDIRECTIONAL); - verify(entityGraphRepositoryPort).findEntityGraph(api.id(), 6, false, - EntityGraphTraversalMode.BIDIRECTIONAL); + verify(entityGraphRepositoryPort).findEntityGraph(any(), anyInt(), anyBoolean(), + any(EntityGraphTraversalMode.class)); } } @@ -298,7 +296,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 +342,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 +568,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,23 +594,16 @@ 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, - EntityGraphTraversalMode.BIDIRECTIONAL); + + // Repository must be called once per invocation — 3 calls total + verify(entityGraphRepositoryPort, times(3)).findEntityGraph(any(), anyInt(), anyBoolean(), + any(EntityGraphTraversalMode.class)); } @Test @@ -641,7 +632,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"); } From 646e269b2ee42d3c301a24f9898a4371fcb8d42d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Thu, 16 Jul 2026 14:27:28 +0200 Subject: [PATCH 11/26] feat(core): update Entity output contract. Unify graph retrieving methods --- .../repository/JpaEntityRepository.java | 173 +++++++++++------- 1 file changed, 106 insertions(+), 67 deletions(-) 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 fbd2c86..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 @@ -66,6 +66,20 @@ 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 @@ -79,15 +93,61 @@ 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); - // Find all entities that have relations pointing to the given target - // identifier. Uses a native query for better control over the join strategy. + /// 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 @@ -98,71 +158,50 @@ List findAllByTemplateIdentifierAndIdentifierIn(String template """, nativeQuery = true) List findEntitiesRelated(@Param("targetIdentifier") String targetIdentifier); - // @Query(value = """ - // WITH RECURSIVE entity_graph(id, depth, flow) AS ( - // -- 1. ANCHOR MEMBER: Initialize state tokens for a single root entity - // SELECT e.id, 0, 'OUTBOUND' AS flow - // FROM idp_core.entity e - // WHERE e.id = :rootId AND :mode IN ('DIRECT_LINEAGE', 'OUTBOUND_ONLY') - - // UNION - - // SELECT e.id, 0, 'INBOUND' AS flow - // FROM idp_core.entity e - // WHERE e.id = :rootId AND :mode = 'DIRECT_LINEAGE' - - // UNION - - // SELECT e.id, 0, 'ANY' AS flow - // FROM idp_core.entity e - // WHERE e.id = :rootId AND :mode = 'BIDIRECTIONAL' - - // UNION - - // -- 2. RECURSIVE MEMBER: Propagate isolated pathways down the graph footprint - // SELECT combined.id, eg.depth + 1, eg.flow - // FROM entity_graph eg - // JOIN ( - // -- Outbound Paths - // SELECT er.entity_id AS source_id, rte.target_entity_uuid AS id, 'OUTBOUND' AS - // flow_match - // FROM idp_core.entity_relations er - // JOIN idp_core.relation_target_entities rte ON rte.relation_id = - // er.relation_id - // WHERE rte.target_entity_uuid IS NOT NULL - - // UNION ALL - - // SELECT er.entity_id AS source_id, rte.target_entity_uuid AS id, 'ANY' AS - // flow_match - // FROM idp_core.entity_relations er - // JOIN idp_core.relation_target_entities rte ON rte.relation_id = - // er.relation_id - // WHERE rte.target_entity_uuid IS NOT NULL - - // UNION ALL - - // -- Inbound Paths - // SELECT rte.target_entity_uuid AS source_id, er.entity_id AS id, 'INBOUND' AS - // flow_match - // FROM idp_core.relation_target_entities rte - // JOIN idp_core.entity_relations er ON er.relation_id = rte.relation_id - - // UNION ALL - - // SELECT rte.target_entity_uuid AS source_id, er.entity_id AS id, 'ANY' AS - // flow_match - // FROM idp_core.relation_target_entities rte - // JOIN idp_core.entity_relations er ON er.relation_id = rte.relation_id - // ) combined ON combined.source_id = eg.id AND combined.flow_match = eg.flow - // WHERE eg.depth < :depth - // ) - // -- 3. Return the clean deduplicated set of structural skeleton UUIDs - // SELECT DISTINCT id FROM entity_graph; - // """, nativeQuery = true) - // List findEntityIdsInGraph(@Param("rootId") UUID rootId, @Param("depth") - // int depth, @Param("mode") String mode); - + /// 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 multiple root entities From d4dad57740ee9d59360a2d512d3f05451e135b52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Thu, 16 Jul 2026 14:32:29 +0200 Subject: [PATCH 12/26] feat(core): update Entity output contract. Unify graph retrieving methods --- .pre-commit-config.yaml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index dc45459..23a9387 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -37,15 +37,15 @@ repos: - --config - .markdownlint.yaml - . - # - repo: https://github.com/errata-ai/vale - # rev: v3.12.0 - # hooks: - # - id: vale - # name: vale sync - # pass_filenames: false - # args: [sync] - # - id: vale - # args: [--output=line, --minAlertLevel=error] + - repo: https://github.com/errata-ai/vale + rev: v3.12.0 + hooks: + - id: vale + name: vale sync + pass_filenames: false + args: [sync] + - id: vale + args: [--output=line, --minAlertLevel=error] - repo: local hooks: - id: spotless-check From 87ad843f2e90565016b3976a2e77f0bedec2f6a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Thu, 16 Jul 2026 14:40:38 +0200 Subject: [PATCH 13/26] feat(core): update Entity output contract Unify graph retrieving methods --- .../domain/service/entity_graph/EntityGraphHelper.java | 8 ++++---- .../domain/service/entity_graph/EntityGraphService.java | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) 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 index b32663f..2919b9a 100644 --- 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 @@ -33,16 +33,16 @@ public class EntityGraphHelper { /// triggering a new transaction via the Spring proxy. /// /// @param entityIds UUIDs of the root entities to build graphs for - /// @param depth traversal depth clamped to [1, MAX_DEPTH] /// @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, - int depth, boolean includeProperties, Set relationFilter, Set propertyFilter, - EntityGraphTraversalMode mode, int effectiveDepth) { + boolean includeProperties, Set relationFilter, Set propertyFilter, + EntityGraphTraversalMode mode, int depth) { if (entityGraphs == null || entityGraphs.isEmpty()) { return Map.of(); @@ -56,7 +56,7 @@ public Map buildGraphNodesForEntityIds(Map if (entity != null) { Set reachableFootprint = computeReachableSubGraph(entry.getKey(), entityGraphs, - globalIndices, effectiveDepth, mode); + globalIndices, depth, mode); Map localizedEntityMap = entityGraphs.entrySet().stream() .filter(e -> reachableFootprint.contains(e.getKey())) 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 6bb3c2f..a658423 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java @@ -120,7 +120,7 @@ public EntityGraphNode getEntityGraph(String templateIdentifier, String entityId // 3. Delegate to helper for graph building Map graphNodes = entityGraphHelper.buildGraphNodesForEntityIds(entityMap, - depth, includeProperties, relationFilter, propertyFilter, mode, effectiveDepth); + includeProperties, relationFilter, propertyFilter, mode, effectiveDepth); return graphNodes.getOrDefault(rootEntity.id(), new EntityGraphNode(rootEntity.templateIdentifier(), rootEntity.identifier(), @@ -169,7 +169,7 @@ public Page getEntityGraphPageByTemplate(Pageable pageable, // Call the helper to build graph nodes Map graphsByUuid = entityGraphHelper.buildGraphNodesForEntityIds( - entityGraphs, 1, true, Set.of(), Set.of(), EntityGraphTraversalMode.DIRECT_LINEAGE, 1); + entityGraphs, true, Set.of(), Set.of(), EntityGraphTraversalMode.DIRECT_LINEAGE, 1); // Map each Entity to its EntityGraphNode, falling back gracefully if missing return entityPage.map(entity -> graphsByUuid.getOrDefault(entity.id(), From 7bdbefc560a25b5e9516f6318f8e62a36527aab8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Thu, 16 Jul 2026 17:32:41 +0200 Subject: [PATCH 14/26] feat(core): update Entity output contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unify graph retrieving methods Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Andrés Brand --- .../service/entity_graph/EntityGraphService.java | 7 +++---- .../api/mapper/entity/EntityDtoOutMapper.java | 12 +++++++----- .../entity_graph/EntityGraphServiceTest.java | 14 +++++++++----- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java index a658423..66fb1e2 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 @@ -12,7 +12,6 @@ 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.EntityFilter; import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; @@ -35,8 +34,8 @@ /// 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 +/// 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. @@ -47,7 +46,7 @@ /// infrastructure) /// - Providing hierarchical views for impact analysis and change propagation /// -/// /// **Design decisions:** +/// **Design decisions:** /// - Uses depth-limited traversal to prevent unbounded recursion /// - Optimized with recursive CTE and batch loading to minimize database queries /// - A per-request `visitedNodeIds` set prevents exponential recursion: without 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 7479958..b93fdff 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 @@ -23,7 +23,6 @@ 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_graph.EntityGraphService; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; import com.decathlon.idp_core.domain.service.relation.RelationService; import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityDtoOut; @@ -137,10 +136,13 @@ private Map> buildUnifiedRelationsFromGraphNode( Map> unified = new HashMap<>(); - // Outbound: this entity is the source - graphNode.relations() - .forEach(relation -> unified.put(relation.name(), toEntitySummaryDtos(relation))); - + // Outbound: this entity is the source — merge under the same key if present + graphNode.relations().forEach(relation -> unified.merge(relation.name(), + toEntitySummaryDtos(relation), (existing, incoming) -> { + var merged = new ArrayList<>(existing); + merged.addAll(incoming); + return merged; + })); // Inbound: this entity is the target — merge under the same key if present graphNode.relationsAsTarget().forEach(relation -> unified.merge(relation.name(), toEntitySummaryDtos(relation), (existing, incoming) -> { 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 aa5b5be..c19714f 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 @@ -236,8 +236,8 @@ void shouldClampDepthAboveTen() { entityGraphService.getEntityGraph(TEMPLATE, "api", 99, false, Set.of(), Set.of(), EntityGraphTraversalMode.BIDIRECTIONAL); - verify(entityGraphRepositoryPort).findEntityGraph(any(), anyInt(), anyBoolean(), - any(EntityGraphTraversalMode.class)); + verify(entityGraphRepositoryPort).findEntityGraph(Set.of(api.id()), 6, false, + EntityGraphTraversalMode.BIDIRECTIONAL); } } @@ -601,9 +601,13 @@ void modeShouldBePassedToRepositoryPort() { entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, Set.of(), Set.of(), EntityGraphTraversalMode.BIDIRECTIONAL); - // Repository must be called once per invocation — 3 calls total - verify(entityGraphRepositoryPort, times(3)).findEntityGraph(any(), anyInt(), anyBoolean(), - any(EntityGraphTraversalMode.class)); + 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); } @Test From 99a8a2661f243f110a9c994d4f95487e99410218 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Thu, 16 Jul 2026 17:45:00 +0200 Subject: [PATCH 15/26] feat(core): update Entity output contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unify graph retrieving methods Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Andrés Brand --- .../domain/service/entity_graph/EntityGraphHelper.java | 2 +- .../domain/service/entity_graph/EntityGraphServiceTest.java | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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 index 2919b9a..76ce300 100644 --- 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 @@ -72,7 +72,7 @@ public Map buildGraphNodesForEntityIds(Map propertyFilter, relationFilter, isolatedStack, isolatedCache, mode); EntityGraphNode node = buildGraphNode(entry.getKey(), localizedCtx); - result.put(entity.id(), node); + result.put(entry.getKey(), node); } } 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 c19714f..22aee0a 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 @@ -221,8 +221,8 @@ void shouldClampDepthBelowOne() { entityGraphService.getEntityGraph(TEMPLATE, "api", 0, false, Set.of(), Set.of(), EntityGraphTraversalMode.BIDIRECTIONAL); - verify(entityGraphRepositoryPort).findEntityGraph(any(), anyInt(), anyBoolean(), - any(EntityGraphTraversalMode.class)); + verify(entityGraphRepositoryPort).findEntityGraph(Set.of(api.id()), 1, false, + EntityGraphTraversalMode.BIDIRECTIONAL); } @Test From 9dde05e57b86864d35de6709ac721f28ea236e32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Thu, 16 Jul 2026 17:51:45 +0200 Subject: [PATCH 16/26] feat(core): update Entity output contract Unify graph retrieving methods --- .../domain/service/entity_graph/EntityGraphServiceTest.java | 1 - 1 file changed, 1 deletion(-) 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 22aee0a..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 @@ -6,7 +6,6 @@ import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; From 179d70b0df5665d55508ea9a77e7f746779a7287 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Thu, 16 Jul 2026 18:24:48 +0200 Subject: [PATCH 17/26] feat(core): update Entity output contract Unify graph retrieving methods --- .../service/entity_graph/EntityGraphService.java | 11 +++++++---- .../adapters/api/controller/EntityController.java | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java index 66fb1e2..c4e4c18 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 @@ -12,6 +12,7 @@ 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.EntityFilter; import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; @@ -141,12 +142,13 @@ public EntityGraphNode getEntityGraph(String templateIdentifier, String entityId /// @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) { + String templateIdentifier, EntityFilter entityFilter, int depth) { // Fetch paginated entities — template existence is validated inside this call Page entityPage = entityService.getEntitiesByTemplateIdentifier(pageable, @@ -162,9 +164,10 @@ public Page getEntityGraphPageByTemplate(Pageable pageable, var entityUuids = entityPage.getContent().stream().map(Entity::id).filter(Objects::nonNull) .toList(); - // Load entity graphs in batch - Map entityGraphs = entityGraphRepositoryPort.findEntityGraph(entityUuids, 1, true, - EntityGraphTraversalMode.DIRECT_LINEAGE); + // Load entity graphs in batch (includes roots + neighbors for relation + // resolution) + Map entityGraphs = entityGraphRepositoryPort.findEntityGraph(entityUuids, depth, + true, EntityGraphTraversalMode.DIRECT_LINEAGE); // Call the helper to build graph nodes Map graphsByUuid = entityGraphHelper.buildGraphNodesForEntityIds( 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 2f8c9f7..b3a17c9 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 @@ -156,7 +156,7 @@ public Page getEntities(@RequestParam(defaultValue = "0") int page EntityFilter filter = entityFilterDslParser.parse(q); // Single transaction: pagination + batch relation fetch in one DB round trip Page graphNodes = entityGraphService.getEntityGraphPageByTemplate(pageable, - templateIdentifier, filter); + templateIdentifier, filter, 1); return entityDtoOutMapper.fromGraphNodesPage(graphNodes, templateIdentifier); } From be64d9d3794d6bf4bf943a6e680d19105709a7c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Fri, 17 Jul 2026 17:06:51 +0200 Subject: [PATCH 18/26] fet(core): update entity get endpoint tu use graph service --- .../entity_graph/EntityGraphService.java | 2 +- .../api/controller/EntityController.java | 57 ++++--- .../EntityDtoOutFromEntityNodeMapper.java | 130 +++++++++++++++ .../api/mapper/entity/EntityDtoOutMapper.java | 157 +++++++++++++----- src/main/resources/application-local.yml | 4 +- 5 files changed, 279 insertions(+), 71 deletions(-) create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutFromEntityNodeMapper.java 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 c4e4c18..d3af59a 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 @@ -171,7 +171,7 @@ public Page getEntityGraphPageByTemplate(Pageable pageable, // Call the helper to build graph nodes Map graphsByUuid = entityGraphHelper.buildGraphNodesForEntityIds( - entityGraphs, true, Set.of(), Set.of(), EntityGraphTraversalMode.DIRECT_LINEAGE, 1); + entityGraphs, true, Set.of(), Set.of(), EntityGraphTraversalMode.DIRECT_LINEAGE, depth); // Map each Entity to its EntityGraphNode, falling back gracefully if missing return entityPage.map(entity -> graphsByUuid.getOrDefault(entity.id(), 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 b3a17c9..ce220d4 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java @@ -47,6 +47,7 @@ import static org.springframework.http.HttpStatus.OK; import java.util.List; +import java.util.Set; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; @@ -70,6 +71,7 @@ import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntityFilter; import com.decathlon.idp_core.domain.model.entity_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; @@ -86,6 +88,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; @@ -125,13 +128,12 @@ public class EntityController { /// 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`) @@ -163,9 +165,8 @@ public Page getEntities(@RequestParam(defaultValue = "0") int page /// Retrieves a single entity by template and entity identifiers. /// /// **API contract:** Provides specific entity lookup using compound identifier - /// pattern. - /// Returns HTTP 404 if either template or entity doesn't exist, maintaining - /// REST semantics. + /// pattern. Returns HTTP 404 if either template or entity doesn't exist, + /// maintaining REST semantics. /// /// @param templateIdentifier business template identifier for entity scope /// @param entityIdentifier unique business identifier within template context @@ -178,19 +179,20 @@ public Page getEntities(@RequestParam(defaultValue = "0") int page @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, defaultValue = "") Set relationsToDisplay) { + EntityGraphNode entityGraphNode = entityGraphService.getEntityGraph(templateIdentifier, + entityIdentifier, relationsDepth, true, relationsToDisplay, Set.of(), + EntityGraphTraversalMode.DIRECT_LINEAGE); + return EntityDtoOutFromEntityNodeMapper.toDto(entityGraphNode); } /// 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 @@ -224,10 +226,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 @@ -261,10 +262,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 @@ -290,11 +291,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/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..d41fc93 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutFromEntityNodeMapper.java @@ -0,0 +1,130 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Component; + +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphRelation; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntitySummaryDto; + +@Component +public class EntityDtoOutFromEntityNodeMapper { + + private record TraversalState(String rootIdentifier, // Stored to easily identify the starting + // anchor + Map> relationsMap, Set visitedNodeIds, + Set emittedEdgeSignatures) { + } + + /// Converts an [EntityGraphNode] to an [EntityDtoOut] with flattened relations. + /// + /// **Flattening strategy:** All relations from the entire graph tree (root + + /// all descendants) are collected into a single flat `relations` map at the + /// root + /// level. No nested relation structures are created. + /// + /// **Cycle prevention:** Uses a visited set to avoid infinite recursion on + /// circular references. + /// + /// @param root the root entity graph node to convert + /// @return the DTO with flat unified relations map containing all graph + /// relations + public static EntityDtoOut toDto(EntityGraphNode root) { + if (root == null) { + return null; + } + + // Build properties map + Map properties = root.properties().stream() + .collect(Collectors.toMap(p -> p.name(), p -> p.value(), (v1, v2) -> v1)); + + // Traverse graph and collect all relations into a flat map + var state = new TraversalState(root.identifier(), new HashMap<>(), new HashSet<>(), + new HashSet<>()); + + traverse(root, state); + + // Convert Map> to Map> + Map> finalizedRelations = state.relationsMap().entrySet() + .stream().collect(Collectors.toMap(Map.Entry::getKey, e -> new ArrayList<>(e.getValue()))); + + // Construct immutable record + return new EntityDtoOut(root.identifier(), root.name(), root.templateIdentifier(), properties, + finalizedRelations); + } + + private static void traverse(EntityGraphNode node, TraversalState state) { + var nodeId = nodeId(node.templateIdentifier(), node.identifier()); + + if (!state.visitedNodeIds().add(nodeId)) { + return; + } + + // 1. Outbound Relations: Current Node is ALWAYS Source, Target is ALWAYS Target + for (EntityGraphRelation relation : node.relations()) { + for (EntityGraphNode target : relation.targets()) { + var targetId = nodeId(target.templateIdentifier(), target.identifier()); + var signature = nodeId + "|" + targetId + "|" + relation.name(); + + if (state.emittedEdgeSignatures().add(signature)) { + // Identify the dependency node based on relation geometry + EntityGraphNode dependencyNode = identifyDependency(state.rootIdentifier(), node, target); + + state.relationsMap().computeIfAbsent(relation.name(), k -> new LinkedHashSet<>()) + .add(new EntitySummaryDto(dependencyNode.identifier(), dependencyNode.name(), + dependencyNode.templateIdentifier())); + } + traverse(target, state); + } + } + + // 2. Inbound Relations: Source is ALWAYS Source, Current Node is ALWAYS Target + for (EntityGraphRelation relation : node.relationsAsTarget()) { + for (EntityGraphNode source : relation.targets()) { + var sourceId = nodeId(source.templateIdentifier(), source.identifier()); + var signature = sourceId + "|" + nodeId + "|" + relation.name(); + + if (state.emittedEdgeSignatures().add(signature)) { + // Identify the dependency node based on relation geometry + EntityGraphNode dependencyNode = identifyDependency(state.rootIdentifier(), source, node); + + state.relationsMap().computeIfAbsent(relation.name(), k -> new LinkedHashSet<>()) + .add(new EntitySummaryDto(dependencyNode.identifier(), dependencyNode.name(), + dependencyNode.templateIdentifier())); + } + traverse(source, state); + } + } + } + + private static EntityGraphNode identifyDependency(String rootIdentifier, EntityGraphNode source, + EntityGraphNode target) { + // If the root node is the source, we want to look at what it points to (the + // target) + if (source.identifier().equals(rootIdentifier)) { + return target; + } + // If the root node is the target, we want to look at what points to it (the + // source) + if (target.identifier().equals(rootIdentifier)) { + return source; + } + // Deep structural default: if we are out in the graph branches, map the target + // entity + return target; + } + + private static String nodeId(String templateIdentifier, String identifier) { + return templateIdentifier + ":" + identifier; + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java index b93fdff..ea2b5d3 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 @@ -3,9 +3,11 @@ import java.util.ArrayList; import java.util.Collections; 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.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -18,7 +20,6 @@ import com.decathlon.idp_core.domain.model.entity.Property; import com.decathlon.idp_core.domain.model.entity.RelationAsTargetSummary; import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; -import com.decathlon.idp_core.domain.model.entity_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.model.enums.PropertyType; @@ -76,11 +77,11 @@ public EntityDtoOut fromEntity(Entity entity) { /// /// **Pure mapping:** All DB queries were already executed inside /// [EntityGraphService#getEntityGraphPageByTemplate] within a single - /// transaction. This method only transforms domain models to API DTOs — - /// no further repository calls are made. + /// transaction. This method only transforms domain models to API DTOs — no + /// further repository calls are made. /// - /// @param graphNodes paginated graph nodes with resolved bidirectional - /// relations + /// @param graphNodes paginated graph nodes with resolved + /// bidirectional relations /// @param entityTemplateIdentifier template identifier for property type /// mapping /// @return paginated API DTOs with unified outbound and inbound relations @@ -97,8 +98,9 @@ public Page fromGraphNodesPage(Page graphNodes, /// /// **Unification:** Merges `graphNode.relations()` (outbound) and /// `graphNode.relationsAsTarget()` (inbound) into a single `relations` map - /// keyed by relation name. When both directions share the same relation name, - /// their entity summaries are merged into one list. + /// keyed + /// by relation name. When both directions share the same relation name, their + /// entity summaries are merged into one list. /// /// @param graphNode domain graph node with bidirectional relation data /// @param template entity template for property type resolution @@ -112,6 +114,25 @@ private EntityDtoOut fromGraphNode(EntityGraphNode graphNode, EntityTemplate tem graphNode.templateIdentifier(), props, unifiedRelations); } + /// Maps a single [EntityGraphNode] to an [EntityDtoOut]. + /// + /// **Unification:** Merges `graphNode.relations()` (outbound) and + /// `graphNode.relationsAsTarget()` (inbound) into a single `relations` map + /// keyed + /// by relation name. When both directions share the same relation name, their + /// entity summaries are merged into one list. + /// + /// @param graphNode domain graph node with bidirectional relation + /// data + /// @param entityTemplateIdentifier entity template identifier for property type + /// resolution + /// @return API DTO with unified relations map + public EntityDtoOut fromGraphNode(EntityGraphNode graphNode, String entityTemplateIdentifier) { + EntityTemplate template = entityTemplateService + .getEntityTemplateByIdentifier(entityTemplateIdentifier); + return fromGraphNode(graphNode, template); + } + /// Maps properties from a graph node using the template for type conversion. private Map mapPropertiesFromGraphNode(EntityGraphNode graphNode, EntityTemplate template) { @@ -126,39 +147,95 @@ private Map mapPropertiesFromGraphNode(EntityGraphNode graphNode .toMap(Property::name, prop -> convertPropertyValue(prop, definitions.get(prop.name())))); } - /// Builds a unified relations map from a graph node combining both directions. + /// Builds a unified **flat** relations map from a graph node, collecting all + /// relations recursively into the root level. + /// + /// **Flattening strategy:** + /// - Recursively traverses all nested graph nodes (targets of targets) + /// - Collects all relation names and their entity summaries at the root level + /// - Does NOT create nested relation structures /// - /// Outbound relations (`graphNode.relations()`) and inbound relations + /// **Unification:** + /// - Outbound relations (`graphNode.relations()`) and inbound relations /// (`graphNode.relationsAsTarget()`) are merged under the same relation name - /// key when present. + /// key + /// - Relations from nested targets are also merged into the root map + /// + /// @param graphNode domain graph node with bidirectional relation data + /// @return unified flat relations map with all relations from the entire graph + /// tree private Map> buildUnifiedRelationsFromGraphNode( EntityGraphNode graphNode) { Map> unified = new HashMap<>(); - // Outbound: this entity is the source — merge under the same key if present - graphNode.relations().forEach(relation -> unified.merge(relation.name(), - toEntitySummaryDtos(relation), (existing, incoming) -> { - var merged = new ArrayList<>(existing); - merged.addAll(incoming); - return merged; - })); - // Inbound: this entity is the target — merge under the same key if present - graphNode.relationsAsTarget().forEach(relation -> unified.merge(relation.name(), - toEntitySummaryDtos(relation), (existing, incoming) -> { - var merged = new ArrayList<>(existing); - merged.addAll(incoming); - return merged; - })); + // Recursively collect all relations (outbound + inbound) from this node and its + // children + collectAllRelationsRecursively(graphNode, unified, new HashSet<>()); return unified; } - /// Converts the target nodes of an [EntityGraphRelation] to [EntitySummaryDto] - /// list. - private List toEntitySummaryDtos(EntityGraphRelation relation) { - return relation.targets().stream().map(target -> new EntitySummaryDto(target.identifier(), - target.name(), target.templateIdentifier())).toList(); + /// Recursively collects all relations from a graph node and its descendants, + /// flattening them into a single map. + /// + /// **Cycle prevention:** Uses a visited set to avoid infinite recursion on + /// circular references. + /// + /// @param node the current graph node to process + /// @param unified the accumulator map for all relations + /// @param visited set of already-processed node identifiers (cycle detection) + private void collectAllRelationsRecursively(EntityGraphNode node, + Map> unified, Set visited) { + + // Prevent infinite recursion on cycles + String nodeKey = node.templateIdentifier() + "/" + node.identifier(); + if (visited.contains(nodeKey)) { + return; + } + visited.add(nodeKey); + + // Collect outbound relations from this node + node.relations().forEach(relation -> { + String relationName = relation.name(); + + // Add immediate targets as summaries + List targetSummaries = relation.targets().stream() + .map(target -> new EntitySummaryDto(target.identifier(), target.name(), + target.templateIdentifier())) + .toList(); + + unified.merge(relationName, new ArrayList<>(targetSummaries), (existing, incoming) -> { + var merged = new ArrayList<>(existing); + merged.addAll(incoming); + return merged; + }); + + // Recursively collect relations from each target + relation.targets() + .forEach(target -> collectAllRelationsRecursively(target, unified, visited)); + }); + + // Collect inbound relations from this node + node.relationsAsTarget().forEach(relation -> { + String relationName = relation.name(); + + // Add immediate sources as summaries + List sourceSummaries = relation.targets().stream() + .map(source -> new EntitySummaryDto(source.identifier(), source.name(), + source.templateIdentifier())) + .toList(); + + unified.merge(relationName, new ArrayList<>(sourceSummaries), (existing, incoming) -> { + var merged = new ArrayList<>(existing); + merged.addAll(incoming); + return merged; + }); + + // Recursively collect relations from each source + relation.targets() + .forEach(source -> collectAllRelationsRecursively(source, unified, visited)); + }); } /// Maps a single entity to its DTO using the provided entity template. @@ -187,7 +264,8 @@ 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 with unified relations private EntityDtoOut fromEntityUsingEntityTemplateAndSummaryMap(Entity entity, @@ -203,8 +281,8 @@ private EntityDtoOut fromEntityUsingEntityTemplateAndSummaryMap(Entity entity, } /// 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 @@ -302,8 +380,8 @@ private Map> buildUnifiedRelationsMap(Entity enti /// 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()) { @@ -321,8 +399,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) { @@ -373,6 +451,7 @@ private Map buildRelatedEntitiesSummaryMapByPage( /// /// 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 with template info private Map buildEntitiesSummariesMap(List targetIdentifiers) { @@ -386,9 +465,9 @@ private Map buildEntitiesSummariesMap(List tar /// 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 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: From 33881554619700740e391533697313a08d480b09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Mon, 20 Jul 2026 17:26:42 +0200 Subject: [PATCH 19/26] fet(core): update entity get endpoint tu use graph service add entity mapper from entity node --- .../api/controller/EntityController.java | 395 +++++++++--------- .../EntityDtoOutFromEntityNodeMapper.java | 268 ++++++++---- .../api/mapper/entity/EntityDtoOutMapper.java | 165 -------- 3 files changed, 380 insertions(+), 448 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java index ce220d4..7efaf48 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 @@ -72,6 +72,7 @@ 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.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.model.search.PaginatedResult; import com.decathlon.idp_core.domain.model.search.PaginationCriteria; import com.decathlon.idp_core.domain.model.search.RawSearchFilterNode; @@ -116,210 +117,212 @@ @RequiredArgsConstructor public class EntityController { - private final EntityService entityService; - private final EntityDtoOutMapper entityDtoOutMapper; - private final EntityDtoInMapper entityDtoInMapper; - private final EntityFilterDslParser entityFilterDslParser; - private final SearchFilterMapper searchFilterMapper; - private final SearchFilterParser searchFilterParser; - private final EntityGraphService entityGraphService; + 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. - /// - /// @param page zero-based page index for pagination navigation - /// @param size number of entities per page for response size - /// control - /// @param templateIdentifier template filter for entity scope limitation - /// @param q optional filter query string (e.g. - /// `name:API;property.language=JAVA`) - /// @return paginated entity DTOs matching the template and optional filter - @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 = { - @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) - @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_QUERY, content = { - @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) - @Parameter(name = "page", description = PARAM_PAGE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "0"))) - @Parameter(name = "size", description = PARAM_SIZE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "20"))) - @Parameter(name = "sort", description = PARAM_SORT_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string", defaultValue = "identifier,asc"))) - @Parameter(name = "q", description = PARAM_QUERY_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string"))) - @ResponseStatus(OK) - @GetMapping("/{templateIdentifier}") - public Page getEntities(@RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size, @PathVariable String templateIdentifier, - @RequestParam(required = false) String q) { - Pageable pageable = PageRequest.of(page, size); - EntityFilter filter = entityFilterDslParser.parse(q); - // Single transaction: pagination + batch relation fetch in one DB round trip - Page graphNodes = entityGraphService.getEntityGraphPageByTemplate(pageable, - templateIdentifier, filter, 1); - return entityDtoOutMapper.fromGraphNodesPage(graphNodes, templateIdentifier); - } + /// Returns paginated entities filtered by template with HTTP pagination + /// support. + /// + /// **API contract:** Provides paginated entity listings for template-specific + /// views. Supports standard REST pagination parameters and an optional `q` + /// filter query. Template validation is handled by the domain service layer. + /// + /// @param page zero-based page index for pagination navigation + /// @param size number of entities per page for response size + /// control + /// @param templateIdentifier template filter for entity scope limitation + /// @param q optional filter query string (e.g. + /// `name:API;property.language=JAVA`) + /// @return paginated entity DTOs matching the template and optional filter + @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 = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class)) }) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_QUERY, content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class)) }) + @Parameter(name = "page", description = PARAM_PAGE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "0"))) + @Parameter(name = "size", description = PARAM_SIZE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "20"))) + @Parameter(name = "sort", description = PARAM_SORT_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string", defaultValue = "identifier,asc"))) + @Parameter(name = "q", description = PARAM_QUERY_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string"))) + @ResponseStatus(OK) + @GetMapping("/{templateIdentifier}") + public Page getEntities(@RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, @PathVariable String templateIdentifier, + @RequestParam(required = false) String q) { + Pageable pageable = PageRequest.of(page, size); + EntityFilter filter = entityFilterDslParser.parse(q); + // 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. - /// - /// **API contract:** Provides specific entity lookup using compound identifier - /// pattern. Returns HTTP 404 if either template or entity doesn't exist, - /// maintaining REST semantics. - /// - /// @param templateIdentifier business template identifier for entity scope - /// @param entityIdentifier unique business identifier within template context - /// @return entity DTO with full property and relationship data - @Operation(summary = ENDPOINT_GET_ENTITY_BY_IDENTIFIER_SUMMARY, description = ENDPOINT_GET_ENTITY_BY_IDENTIFIER_DESCRIPTION) - @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_FOUND, content = { - @Content(schema = @Schema(implementation = EntityDtoOut.class))}) - @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = { - @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) - @GetMapping("/{templateIdentifier}/{entityIdentifier}") - @ResponseStatus(OK) - public EntityDtoOut getEntity(@PathVariable String templateIdentifier, - @PathVariable String entityIdentifier, - @RequestParam(name = "relations_depth", required = false, defaultValue = "1") Integer relationsDepth, - @RequestParam(name = "relations_to_display", required = false, defaultValue = "") Set relationsToDisplay) { - EntityGraphNode entityGraphNode = entityGraphService.getEntityGraph(templateIdentifier, - entityIdentifier, relationsDepth, true, relationsToDisplay, Set.of(), - EntityGraphTraversalMode.DIRECT_LINEAGE); - return EntityDtoOutFromEntityNodeMapper.toDto(entityGraphNode); - } + /// Retrieves a single entity by template and entity identifiers. + /// + /// **API contract:** Provides specific entity lookup using compound identifier + /// pattern. Returns HTTP 404 if either template or entity doesn't exist, + /// maintaining REST semantics. + /// + /// @param templateIdentifier business template identifier for entity scope + /// @param entityIdentifier unique business identifier within template context + /// @return entity DTO with full property and relationship data + @Operation(summary = ENDPOINT_GET_ENTITY_BY_IDENTIFIER_SUMMARY, description = ENDPOINT_GET_ENTITY_BY_IDENTIFIER_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_FOUND, content = { + @Content(schema = @Schema(implementation = EntityDtoOut.class)) }) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class)) }) + @GetMapping("/{templateIdentifier}/{entityIdentifier}") + @ResponseStatus(OK) + public EntityDtoOut getEntity(@PathVariable String templateIdentifier, + @PathVariable String entityIdentifier, + @RequestParam(name = "relations_depth", required = false, defaultValue = "1") Integer relationsDepth, + @RequestParam(name = "relations_to_display", required = false, defaultValue = "") Set relationsToDisplay) { - /// Creates a new entity for the specified template with validation. - /// - /// **API contract:** Accepts entity creation payload and returns created entity - /// with generated identifiers. Validates entity structure against template - /// constraints and returns HTTP 201 on success, HTTP 400 for validation errors. - /// - /// @param templateIdentifier target template identifier for entity creation - /// context - /// @param entityCreateDtoIn entity creation payload with properties and - /// relationships - /// @return created entity DTO with server-generated identifiers - @Operation(summary = ENDPOINT_POST_ENTITY_SUMMARY, description = ENDPOINT_POST_ENTITY_DESCRIPTION) - @ApiResponse(responseCode = CREATED_CODE, description = RESPONSE_ENTITY_CREATED, content = { - @Content(schema = @Schema(implementation = EntityDtoOut.class))}) - @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_ENTITY_DATA, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) - @ApiResponse(responseCode = UNAUTHORIZED_CODE, description = RESPONSE_UNAUTHORIZED, content = @Content) - @ApiResponse(responseCode = FORBIDDEN_CODE, description = RESPONSE_INSUFFICIENT_RIGHTS, content = @Content) - @ApiResponse(responseCode = CONFLICT_CODE, description = RESPONSE_ENTITY_CONFLICT, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) - @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) - @ApiResponse(responseCode = INTERNAL_SERVER_ERROR_CODE, description = RESPONSE_UNEXPECTED_SERVER_ERROR, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) - @PostMapping("/{templateIdentifier}") - @ResponseStatus(CREATED) - public EntityDtoOut createEntity(@NotBlank @PathVariable String templateIdentifier, - @Valid @RequestBody EntityCreateDtoIn entityCreateDtoIn) { + EntityGraphNode entityGraphNode = entityGraphService.getEntityGraph(templateIdentifier, + entityIdentifier, relationsDepth, true, relationsToDisplay, Set.of(), + EntityGraphTraversalMode.DIRECT_LINEAGE); + return entityDtoOutFromEntityNodeMapper.toDto(entityGraphNode, templateIdentifier, relationsDepth); + } - Entity entity = entityDtoInMapper.fromPostEntityDtoInToEntity(entityCreateDtoIn, - templateIdentifier); - Entity savedEntity = entityService.createEntity(entity); - return entityDtoOutMapper.fromEntity(savedEntity); - } + /// Creates a new entity for the specified template with validation. + /// + /// **API contract:** Accepts entity creation payload and returns created entity + /// with generated identifiers. Validates entity structure against template + /// constraints and returns HTTP 201 on success, HTTP 400 for validation errors. + /// + /// @param templateIdentifier target template identifier for entity creation + /// context + /// @param entityCreateDtoIn entity creation payload with properties and + /// relationships + /// @return created entity DTO with server-generated identifiers + @Operation(summary = ENDPOINT_POST_ENTITY_SUMMARY, description = ENDPOINT_POST_ENTITY_DESCRIPTION) + @ApiResponse(responseCode = CREATED_CODE, description = RESPONSE_ENTITY_CREATED, content = { + @Content(schema = @Schema(implementation = EntityDtoOut.class)) }) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_ENTITY_DATA, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class)) }) + @ApiResponse(responseCode = UNAUTHORIZED_CODE, description = RESPONSE_UNAUTHORIZED, content = @Content) + @ApiResponse(responseCode = FORBIDDEN_CODE, description = RESPONSE_INSUFFICIENT_RIGHTS, content = @Content) + @ApiResponse(responseCode = CONFLICT_CODE, description = RESPONSE_ENTITY_CONFLICT, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class)) }) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class)) }) + @ApiResponse(responseCode = INTERNAL_SERVER_ERROR_CODE, description = RESPONSE_UNEXPECTED_SERVER_ERROR, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class)) }) + @PostMapping("/{templateIdentifier}") + @ResponseStatus(CREATED) + public EntityDtoOut createEntity(@NotBlank @PathVariable String templateIdentifier, + @Valid @RequestBody EntityCreateDtoIn entityCreateDtoIn) { - /// Updates an existing entity for the specified template. - /// - /// **API contract:** Accepts entity update payload and returns updated entity. - /// Validates that the entity exists and that the update payload conforms to - /// template constraints. Returns HTTP 200 on success, HTTP 400 for validation - /// errors, HTTP 404 if entity doesn't exist. - /// - /// @param templateIdentifier target template identifier for entity update - /// context - /// @param entityIdentifier unique business identifier of the entity to update - /// @param entityUpdateDtoIn entity update payload with properties and - /// relationships to apply - /// @return updated entity DTO reflecting persisted changes - @Operation(summary = ENDPOINT_PUT_ENTITY_SUMMARY, description = ENDPOINT_PUT_ENTITY_DESCRIPTION) - @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_UPDATED, content = { - @Content(schema = @Schema(implementation = EntityDtoOut.class))}) - @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_ENTITY_DATA, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) - @ApiResponse(responseCode = UNAUTHORIZED_CODE, description = RESPONSE_UNAUTHORIZED, content = @Content) - @ApiResponse(responseCode = FORBIDDEN_CODE, description = RESPONSE_INSUFFICIENT_RIGHTS, content = @Content) - @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) - @ApiResponse(responseCode = INTERNAL_SERVER_ERROR_CODE, description = RESPONSE_UNEXPECTED_SERVER_ERROR, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) - @PutMapping("/{templateIdentifier}/{entityIdentifier}") - @ResponseStatus(OK) - public EntityDtoOut updateEntity(@NotBlank @PathVariable String templateIdentifier, - @NotBlank @PathVariable String entityIdentifier, - @Valid @RequestBody EntityUpdateDtoIn entityUpdateDtoIn) { + Entity entity = entityDtoInMapper.fromPostEntityDtoInToEntity(entityCreateDtoIn, + templateIdentifier); + Entity savedEntity = entityService.createEntity(entity); + return entityDtoOutMapper.fromEntity(savedEntity); + } - Entity entity = entityDtoInMapper.fromPutEntityDtoInToEntity(entityUpdateDtoIn, - templateIdentifier, entityIdentifier); - Entity updatedEntity = entityService.updateEntity(templateIdentifier, entityIdentifier, entity); - return entityDtoOutMapper.fromEntity(updatedEntity); - } + /// Updates an existing entity for the specified template. + /// + /// **API contract:** Accepts entity update payload and returns updated entity. + /// Validates that the entity exists and that the update payload conforms to + /// template constraints. Returns HTTP 200 on success, HTTP 400 for validation + /// errors, HTTP 404 if entity doesn't exist. + /// + /// @param templateIdentifier target template identifier for entity update + /// context + /// @param entityIdentifier unique business identifier of the entity to update + /// @param entityUpdateDtoIn entity update payload with properties and + /// relationships to apply + /// @return updated entity DTO reflecting persisted changes + @Operation(summary = ENDPOINT_PUT_ENTITY_SUMMARY, description = ENDPOINT_PUT_ENTITY_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_UPDATED, content = { + @Content(schema = @Schema(implementation = EntityDtoOut.class)) }) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_ENTITY_DATA, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class)) }) + @ApiResponse(responseCode = UNAUTHORIZED_CODE, description = RESPONSE_UNAUTHORIZED, content = @Content) + @ApiResponse(responseCode = FORBIDDEN_CODE, description = RESPONSE_INSUFFICIENT_RIGHTS, content = @Content) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class)) }) + @ApiResponse(responseCode = INTERNAL_SERVER_ERROR_CODE, description = RESPONSE_UNEXPECTED_SERVER_ERROR, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class)) }) + @PutMapping("/{templateIdentifier}/{entityIdentifier}") + @ResponseStatus(OK) + public EntityDtoOut updateEntity(@NotBlank @PathVariable String templateIdentifier, + @NotBlank @PathVariable String entityIdentifier, + @Valid @RequestBody EntityUpdateDtoIn entityUpdateDtoIn) { - /// 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. - /// - /// @param templateIdentifier the template identifier of the entity to delete - /// @param entityIdentifier the identifier of the entity to delete - @Operation(summary = ENDPOINT_DELETE_ENTITY_SUMMARY, description = ENDPOINT_DELETE_ENTITY_DESCRIPTION) - @ApiResponse(responseCode = NO_CONTENT_CODE, description = RESPONSE_ENTITY_DELETED) - @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_ENTITY_DATA, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) - @ApiResponse(responseCode = UNAUTHORIZED_CODE, description = RESPONSE_UNAUTHORIZED, content = @Content) - @ApiResponse(responseCode = FORBIDDEN_CODE, description = RESPONSE_INSUFFICIENT_RIGHTS, content = @Content) - @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) - @ApiResponse(responseCode = CONFLICT_CODE, description = RESPONSE_ENTITY_RELATION_CONFLICT, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) - @ApiResponse(responseCode = INTERNAL_SERVER_ERROR_CODE, description = RESPONSE_UNEXPECTED_SERVER_ERROR, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) - @DeleteMapping("/{templateIdentifier}/{entityIdentifier}") - @ResponseStatus(NO_CONTENT) - public void deleteEntity(@NotBlank @PathVariable String templateIdentifier, - @NotBlank @PathVariable String entityIdentifier) { - entityService.deleteEntity(templateIdentifier, entityIdentifier); - } + Entity entity = entityDtoInMapper.fromPutEntityDtoInToEntity(entityUpdateDtoIn, + templateIdentifier, entityIdentifier); + Entity updatedEntity = entityService.updateEntity(templateIdentifier, entityIdentifier, entity); + return entityDtoOutMapper.fromEntity(updatedEntity); + } - /// Searches for entities across all templates using a nested filter query. - /// - /// **API contract:** Accepts a JSON body with a nested filter tree, pagination, - /// and sorting parameters. Returns a paginated list of entities matching the - /// filter. No template scoping is applied by default; include a template - /// criterion in the filter to scope results to a specific template. - /// - /// @param searchRequest the search request body with filter, page, size, and - /// sort - /// @return paginated entity DTOs matching the filter - @Operation(summary = ENDPOINT_POST_SEARCH_SUMMARY, description = ENDPOINT_POST_SEARCH_DESCRIPTION) - @ApiResponse(responseCode = OK_CODE, description = RESPONSE_SEARCH_SUCCESS, content = @Content(schema = @Schema(implementation = EntityPageResponse.class))) - @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_SEARCH_QUERY, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class))}) - @PostMapping("/search") - @ResponseStatus(OK) - public Page searchEntities(@RequestBody EntitySearchRequestDtoIn searchRequest) { - RawSearchFilterNode rawFilter = searchFilterMapper.toRaw(searchRequest.filter()); - SearchFilterNode filter = searchFilterParser.parse(rawFilter); - PaginationCriteria paginationCriteria = new PaginationCriteria(searchRequest.page(), - searchRequest.size(), searchRequest.sort()); + /// 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. + /// + /// @param templateIdentifier the template identifier of the entity to delete + /// @param entityIdentifier the identifier of the entity to delete + @Operation(summary = ENDPOINT_DELETE_ENTITY_SUMMARY, description = ENDPOINT_DELETE_ENTITY_DESCRIPTION) + @ApiResponse(responseCode = NO_CONTENT_CODE, description = RESPONSE_ENTITY_DELETED) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_ENTITY_DATA, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class)) }) + @ApiResponse(responseCode = UNAUTHORIZED_CODE, description = RESPONSE_UNAUTHORIZED, content = @Content) + @ApiResponse(responseCode = FORBIDDEN_CODE, description = RESPONSE_INSUFFICIENT_RIGHTS, content = @Content) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class)) }) + @ApiResponse(responseCode = CONFLICT_CODE, description = RESPONSE_ENTITY_RELATION_CONFLICT, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class)) }) + @ApiResponse(responseCode = INTERNAL_SERVER_ERROR_CODE, description = RESPONSE_UNEXPECTED_SERVER_ERROR, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class)) }) + @DeleteMapping("/{templateIdentifier}/{entityIdentifier}") + @ResponseStatus(NO_CONTENT) + public void deleteEntity(@NotBlank @PathVariable String templateIdentifier, + @NotBlank @PathVariable String entityIdentifier) { + entityService.deleteEntity(templateIdentifier, entityIdentifier); + } - PaginatedResult result = entityService.searchEntities(filter, searchRequest.query(), - paginationCriteria); - Page page = toPageResponse(result.content(), paginationCriteria, - result.totalElements()); - return entityDtoOutMapper.fromEntitiesSearchPageToDtoPage(page); - } + /// Searches for entities across all templates using a nested filter query. + /// + /// **API contract:** Accepts a JSON body with a nested filter tree, pagination, + /// and sorting parameters. Returns a paginated list of entities matching the + /// filter. No template scoping is applied by default; include a template + /// criterion in the filter to scope results to a specific template. + /// + /// @param searchRequest the search request body with filter, page, size, and + /// sort + /// @return paginated entity DTOs matching the filter + @Operation(summary = ENDPOINT_POST_SEARCH_SUMMARY, description = ENDPOINT_POST_SEARCH_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_SEARCH_SUCCESS, content = @Content(schema = @Schema(implementation = EntityPageResponse.class))) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_SEARCH_QUERY, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class)) }) + @PostMapping("/search") + @ResponseStatus(OK) + public Page searchEntities(@RequestBody EntitySearchRequestDtoIn searchRequest) { + RawSearchFilterNode rawFilter = searchFilterMapper.toRaw(searchRequest.filter()); + SearchFilterNode filter = searchFilterParser.parse(rawFilter); + PaginationCriteria paginationCriteria = new PaginationCriteria(searchRequest.page(), + searchRequest.size(), searchRequest.sort()); - private Page toPageResponse(List content, PaginationCriteria criteria, - long totalElements) { - Pageable pageable = PageRequest.of(criteria.page(), criteria.size()); - return new PageImpl<>(content, pageable, totalElements); - } + PaginatedResult result = entityService.searchEntities(filter, searchRequest.query(), + paginationCriteria); + Page page = toPageResponse(result.content(), paginationCriteria, + result.totalElements()); + return entityDtoOutMapper.fromEntitiesSearchPageToDtoPage(page); + } + + private Page toPageResponse(List content, PaginationCriteria criteria, + long totalElements) { + Pageable pageable = PageRequest.of(criteria.page(), criteria.size()); + return new PageImpl<>(content, pageable, totalElements); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutFromEntityNodeMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutFromEntityNodeMapper.java index d41fc93..031d30a 100644 --- 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 @@ -1,130 +1,224 @@ 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.model.enums.PropertyType; +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 record TraversalState(String rootIdentifier, // Stored to easily identify the starting - // anchor - Map> relationsMap, Set visitedNodeIds, - Set emittedEdgeSignatures) { - } - - /// Converts an [EntityGraphNode] to an [EntityDtoOut] with flattened relations. - /// - /// **Flattening strategy:** All relations from the entire graph tree (root + - /// all descendants) are collected into a single flat `relations` map at the - /// root - /// level. No nested relation structures are created. - /// - /// **Cycle prevention:** Uses a visited set to avoid infinite recursion on - /// circular references. - /// - /// @param root the root entity graph node to convert - /// @return the DTO with flat unified relations map containing all graph - /// relations - public static EntityDtoOut toDto(EntityGraphNode root) { - if (root == null) { - return null; + 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); } - // Build properties map - Map properties = root.properties().stream() - .collect(Collectors.toMap(p -> p.name(), p -> p.value(), (v1, v2) -> v1)); + /// 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); - // Traverse graph and collect all relations into a flat map - var state = new TraversalState(root.identifier(), new HashMap<>(), new HashSet<>(), - new HashSet<>()); + var state = new TraversalState(root.identifier(), new HashMap<>(), new HashMap<>(), + new HashSet<>()); - traverse(root, state); + // Initiate sequence tracking from baseline layer 0 + traverse(root, 0, maxDepth, state); - // Convert Map> to Map> - Map> finalizedRelations = state.relationsMap().entrySet() - .stream().collect(Collectors.toMap(Map.Entry::getKey, e -> new ArrayList<>(e.getValue()))); + 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); + } - // Construct immutable record - return new EntityDtoOut(root.identifier(), root.name(), root.templateIdentifier(), properties, - finalizedRelations); - } + /// 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(); + } - private static void traverse(EntityGraphNode node, TraversalState state) { - var nodeId = nodeId(node.templateIdentifier(), node.identifier()); + EntityTemplate entityTemplate = entityTemplateService + .getEntityTemplateByIdentifier(entityTemplateIdentifier); - if (!state.visitedNodeIds().add(nodeId)) { - return; + return graphNodesPage.map(node -> this.toDto(node, entityTemplate, maxDepth)); } - // 1. Outbound Relations: Current Node is ALWAYS Source, Target is ALWAYS Target - for (EntityGraphRelation relation : node.relations()) { - for (EntityGraphNode target : relation.targets()) { - var targetId = nodeId(target.templateIdentifier(), target.identifier()); - var signature = nodeId + "|" + targetId + "|" + relation.name(); + /// 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); - if (state.emittedEdgeSignatures().add(signature)) { - // Identify the dependency node based on relation geometry - EntityGraphNode dependencyNode = identifyDependency(state.rootIdentifier(), node, target); + // Map outbound and inbound geometries via isolated sub-methods + processOutboundRelations(node, nodeId, currentDepth, maxDepth, state); + processInboundRelations(node, nodeId, currentDepth, maxDepth, state); + } - state.relationsMap().computeIfAbsent(relation.name(), k -> new LinkedHashSet<>()) - .add(new EntitySummaryDto(dependencyNode.identifier(), dependencyNode.name(), - dependencyNode.templateIdentifier())); + /// 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(); + + if (shouldCollectRelations && 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); + } + } } - traverse(target, state); - } } - // 2. Inbound Relations: Source is ALWAYS Source, Current Node is ALWAYS Target - for (EntityGraphRelation relation : node.relationsAsTarget()) { - for (EntityGraphNode source : relation.targets()) { - var sourceId = nodeId(source.templateIdentifier(), source.identifier()); - var signature = sourceId + "|" + nodeId + "|" + relation.name(); + /// 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(); + + if (shouldCollectRelations && 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); + } + } + } + } - if (state.emittedEdgeSignatures().add(signature)) { - // Identify the dependency node based on relation geometry - EntityGraphNode dependencyNode = identifyDependency(state.rootIdentifier(), source, node); + private static String nodeId(String templateIdentifier, String identifier) { + return templateIdentifier + ":" + identifier; + } - state.relationsMap().computeIfAbsent(relation.name(), k -> new LinkedHashSet<>()) - .add(new EntitySummaryDto(dependencyNode.identifier(), dependencyNode.name(), - dependencyNode.templateIdentifier())); + /// 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(); } - traverse(source, state); - } + + 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 -> convertPropertyValue(prop, definitions.get(prop.name())))); } - } - - private static EntityGraphNode identifyDependency(String rootIdentifier, EntityGraphNode source, - EntityGraphNode target) { - // If the root node is the source, we want to look at what it points to (the - // target) - if (source.identifier().equals(rootIdentifier)) { - return target; + + 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; } - // If the root node is the target, we want to look at what points to it (the - // source) - if (target.identifier().equals(rootIdentifier)) { - return source; + + /// State container capturing cross-layer tracking matrices during execution. + private record TraversalState( + String rootIdentifier, + Map> relationsMap, + Map visitedNodeDepths, + Set emittedEdgeSignatures) { } - // Deep structural default: if we are out in the graph branches, map the target - // entity - return target; - } - - private static String nodeId(String templateIdentifier, String identifier) { - return templateIdentifier + ":" + identifier; - } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutMapper.java index ea2b5d3..9e10870 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 @@ -73,171 +73,6 @@ public EntityDtoOut fromEntity(Entity entity) { return fromEntityUsingEntityTemplate(entity, entityTemplate); } - /// Maps a page of pre-fetched graph nodes to paginated [EntityDtoOut] DTOs. - /// - /// **Pure mapping:** All DB queries were already executed inside - /// [EntityGraphService#getEntityGraphPageByTemplate] within a single - /// transaction. This method only transforms domain models to API DTOs — no - /// further repository calls are made. - /// - /// @param graphNodes paginated graph nodes with resolved - /// bidirectional relations - /// @param entityTemplateIdentifier template identifier for property type - /// mapping - /// @return paginated API DTOs with unified outbound and inbound relations - public Page fromGraphNodesPage(Page graphNodes, - String entityTemplateIdentifier) { - - EntityTemplate template = entityTemplateService - .getEntityTemplateByIdentifier(entityTemplateIdentifier); - - return graphNodes.map(graphNode -> fromGraphNode(graphNode, template)); - } - - /// Maps a single [EntityGraphNode] to an [EntityDtoOut]. - /// - /// **Unification:** Merges `graphNode.relations()` (outbound) and - /// `graphNode.relationsAsTarget()` (inbound) into a single `relations` map - /// keyed - /// by relation name. When both directions share the same relation name, their - /// entity summaries are merged into one list. - /// - /// @param graphNode domain graph node with bidirectional relation data - /// @param template entity template for property type resolution - /// @return API DTO with unified relations map - private EntityDtoOut fromGraphNode(EntityGraphNode graphNode, EntityTemplate template) { - Map props = mapPropertiesFromGraphNode(graphNode, template); - Map> unifiedRelations = buildUnifiedRelationsFromGraphNode( - graphNode); - - return new EntityDtoOut(graphNode.identifier(), graphNode.name(), - graphNode.templateIdentifier(), props, unifiedRelations); - } - - /// Maps a single [EntityGraphNode] to an [EntityDtoOut]. - /// - /// **Unification:** Merges `graphNode.relations()` (outbound) and - /// `graphNode.relationsAsTarget()` (inbound) into a single `relations` map - /// keyed - /// by relation name. When both directions share the same relation name, their - /// entity summaries are merged into one list. - /// - /// @param graphNode domain graph node with bidirectional relation - /// data - /// @param entityTemplateIdentifier entity template identifier for property type - /// resolution - /// @return API DTO with unified relations map - public EntityDtoOut fromGraphNode(EntityGraphNode graphNode, String entityTemplateIdentifier) { - EntityTemplate template = entityTemplateService - .getEntityTemplateByIdentifier(entityTemplateIdentifier); - return fromGraphNode(graphNode, template); - } - - /// Maps properties from a graph node using the template for type conversion. - private Map mapPropertiesFromGraphNode(EntityGraphNode graphNode, - EntityTemplate template) { - if (graphNode.properties() == null || graphNode.properties().isEmpty()) { - return Collections.emptyMap(); - } - - Map definitions = template.propertiesDefinitions().stream() - .collect(Collectors.toMap(PropertyDefinition::name, Function.identity())); - - return graphNode.properties().stream().filter(prop -> prop.value() != null).collect(Collectors - .toMap(Property::name, prop -> convertPropertyValue(prop, definitions.get(prop.name())))); - } - - /// Builds a unified **flat** relations map from a graph node, collecting all - /// relations recursively into the root level. - /// - /// **Flattening strategy:** - /// - Recursively traverses all nested graph nodes (targets of targets) - /// - Collects all relation names and their entity summaries at the root level - /// - Does NOT create nested relation structures - /// - /// **Unification:** - /// - Outbound relations (`graphNode.relations()`) and inbound relations - /// (`graphNode.relationsAsTarget()`) are merged under the same relation name - /// key - /// - Relations from nested targets are also merged into the root map - /// - /// @param graphNode domain graph node with bidirectional relation data - /// @return unified flat relations map with all relations from the entire graph - /// tree - private Map> buildUnifiedRelationsFromGraphNode( - EntityGraphNode graphNode) { - - Map> unified = new HashMap<>(); - - // Recursively collect all relations (outbound + inbound) from this node and its - // children - collectAllRelationsRecursively(graphNode, unified, new HashSet<>()); - - return unified; - } - - /// Recursively collects all relations from a graph node and its descendants, - /// flattening them into a single map. - /// - /// **Cycle prevention:** Uses a visited set to avoid infinite recursion on - /// circular references. - /// - /// @param node the current graph node to process - /// @param unified the accumulator map for all relations - /// @param visited set of already-processed node identifiers (cycle detection) - private void collectAllRelationsRecursively(EntityGraphNode node, - Map> unified, Set visited) { - - // Prevent infinite recursion on cycles - String nodeKey = node.templateIdentifier() + "/" + node.identifier(); - if (visited.contains(nodeKey)) { - return; - } - visited.add(nodeKey); - - // Collect outbound relations from this node - node.relations().forEach(relation -> { - String relationName = relation.name(); - - // Add immediate targets as summaries - List targetSummaries = relation.targets().stream() - .map(target -> new EntitySummaryDto(target.identifier(), target.name(), - target.templateIdentifier())) - .toList(); - - unified.merge(relationName, new ArrayList<>(targetSummaries), (existing, incoming) -> { - var merged = new ArrayList<>(existing); - merged.addAll(incoming); - return merged; - }); - - // Recursively collect relations from each target - relation.targets() - .forEach(target -> collectAllRelationsRecursively(target, unified, visited)); - }); - - // Collect inbound relations from this node - node.relationsAsTarget().forEach(relation -> { - String relationName = relation.name(); - - // Add immediate sources as summaries - List sourceSummaries = relation.targets().stream() - .map(source -> new EntitySummaryDto(source.identifier(), source.name(), - source.templateIdentifier())) - .toList(); - - unified.merge(relationName, new ArrayList<>(sourceSummaries), (existing, incoming) -> { - var merged = new ArrayList<>(existing); - merged.addAll(incoming); - return merged; - }); - - // Recursively collect relations from each source - relation.targets() - .forEach(source -> collectAllRelationsRecursively(source, unified, visited)); - }); - } - /// Maps a single entity to its DTO using the provided entity template. /// /// @param entity the entity to map From 0e4607bdcf2c0f9cef7d3e276eb15e29990966cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Mon, 20 Jul 2026 17:38:41 +0200 Subject: [PATCH 20/26] fet(core): update entity get endpoint tu use graph service add entity mapper from entity node --- .../api/controller/EntityController.java | 395 +++++++++--------- .../EntityDtoOutFromEntityNodeMapper.java | 304 +++++++------- .../api/mapper/entity/EntityDtoOutMapper.java | 3 - .../mapper/entity/PropertyValueConverter.java | 56 +++ 4 files changed, 395 insertions(+), 363 deletions(-) create mode 100644 src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/PropertyValueConverter.java 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 7efaf48..269d4b8 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 @@ -117,212 +117,213 @@ @RequiredArgsConstructor 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; + 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. - /// - /// @param page zero-based page index for pagination navigation - /// @param size number of entities per page for response size - /// control - /// @param templateIdentifier template filter for entity scope limitation - /// @param q optional filter query string (e.g. - /// `name:API;property.language=JAVA`) - /// @return paginated entity DTOs matching the template and optional filter - @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 = { - @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class)) }) - @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_QUERY, content = { - @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class)) }) - @Parameter(name = "page", description = PARAM_PAGE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "0"))) - @Parameter(name = "size", description = PARAM_SIZE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "20"))) - @Parameter(name = "sort", description = PARAM_SORT_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string", defaultValue = "identifier,asc"))) - @Parameter(name = "q", description = PARAM_QUERY_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string"))) - @ResponseStatus(OK) - @GetMapping("/{templateIdentifier}") - public Page getEntities(@RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size, @PathVariable String templateIdentifier, - @RequestParam(required = false) String q) { - Pageable pageable = PageRequest.of(page, size); - EntityFilter filter = entityFilterDslParser.parse(q); - // 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); - } + /// Returns paginated entities filtered by template with HTTP pagination + /// support. + /// + /// **API contract:** Provides paginated entity listings for template-specific + /// views. Supports standard REST pagination parameters and an optional `q` + /// filter query. Template validation is handled by the domain service layer. + /// + /// @param page zero-based page index for pagination navigation + /// @param size number of entities per page for response size + /// control + /// @param templateIdentifier template filter for entity scope limitation + /// @param q optional filter query string (e.g. + /// `name:API;property.language=JAVA`) + /// @return paginated entity DTOs matching the template and optional filter + @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 = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_QUERY, content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @Parameter(name = "page", description = PARAM_PAGE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "0"))) + @Parameter(name = "size", description = PARAM_SIZE_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "integer", defaultValue = "20"))) + @Parameter(name = "sort", description = PARAM_SORT_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string", defaultValue = "identifier,asc"))) + @Parameter(name = "q", description = PARAM_QUERY_DESCRIPTION, in = ParameterIn.QUERY, content = @Content(schema = @Schema(type = "string"))) + @ResponseStatus(OK) + @GetMapping("/{templateIdentifier}") + public Page getEntities(@RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, @PathVariable String templateIdentifier, + @RequestParam(required = false) String q) { + Pageable pageable = PageRequest.of(page, size); + EntityFilter filter = entityFilterDslParser.parse(q); + // 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. - /// - /// **API contract:** Provides specific entity lookup using compound identifier - /// pattern. Returns HTTP 404 if either template or entity doesn't exist, - /// maintaining REST semantics. - /// - /// @param templateIdentifier business template identifier for entity scope - /// @param entityIdentifier unique business identifier within template context - /// @return entity DTO with full property and relationship data - @Operation(summary = ENDPOINT_GET_ENTITY_BY_IDENTIFIER_SUMMARY, description = ENDPOINT_GET_ENTITY_BY_IDENTIFIER_DESCRIPTION) - @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_FOUND, content = { - @Content(schema = @Schema(implementation = EntityDtoOut.class)) }) - @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = { - @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class)) }) - @GetMapping("/{templateIdentifier}/{entityIdentifier}") - @ResponseStatus(OK) - public EntityDtoOut getEntity(@PathVariable String templateIdentifier, - @PathVariable String entityIdentifier, - @RequestParam(name = "relations_depth", required = false, defaultValue = "1") Integer relationsDepth, - @RequestParam(name = "relations_to_display", required = false, defaultValue = "") Set relationsToDisplay) { + /// Retrieves a single entity by template and entity identifiers. + /// + /// **API contract:** Provides specific entity lookup using compound identifier + /// pattern. Returns HTTP 404 if either template or entity doesn't exist, + /// maintaining REST semantics. + /// + /// @param templateIdentifier business template identifier for entity scope + /// @param entityIdentifier unique business identifier within template context + /// @return entity DTO with full property and relationship data + @Operation(summary = ENDPOINT_GET_ENTITY_BY_IDENTIFIER_SUMMARY, description = ENDPOINT_GET_ENTITY_BY_IDENTIFIER_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_FOUND, content = { + @Content(schema = @Schema(implementation = EntityDtoOut.class))}) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = { + @Content(schema = @Schema(implementation = ApiExceptionHandler.ErrorResponse.class))}) + @GetMapping("/{templateIdentifier}/{entityIdentifier}") + @ResponseStatus(OK) + public EntityDtoOut getEntity(@PathVariable String templateIdentifier, + @PathVariable String entityIdentifier, + @RequestParam(name = "relations_depth", required = false, defaultValue = "1") Integer relationsDepth, + @RequestParam(name = "relations_to_display", required = false, defaultValue = "") Set relationsToDisplay) { - EntityGraphNode entityGraphNode = entityGraphService.getEntityGraph(templateIdentifier, - entityIdentifier, relationsDepth, true, relationsToDisplay, Set.of(), - EntityGraphTraversalMode.DIRECT_LINEAGE); - return entityDtoOutFromEntityNodeMapper.toDto(entityGraphNode, templateIdentifier, relationsDepth); - } + EntityGraphNode entityGraphNode = entityGraphService.getEntityGraph(templateIdentifier, + entityIdentifier, relationsDepth, true, 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. - /// - /// @param templateIdentifier target template identifier for entity creation - /// context - /// @param entityCreateDtoIn entity creation payload with properties and - /// relationships - /// @return created entity DTO with server-generated identifiers - @Operation(summary = ENDPOINT_POST_ENTITY_SUMMARY, description = ENDPOINT_POST_ENTITY_DESCRIPTION) - @ApiResponse(responseCode = CREATED_CODE, description = RESPONSE_ENTITY_CREATED, content = { - @Content(schema = @Schema(implementation = EntityDtoOut.class)) }) - @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_ENTITY_DATA, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class)) }) - @ApiResponse(responseCode = UNAUTHORIZED_CODE, description = RESPONSE_UNAUTHORIZED, content = @Content) - @ApiResponse(responseCode = FORBIDDEN_CODE, description = RESPONSE_INSUFFICIENT_RIGHTS, content = @Content) - @ApiResponse(responseCode = CONFLICT_CODE, description = RESPONSE_ENTITY_CONFLICT, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class)) }) - @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class)) }) - @ApiResponse(responseCode = INTERNAL_SERVER_ERROR_CODE, description = RESPONSE_UNEXPECTED_SERVER_ERROR, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class)) }) - @PostMapping("/{templateIdentifier}") - @ResponseStatus(CREATED) - public EntityDtoOut createEntity(@NotBlank @PathVariable String templateIdentifier, - @Valid @RequestBody EntityCreateDtoIn entityCreateDtoIn) { + /// Creates a new entity for the specified template with validation. + /// + /// **API contract:** Accepts entity creation payload and returns created entity + /// with generated identifiers. Validates entity structure against template + /// constraints and returns HTTP 201 on success, HTTP 400 for validation errors. + /// + /// @param templateIdentifier target template identifier for entity creation + /// context + /// @param entityCreateDtoIn entity creation payload with properties and + /// relationships + /// @return created entity DTO with server-generated identifiers + @Operation(summary = ENDPOINT_POST_ENTITY_SUMMARY, description = ENDPOINT_POST_ENTITY_DESCRIPTION) + @ApiResponse(responseCode = CREATED_CODE, description = RESPONSE_ENTITY_CREATED, content = { + @Content(schema = @Schema(implementation = EntityDtoOut.class))}) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_ENTITY_DATA, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = UNAUTHORIZED_CODE, description = RESPONSE_UNAUTHORIZED, content = @Content) + @ApiResponse(responseCode = FORBIDDEN_CODE, description = RESPONSE_INSUFFICIENT_RIGHTS, content = @Content) + @ApiResponse(responseCode = CONFLICT_CODE, description = RESPONSE_ENTITY_CONFLICT, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = INTERNAL_SERVER_ERROR_CODE, description = RESPONSE_UNEXPECTED_SERVER_ERROR, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @PostMapping("/{templateIdentifier}") + @ResponseStatus(CREATED) + public EntityDtoOut createEntity(@NotBlank @PathVariable String templateIdentifier, + @Valid @RequestBody EntityCreateDtoIn entityCreateDtoIn) { - Entity entity = entityDtoInMapper.fromPostEntityDtoInToEntity(entityCreateDtoIn, - templateIdentifier); - Entity savedEntity = entityService.createEntity(entity); - return entityDtoOutMapper.fromEntity(savedEntity); - } + Entity entity = entityDtoInMapper.fromPostEntityDtoInToEntity(entityCreateDtoIn, + templateIdentifier); + Entity savedEntity = entityService.createEntity(entity); + return entityDtoOutMapper.fromEntity(savedEntity); + } - /// Updates an existing entity for the specified template. - /// - /// **API contract:** Accepts entity update payload and returns updated entity. - /// Validates that the entity exists and that the update payload conforms to - /// template constraints. Returns HTTP 200 on success, HTTP 400 for validation - /// errors, HTTP 404 if entity doesn't exist. - /// - /// @param templateIdentifier target template identifier for entity update - /// context - /// @param entityIdentifier unique business identifier of the entity to update - /// @param entityUpdateDtoIn entity update payload with properties and - /// relationships to apply - /// @return updated entity DTO reflecting persisted changes - @Operation(summary = ENDPOINT_PUT_ENTITY_SUMMARY, description = ENDPOINT_PUT_ENTITY_DESCRIPTION) - @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_UPDATED, content = { - @Content(schema = @Schema(implementation = EntityDtoOut.class)) }) - @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_ENTITY_DATA, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class)) }) - @ApiResponse(responseCode = UNAUTHORIZED_CODE, description = RESPONSE_UNAUTHORIZED, content = @Content) - @ApiResponse(responseCode = FORBIDDEN_CODE, description = RESPONSE_INSUFFICIENT_RIGHTS, content = @Content) - @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class)) }) - @ApiResponse(responseCode = INTERNAL_SERVER_ERROR_CODE, description = RESPONSE_UNEXPECTED_SERVER_ERROR, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class)) }) - @PutMapping("/{templateIdentifier}/{entityIdentifier}") - @ResponseStatus(OK) - public EntityDtoOut updateEntity(@NotBlank @PathVariable String templateIdentifier, - @NotBlank @PathVariable String entityIdentifier, - @Valid @RequestBody EntityUpdateDtoIn entityUpdateDtoIn) { + /// Updates an existing entity for the specified template. + /// + /// **API contract:** Accepts entity update payload and returns updated entity. + /// Validates that the entity exists and that the update payload conforms to + /// template constraints. Returns HTTP 200 on success, HTTP 400 for validation + /// errors, HTTP 404 if entity doesn't exist. + /// + /// @param templateIdentifier target template identifier for entity update + /// context + /// @param entityIdentifier unique business identifier of the entity to update + /// @param entityUpdateDtoIn entity update payload with properties and + /// relationships to apply + /// @return updated entity DTO reflecting persisted changes + @Operation(summary = ENDPOINT_PUT_ENTITY_SUMMARY, description = ENDPOINT_PUT_ENTITY_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_UPDATED, content = { + @Content(schema = @Schema(implementation = EntityDtoOut.class))}) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_ENTITY_DATA, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = UNAUTHORIZED_CODE, description = RESPONSE_UNAUTHORIZED, content = @Content) + @ApiResponse(responseCode = FORBIDDEN_CODE, description = RESPONSE_INSUFFICIENT_RIGHTS, content = @Content) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = INTERNAL_SERVER_ERROR_CODE, description = RESPONSE_UNEXPECTED_SERVER_ERROR, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @PutMapping("/{templateIdentifier}/{entityIdentifier}") + @ResponseStatus(OK) + public EntityDtoOut updateEntity(@NotBlank @PathVariable String templateIdentifier, + @NotBlank @PathVariable String entityIdentifier, + @Valid @RequestBody EntityUpdateDtoIn entityUpdateDtoIn) { - Entity entity = entityDtoInMapper.fromPutEntityDtoInToEntity(entityUpdateDtoIn, - templateIdentifier, entityIdentifier); - Entity updatedEntity = entityService.updateEntity(templateIdentifier, entityIdentifier, entity); - return entityDtoOutMapper.fromEntity(updatedEntity); - } + Entity entity = entityDtoInMapper.fromPutEntityDtoInToEntity(entityUpdateDtoIn, + templateIdentifier, entityIdentifier); + Entity updatedEntity = entityService.updateEntity(templateIdentifier, entityIdentifier, entity); + return entityDtoOutMapper.fromEntity(updatedEntity); + } - /// 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. - /// - /// @param templateIdentifier the template identifier of the entity to delete - /// @param entityIdentifier the identifier of the entity to delete - @Operation(summary = ENDPOINT_DELETE_ENTITY_SUMMARY, description = ENDPOINT_DELETE_ENTITY_DESCRIPTION) - @ApiResponse(responseCode = NO_CONTENT_CODE, description = RESPONSE_ENTITY_DELETED) - @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_ENTITY_DATA, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class)) }) - @ApiResponse(responseCode = UNAUTHORIZED_CODE, description = RESPONSE_UNAUTHORIZED, content = @Content) - @ApiResponse(responseCode = FORBIDDEN_CODE, description = RESPONSE_INSUFFICIENT_RIGHTS, content = @Content) - @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class)) }) - @ApiResponse(responseCode = CONFLICT_CODE, description = RESPONSE_ENTITY_RELATION_CONFLICT, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class)) }) - @ApiResponse(responseCode = INTERNAL_SERVER_ERROR_CODE, description = RESPONSE_UNEXPECTED_SERVER_ERROR, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class)) }) - @DeleteMapping("/{templateIdentifier}/{entityIdentifier}") - @ResponseStatus(NO_CONTENT) - public void deleteEntity(@NotBlank @PathVariable String templateIdentifier, - @NotBlank @PathVariable String entityIdentifier) { - entityService.deleteEntity(templateIdentifier, entityIdentifier); - } + /// 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. + /// + /// @param templateIdentifier the template identifier of the entity to delete + /// @param entityIdentifier the identifier of the entity to delete + @Operation(summary = ENDPOINT_DELETE_ENTITY_SUMMARY, description = ENDPOINT_DELETE_ENTITY_DESCRIPTION) + @ApiResponse(responseCode = NO_CONTENT_CODE, description = RESPONSE_ENTITY_DELETED) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_ENTITY_DATA, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = UNAUTHORIZED_CODE, description = RESPONSE_UNAUTHORIZED, content = @Content) + @ApiResponse(responseCode = FORBIDDEN_CODE, description = RESPONSE_INSUFFICIENT_RIGHTS, content = @Content) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = CONFLICT_CODE, description = RESPONSE_ENTITY_RELATION_CONFLICT, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = INTERNAL_SERVER_ERROR_CODE, description = RESPONSE_UNEXPECTED_SERVER_ERROR, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @DeleteMapping("/{templateIdentifier}/{entityIdentifier}") + @ResponseStatus(NO_CONTENT) + public void deleteEntity(@NotBlank @PathVariable String templateIdentifier, + @NotBlank @PathVariable String entityIdentifier) { + entityService.deleteEntity(templateIdentifier, entityIdentifier); + } - /// Searches for entities across all templates using a nested filter query. - /// - /// **API contract:** Accepts a JSON body with a nested filter tree, pagination, - /// and sorting parameters. Returns a paginated list of entities matching the - /// filter. No template scoping is applied by default; include a template - /// criterion in the filter to scope results to a specific template. - /// - /// @param searchRequest the search request body with filter, page, size, and - /// sort - /// @return paginated entity DTOs matching the filter - @Operation(summary = ENDPOINT_POST_SEARCH_SUMMARY, description = ENDPOINT_POST_SEARCH_DESCRIPTION) - @ApiResponse(responseCode = OK_CODE, description = RESPONSE_SEARCH_SUCCESS, content = @Content(schema = @Schema(implementation = EntityPageResponse.class))) - @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_SEARCH_QUERY, content = { - @Content(schema = @Schema(implementation = ErrorResponse.class)) }) - @PostMapping("/search") - @ResponseStatus(OK) - public Page searchEntities(@RequestBody EntitySearchRequestDtoIn searchRequest) { - RawSearchFilterNode rawFilter = searchFilterMapper.toRaw(searchRequest.filter()); - SearchFilterNode filter = searchFilterParser.parse(rawFilter); - PaginationCriteria paginationCriteria = new PaginationCriteria(searchRequest.page(), - searchRequest.size(), searchRequest.sort()); + /// Searches for entities across all templates using a nested filter query. + /// + /// **API contract:** Accepts a JSON body with a nested filter tree, pagination, + /// and sorting parameters. Returns a paginated list of entities matching the + /// filter. No template scoping is applied by default; include a template + /// criterion in the filter to scope results to a specific template. + /// + /// @param searchRequest the search request body with filter, page, size, and + /// sort + /// @return paginated entity DTOs matching the filter + @Operation(summary = ENDPOINT_POST_SEARCH_SUMMARY, description = ENDPOINT_POST_SEARCH_DESCRIPTION) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_SEARCH_SUCCESS, content = @Content(schema = @Schema(implementation = EntityPageResponse.class))) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_SEARCH_QUERY, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @PostMapping("/search") + @ResponseStatus(OK) + public Page searchEntities(@RequestBody EntitySearchRequestDtoIn searchRequest) { + RawSearchFilterNode rawFilter = searchFilterMapper.toRaw(searchRequest.filter()); + SearchFilterNode filter = searchFilterParser.parse(rawFilter); + PaginationCriteria paginationCriteria = new PaginationCriteria(searchRequest.page(), + searchRequest.size(), searchRequest.sort()); - PaginatedResult result = entityService.searchEntities(filter, searchRequest.query(), - paginationCriteria); - Page page = toPageResponse(result.content(), paginationCriteria, - result.totalElements()); - return entityDtoOutMapper.fromEntitiesSearchPageToDtoPage(page); - } + PaginatedResult result = entityService.searchEntities(filter, searchRequest.query(), + paginationCriteria); + Page page = toPageResponse(result.content(), paginationCriteria, + result.totalElements()); + return entityDtoOutMapper.fromEntitiesSearchPageToDtoPage(page); + } - private Page toPageResponse(List content, PaginationCriteria criteria, - long totalElements) { - Pageable pageable = PageRequest.of(criteria.page(), criteria.size()); - return new PageImpl<>(content, pageable, totalElements); - } + private Page toPageResponse(List content, PaginationCriteria criteria, + long totalElements) { + Pageable pageable = PageRequest.of(criteria.page(), criteria.size()); + return new PageImpl<>(content, pageable, totalElements); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutFromEntityNodeMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityDtoOutFromEntityNodeMapper.java index 031d30a..dc7ae8f 100644 --- 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 @@ -20,7 +20,6 @@ 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.model.enums.PropertyType; 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; @@ -33,192 +32,171 @@ @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; - } + 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); - EntityTemplate entityTemplate = entityTemplateService - .getEntityTemplateByIdentifier(entityTemplateIdentifier); + return toDto(root, entityTemplate, maxDepth); + } - 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; } - /// 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); - Map properties = mapPropertiesFromGraphNode(root, entityTemplate); + var state = new TraversalState(root.identifier(), new HashMap<>(), new HashMap<>(), + new HashSet<>()); - var state = new TraversalState(root.identifier(), new HashMap<>(), new HashMap<>(), - new HashSet<>()); + // Initiate sequence tracking from baseline layer 0 + traverse(root, 0, maxDepth, state); - // 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()))); - 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); + } - 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; } - /// 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); + 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(); } - /// 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)); + 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; } - - /// 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(); + + if (shouldCollectRelations && state.emittedEdgeSignatures().add(signature)) { + state.relationsMap().computeIfAbsent(relation.name(), k -> new LinkedHashSet<>()) + .add(new EntitySummaryDto(target.identifier(), target.name(), + target.templateIdentifier())); } - 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(); - - if (shouldCollectRelations && 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); - } - } + 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(); - - if (shouldCollectRelations && 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); - } - } + } + + /// 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(); + + if (shouldCollectRelations && state.emittedEdgeSignatures().add(signature)) { + state.relationsMap().computeIfAbsent(relation.name(), k -> new LinkedHashSet<>()) + .add(new EntitySummaryDto(source.identifier(), source.name(), + source.templateIdentifier())); } - } - 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(); + if (currentDepth < maxDepth) { + traverse(source, currentDepth + 1, maxDepth, state); } + } + } + } - Map definitions = template.propertiesDefinitions().stream() - .filter(Objects::nonNull) - .collect(Collectors.toMap(def -> def.name(), Function.identity())); + private static String nodeId(String templateIdentifier, String identifier) { + return templateIdentifier + ":" + identifier; + } - return graphNode.properties().stream() - .filter(prop -> prop.value() != null) - .collect(Collectors.toMap( - Property::name, - prop -> convertPropertyValue(prop, definitions.get(prop.name())))); + /// 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(); } - 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; - } + Map definitions = template.propertiesDefinitions().stream() + .filter(Objects::nonNull).collect(Collectors.toMap(def -> def.name(), Function.identity())); - /// State container capturing cross-layer tracking matrices during execution. - private record TraversalState( - String rootIdentifier, - Map> relationsMap, - Map visitedNodeDepths, - Set emittedEdgeSignatures) { - } + 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 9e10870..7cfdcfb 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 @@ -3,11 +3,9 @@ import java.util.ArrayList; import java.util.Collections; 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.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -19,7 +17,6 @@ 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.RelationAsTargetSummary; -import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; import com.decathlon.idp_core.domain.model.enums.PropertyType; 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; + } +} From 04310e244eaf85c35e4b73e6ae480b2bf8be50d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Tue, 21 Jul 2026 11:23:50 +0200 Subject: [PATCH 21/26] fet(core): update entity get endpoint tu use graph service --- .../domain/service/entity_graph/EntityGraphService.java | 4 ++-- .../adapters/api/controller/EntityController.java | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java index d3af59a..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 @@ -35,8 +35,8 @@ /// 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 +/// 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. 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 269d4b8..0545e7f 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 @@ -72,7 +72,6 @@ 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.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.model.search.PaginatedResult; import com.decathlon.idp_core.domain.model.search.PaginationCriteria; import com.decathlon.idp_core.domain.model.search.RawSearchFilterNode; From 1c7def82834cedebad93c20745dc6aef2532b89a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Tue, 21 Jul 2026 11:45:32 +0200 Subject: [PATCH 22/26] fet(core): update entity get endpoint tu use graph service --- .../api/mapper/entity/EntityDtoOutMapper.java | 26 +------------------ 1 file changed, 1 insertion(+), 25 deletions(-) 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 7cfdcfb..866d01b 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 @@ -129,31 +129,7 @@ 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())))); - } - - /// Converts a property value to its typed representation based on the property - /// definition. - /// - /// @param property the property to convert - /// @param definition the property definition for type information, may be null - /// @return the typed value, falling back to the raw string value - private Object convertPropertyValue(Property property, PropertyDefinition definition) { - String value = property.value(); - if (definition == null) { - return value; - } - PropertyType type = definition.type(); - if (PropertyType.NUMBER.equals(type)) { - try { - return Double.valueOf(value); - } catch (NumberFormatException _) { - return value; - } - } else if (PropertyType.BOOLEAN.equals(type)) { - return Boolean.valueOf(value); - } - return value; + prop -> PropertyValueConverter.convert(prop, propertiesDefinitions.get(prop.name())))); } /// Builds a unified relations map combining outbound and inbound relations. From 3d5db3e0df4f8a5338e46346c51ad92c5bdb5a69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Tue, 21 Jul 2026 11:52:40 +0200 Subject: [PATCH 23/26] fet(core): update entity get endpoint tu use graph service --- .../adapters/api/mapper/entity/EntityDtoOutMapper.java | 1 - 1 file changed, 1 deletion(-) 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 866d01b..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 @@ -19,7 +19,6 @@ 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; From 06c4c3ff8926f06b514516dcbdee054acd4570f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Tue, 21 Jul 2026 12:16:53 +0200 Subject: [PATCH 24/26] fet(core): update entity get endpoint tu use graph service --- .../api/dto/out/entity/EntityDtoOut.java | 40 +++++++++++-------- 1 file changed, 24 insertions(+), 16 deletions(-) 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 a96d798..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,27 +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; -/** - * 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 (e.g., "component-supported_by-support_group"). - * - */ +/// 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 record EntityDtoOut(String identifier, +public record EntityDtoOut(String identifier, String name, String templateIdentifier, + Map properties, Map> relations) { - 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()); + } } From 832d5a54159f6b6659237cd06d6213bc89c85411 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Tue, 21 Jul 2026 15:59:32 +0200 Subject: [PATCH 25/26] fet(core): update entity get endpoint tu use graph service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Andrés Brand --- docs/src/concepts/entity-filtering.md | 2 +- .../domain/service/entity_graph/EntityGraphHelper.java | 4 ++-- .../adapters/api/controller/EntityController.java | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/src/concepts/entity-filtering.md b/docs/src/concepts/entity-filtering.md index 433b8f4..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 have inbound 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/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 index 76ce300..103a460 100644 --- 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 @@ -10,7 +10,7 @@ import java.util.UUID; import java.util.stream.Collectors; -import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.Property; @@ -23,7 +23,7 @@ import lombok.RequiredArgsConstructor; @RequiredArgsConstructor -@Component +@Service public class EntityGraphHelper { /// Bulk graph-building logic. Avoid self-proxy transactional calls. 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 0545e7f..7d80b6b 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 @@ -182,10 +182,10 @@ public Page getEntities(@RequestParam(defaultValue = "0") int page public EntityDtoOut getEntity(@PathVariable String templateIdentifier, @PathVariable String entityIdentifier, @RequestParam(name = "relations_depth", required = false, defaultValue = "1") Integer relationsDepth, - @RequestParam(name = "relations_to_display", required = false, defaultValue = "") Set relationsToDisplay) { + @RequestParam(name = "relations_to_display", required = false) Set relationsToDisplay) { EntityGraphNode entityGraphNode = entityGraphService.getEntityGraph(templateIdentifier, - entityIdentifier, relationsDepth, true, relationsToDisplay, Set.of(), + entityIdentifier, relationsDepth, true, relationsToDisplay == null ? Set.of() : relationsToDisplay, Set.of(), EntityGraphTraversalMode.DIRECT_LINEAGE); return entityDtoOutFromEntityNodeMapper.toDto(entityGraphNode, templateIdentifier, relationsDepth); From 2983d5dfc71e37d78fc5992786f3df4e411a73d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Brand?= Date: Tue, 21 Jul 2026 19:02:04 +0200 Subject: [PATCH 26/26] fet(core): update entity get endpoint tu use graph service --- docs/src/concepts/entities.md | 10 +- docs/src/static/swagger.yaml | 735 ++++++++++-------- .../api/configuration/SwaggerDescription.java | 4 +- .../api/controller/EntityController.java | 27 +- .../EntityDtoOutFromEntityNodeMapper.java | 14 +- .../api/controller/EntityControllerTest.java | 97 ++- .../R__3_Insert_graph_entities_test_data.sql | 49 +- 7 files changed, 552 insertions(+), 384 deletions(-) diff --git a/docs/src/concepts/entities.md b/docs/src/concepts/entities.md index cb725d6..53591d1 100644 --- a/docs/src/concepts/entities.md +++ b/docs/src/concepts/entities.md @@ -51,8 +51,8 @@ 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" ] } } @@ -248,11 +248,13 @@ In API responses, relations are grouped by name and include summary information "depends-on": [ { "identifier": "web-api-1", - "name": "Web API 1" + "name": "Web API 1", + "template_identifier": "web-service" }, { "identifier": "web-api-2", - "name": "Web API 2" + "name": "Web API 2", + "template_identifier": "web-service" } ] } 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/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 7d80b6b..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; @@ -163,20 +165,36 @@ public Page getEntities(@RequestParam(defaultValue = "0") int page 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, @@ -185,7 +203,8 @@ public EntityDtoOut getEntity(@PathVariable String templateIdentifier, @RequestParam(name = "relations_to_display", required = false) Set relationsToDisplay) { EntityGraphNode entityGraphNode = entityGraphService.getEntityGraph(templateIdentifier, - entityIdentifier, relationsDepth, true, relationsToDisplay == null ? Set.of() : relationsToDisplay, Set.of(), + entityIdentifier, relationsDepth, true, + relationsToDisplay == null ? Set.of() : relationsToDisplay, Set.of(), EntityGraphTraversalMode.DIRECT_LINEAGE); return entityDtoOutFromEntityNodeMapper.toDto(entityGraphNode, templateIdentifier, relationsDepth); 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 index dc7ae8f..64116c6 100644 --- 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 @@ -128,8 +128,8 @@ private static void traverse(EntityGraphNode node, int currentDepth, int maxDept processInboundRelations(node, nodeId, currentDepth, maxDepth, state); } - /// Evaluates outbound dependency linkages originating from the targeted - /// node scope. + /// 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; @@ -139,7 +139,10 @@ private static void processOutboundRelations(EntityGraphNode node, String nodeId String targetId = nodeId(target.templateIdentifier(), target.identifier()); String signature = nodeId + "|" + targetId + "|" + relation.name(); - if (shouldCollectRelations && state.emittedEdgeSignatures().add(signature)) { + // 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())); @@ -162,7 +165,10 @@ private static void processInboundRelations(EntityGraphNode node, String nodeId, String sourceId = nodeId(source.templateIdentifier(), source.identifier()); String signature = sourceId + "|" + nodeId + "|" + relation.name(); - if (shouldCollectRelations && state.emittedEdgeSignatures().add(signature)) { + // 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())); 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__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;