diff --git a/.github/instructions/domain.instructions.md b/.github/instructions/domain.instructions.md index 78af57f..8b5a872 100644 --- a/.github/instructions/domain.instructions.md +++ b/.github/instructions/domain.instructions.md @@ -37,10 +37,68 @@ applyTo: '**/domain/**/*.java' ## Exceptions -- Create specific unchecked exceptions for business rule violations (for example, `EntityTemplateNotFoundException`, `EntityTemplateAlreadyExistsException`). +### General Rules + +- Create **specific unchecked exceptions** for each business rule violation (for example, `EntityTemplateNotFoundException`, `EntityAlreadyExistsException`). - Domain exceptions must **not** contain HTTP status codes or REST-specific information. - Map domain exceptions to HTTP status codes exclusively in the Infrastructure layer (`@ControllerAdvice`). +### Exception Clarity + +- **Always prefer specific exceptions over generic ones**. Never throw `IllegalArgumentException` or `IllegalStateException` for business rule violations. +- Exception names must describe **what went wrong** from a business perspective (for example, `EntityTemplateNotFoundException`, not `TemplateException`). +- Exception messages must include **context**: what entity, what identifier, what operation was attempted. + +### Validation Service Pattern + +When a service method needs to validate preconditions (for example, "entity template must exist before creating entity"): + +1. **Extract validation into a dedicated service** (for example, `EntityTemplateValidationService`) +2. **Use explicit method names** that describe the validation (for example, `validateTemplateExists`, `validateTemplateNotExists`) +3. **Throw specific exceptions** that carry business meaning (for example, `EntityTemplateNotFoundException`) +4. **Call validation first** (fail-fast) before executing the main operation + +**Benefits:** + +- **Clear error messages**: `EntityTemplateNotFoundException("web-service")` vs generic `IllegalArgumentException("Invalid template")` +- **Better HTTP mapping**: specific exceptions map to appropriate status codes (404 for not found, 409 for conflict) +- **Reusable validation**: multiple services can call `validateTemplateExists` without duplicating logic +- **Fail-fast**: validation happens before expensive operations (database queries, graph traversal) + +### Exception Naming Convention + +| Pattern | Example | When to Use | +| --------------------------------- | --------------------------------------- | ------------------------------ | +| `NotFoundException` | `EntityTemplateNotFoundException` | Resource doesn't exist (404) | +| `AlreadyExistsException` | `EntityTemplateAlreadyExistsException` | Duplicate key violation (409) | +| `ValidationException` | `PropertyValidationException` | Business rule violation (400) | +| `NotAllowedException` | `EntityDeletionNotAllowedException` | Operation forbidden (403/409) | + +### Exception Structure + +```java +public class EntityTemplateNotFoundException extends RuntimeException { + + private final String identifier; + + public EntityTemplateNotFoundException(String identifier) { + super(String.format("Entity template with identifier '%s' not found", identifier)); + this.identifier = identifier; + } + + public String getIdentifier() { + return identifier; + } +} +``` + +**Rules:** + +- Extend `RuntimeException` (unchecked) for business exceptions +- Include a formatted message with all relevant context +- Store identifiers/keys as fields if needed for logging or error responses +- Never include stack traces in exception messages + ## Constants - Use a dedicated constants class (for example, `ValidationMessages.java`) for all validation messages. @@ -58,6 +116,71 @@ applyTo: '**/domain/**/*.java' - **Adapter-Level vs. Domain-Level**: syntactic checks (nulls, empty strings) belong on DTOs in the Infrastructure layer. Semantic checks (uniqueness, cross-field rules) belong in Domain Services. - Throw a custom `DomainValidationException` (or similar unchecked exception) when rules are violated. +### Creating Validation Services + +When validation logic is reused across multiple domain services: + +1. **Create a dedicated validation service** (for example, `EntityTemplateValidationService`) +2. **Extract validation methods** with clear names: `validateTemplateExists`, `validateTemplateNotExists`, `validateTemplateNotReferenced` +3. **Always call validation first** before the main operation (fail-fast principle) + +**Example validation service:** + +```java +@Service +@RequiredArgsConstructor +public class EntityTemplateValidationService { + + private final EntityTemplateRepositoryPort repository; + + public void validateTemplateExists(String identifier) { + if (!repository.existsByIdentifier(identifier)) { + throw new EntityTemplateNotFoundException(identifier); + } + } + + public void validateTemplateNotExists(String identifier) { + if (repository.existsByIdentifier(identifier)) { + throw new EntityTemplateAlreadyExistsException(identifier); + } + } + + public void validateTemplateNotReferenced(String identifier) { + if (repository.hasEntities(identifier)) { + throw new EntityTemplateReferencedException(identifier, + "Cannot delete template that is referenced by entities"); + } + } +} +``` + +**Usage (fail-fast):** + +```java +@Service +@RequiredArgsConstructor +public class EntityService { + + private final EntityTemplateValidationService templateValidation; + private final EntityRepositoryPort entityRepository; + + @Transactional + public Entity createEntity(String templateIdentifier, String entityIdentifier, ...) { + // Validate template exists FIRST (fail-fast) + templateValidation.validateTemplateExists(templateIdentifier); + + // Validate entity doesn't already exist + if (entityRepository.existsByIdentifier(entityIdentifier)) { + throw new EntityAlreadyExistsException(entityIdentifier); + } + + // Main operation + Entity entity = new Entity(...); + return entityRepository.save(entity); + } +} +``` + ## Mapping - Never use `ObjectMapper` or reflection-based libraries for internal layer mapping. @@ -70,10 +193,22 @@ applyTo: '**/domain/**/*.java' domain/ ├── constant/ # Validation message constants ├── exception/ # Domain-specific exceptions +│ ├── entity/ # Entity-related exceptions +│ ├── entity_template/ # Template-related exceptions +│ └── property/ # Property-related exceptions│ ├── model/ │ ├── entity/ # Core business records │ ├── entity_template/ # Template records │ └── enums/ # Business enums -├── port/ # Port interfaces (contracts for driven adapters) +├── port/ # Port interfaces (contracts for driven adapters) └── service/ # Domain services (orchestration) + ├── entity/ # Entity services + ├── entity_template/ # Template validation services + └── entity_graph/ # Graph services ``` + +### Exception Package Organization + +- Organize exceptions by aggregate/subdomain (for example, `entity/`, `entity_template/`, `property/`) +- Each exception class should have a clear, descriptive name that follows the naming conventions above +- Keep exception hierarchy flat. Avoid deep inheritance trees diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4dea8f0..815a5e2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,7 +3,7 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - - id: trailing-whitespace # Trims trailing whitespace + - id: trailing-whitespace # Trims trailing whitespace exclude: | (?x)^( .gitmodules| @@ -12,23 +12,23 @@ repos: .*\.drawio.*| .*\.snap )$ - - id: check-yaml # Validates YAML files + - id: check-yaml # Validates YAML files args: - --allow-multiple-documents - - id: check-json # Validates JSON files - - id: check-case-conflict # Checks for files that would conflict in case-insensitive filesystems - - id: check-merge-conflict # Checks for files that contain merge conflict strings - - id: detect-private-key # Check for the existence of private keys - - id: check-executables-have-shebangs # Checks that executables have shebangs + - id: check-json # Validates JSON files + - id: check-case-conflict # Checks for files that would conflict in case-insensitive filesystems + - id: check-merge-conflict # Checks for files that contain merge conflict strings + - id: detect-private-key # Check for the existence of private keys + - id: check-executables-have-shebangs # Checks that executables have shebangs exclude: | (?x)^( .*\.java )$ - - id: end-of-file-fixer # Makes sure files end in a newline and only a newline + - id: end-of-file-fixer # Makes sure files end in a newline and only a newline - repo: https://github.com/adrienverge/yamllint rev: v1.37.1 hooks: - - id: yamllint # Lints YAML files + - id: yamllint # Lints YAML files - repo: https://github.com/igorshubovych/markdownlint-cli rev: v0.47.0 hooks: diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateIdentifierCannotChangeException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateIdentifierCannotChangeException.java index c273dbe..b6bb002 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateIdentifierCannotChangeException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateIdentifierCannotChangeException.java @@ -2,6 +2,10 @@ import static com.decathlon.idp_core.domain.constant.ValidationMessages.TEMPLATE_IDENTIFIER_CANNOT_CHANGE; +import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; +import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler; + /// Exception thrown when attempting to change an [EntityTemplate] identifier after creation. /// /// **Why this exception exists:** diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyNameAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyNameAlreadyExistsException.java index 367d89e..54ccc36 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyNameAlreadyExistsException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/PropertyNameAlreadyExistsException.java @@ -2,6 +2,8 @@ import static com.decathlon.idp_core.domain.constant.ValidationMessages.PROPERTY_NAME_ALREADY_EXISTS; +import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; + /// Exception thrown when attempting to create or update an [EntityTemplate] with duplicate property names. /// /// This exception represents a business rule violation where unique constraints on property diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/RelationNameAlreadyExistsException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/RelationNameAlreadyExistsException.java index 198132d..97d5f99 100644 --- a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/RelationNameAlreadyExistsException.java +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/RelationNameAlreadyExistsException.java @@ -2,6 +2,8 @@ import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_NAME_ALREADY_EXISTS; +import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; + /// Exception thrown when attempting to create or update an [EntityTemplate] with duplicate relation names. /// /// This exception represents a business rule violation where unique constraints on relation diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java index c075987..a059708 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/Entity.java @@ -9,6 +9,8 @@ import jakarta.validation.constraints.NotBlank; +import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; + /// Domain entity representing a concrete instance of an [EntityTemplate]. /// /// Business invariants: diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityCompositeKey.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityCompositeKey.java new file mode 100644 index 0000000..db38bde --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityCompositeKey.java @@ -0,0 +1,38 @@ +package com.decathlon.idp_core.domain.model.entity; + +import java.util.Objects; + +/** + * Composite key for uniquely identifying an entity across templates. Since the + * same identifier can exist in different templates, we need both fields. + */ +public record EntityCompositeKey(String templateIdentifier, String identifier) { + public static EntityCompositeKey fromString(String compositeKey) { + String[] parts = compositeKey.split(":", 2); + if (parts.length != 2) { + throw new IllegalArgumentException("Invalid composite key format: " + compositeKey); + } + return new EntityCompositeKey(parts[0], parts[1]); + } + + @Override + public String toString() { + return templateIdentifier + ":" + identifier; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + EntityCompositeKey that = (EntityCompositeKey) o; + return Objects.equals(templateIdentifier, that.templateIdentifier) + && Objects.equals(identifier, that.identifier); + } + + @Override + public int hashCode() { + return Objects.hash(templateIdentifier, identifier); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityGraphNode.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityGraphNode.java new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityGraphRelation.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/EntityGraphRelation.java new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java index 8f1f098..c71c02e 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/Property.java @@ -6,6 +6,10 @@ import jakarta.validation.constraints.NotBlank; +import com.decathlon.idp_core.domain.model.entity_template.PropertyDefinition; +import com.decathlon.idp_core.domain.model.entity_template.PropertyRules; +import com.decathlon.idp_core.domain.model.enums.PropertyType; + /// A concrete property instance belonging to an [Entity]. /// /// Represents actual business data values that conform to the constraints diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity/Relation.java b/src/main/java/com/decathlon/idp_core/domain/model/entity/Relation.java index e92bd62..f5c9a2a 100644 --- a/src/main/java/com/decathlon/idp_core/domain/model/entity/Relation.java +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity/Relation.java @@ -10,6 +10,9 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; +import com.decathlon.idp_core.domain.model.entity_template.RelationDefinition; + /// A concrete relationship instance connecting entities in the business domain. /// /// Represents actual business connections between entities that conform to the diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java new file mode 100644 index 0000000..8b3266b --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphNode.java @@ -0,0 +1,29 @@ +package com.decathlon.idp_core.domain.model.entity_graph; + +import java.util.List; + +import com.decathlon.idp_core.domain.model.entity.Property; + +/// A node in the entity relationship graph, containing summary information +/// and its resolved relations (recursively up to a configurable depth). +/// +/// **Business purpose:** +/// - Visualizing entity dependency graphs +/// - Understanding relationship chains between entities +/// - Providing a hierarchical view of entity connections +/// +/// @param templateIdentifier the template identifier this entity belongs to +/// @param identifier the business identifier of the entity +/// @param name the human-readable name of the entity +/// @param properties the entity's property instances; empty when not requested +/// @param relations the resolved outbound relations with their target graph nodes +/// @param relationsAsTarget incoming relations where this entity is the target +public record EntityGraphNode(String templateIdentifier, String identifier, String name, + List properties, List relations, + List relationsAsTarget) { + public EntityGraphNode { + properties = properties != null ? List.copyOf(properties) : List.of(); + relations = relations != null ? List.copyOf(relations) : List.of(); + relationsAsTarget = relationsAsTarget != null ? List.copyOf(relationsAsTarget) : List.of(); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphRelation.java b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphRelation.java new file mode 100644 index 0000000..e9b25fe --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/model/entity_graph/EntityGraphRelation.java @@ -0,0 +1,18 @@ +package com.decathlon.idp_core.domain.model.entity_graph; + +import java.util.List; + +/// Represents a single named relation in the entity graph with its resolved target nodes. +/// +/// **Business purpose:** +/// - Groups related entities under their relation name +/// - Enables graph traversal by relation type +/// +/// @param name the relation name as defined in the entity template +/// @param targetTemplateIdentifier the template identifier of the target entities +/// @param targets the resolved target entity graph nodes (recursively populated up to depth) +public record EntityGraphRelation(String name, List targets) { + public EntityGraphRelation { + targets = targets != null ? List.copyOf(targets) : List.of(); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java new file mode 100644 index 0000000..b081a33 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityGraphRepositoryPort.java @@ -0,0 +1,50 @@ +package com.decathlon.idp_core.domain.port; + +import java.util.Map; + +import com.decathlon.idp_core.domain.model.entity.Entity; +import com.decathlon.idp_core.domain.model.entity.EntityCompositeKey; + +/// Driven port defining the contract for entity relationship graph retrieval. +/// +/// Separated from [EntityRepositoryPort] to follow the Interface Segregation Principle: +/// graph traversal is a distinct read concern backed by recursive CTE queries, +/// with no overlap with standard CRUD operations. +/// +/// **Contract expectations for implementations:** +/// - Must traverse both outbound and inbound relations up to the requested depth +/// - Must return the root entity itself as part of the result map +/// - Must return an empty map when the root entity does not exist +/// - Depth must be clamped server-side; implementations may ignore values outside a valid range +/// +/// **Transaction behavior:** Implementations should use a read-only transaction +/// as this port performs no write operations. +public interface EntityGraphRepositoryPort { + + /// Fetches all entities in the relationship graph rooted at the given composite + /// key. + /// + /// Uses a recursive CTE to traverse both outbound and inbound relations up to + /// the + /// specified depth, then batch-loads all entities in a minimal number of + /// queries. + /// + /// @param templateIdentifier the template identifier of the root entity + /// @param entityIdentifier the business identifier of the root entity within + /// its template + /// @param depth the maximum traversal depth (1-10) + /// @param includeProperties when true, entity properties are loaded along with + /// relations; + /// when false, only relations are fetched for a leaner query + /// @param relationNames when non-empty, only edges whose relation name is in + /// this set are + /// traversed; when empty, all relation types are followed + /// @return map of [EntityCompositeKey] to [Entity] for O(1) lookup; empty if + /// root not found + /// Relation name filtering is intentionally NOT pushed into this port. + /// The CTE always traverses all relation types so that nodes reachable via + /// any path are loaded. Edge filtering is applied in the service layer so + /// that "filter owns" still returns B and C when A→(depends-on)→B→(owns)→C. + Map findEntityGraph(String templateIdentifier, + String entityIdentifier, int depth, boolean includeProperties); +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java new file mode 100644 index 0000000..b04d1ef --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphService.java @@ -0,0 +1,211 @@ +package com.decathlon.idp_core.domain.service.entity_graph; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; +import com.decathlon.idp_core.domain.model.entity.Entity; +import com.decathlon.idp_core.domain.model.entity.EntityCompositeKey; +import com.decathlon.idp_core.domain.model.entity.Property; +import com.decathlon.idp_core.domain.model.entity.Relation; +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphRelation; +import com.decathlon.idp_core.domain.port.EntityGraphRepositoryPort; +import com.decathlon.idp_core.domain.port.EntityRepositoryPort; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; + +import lombok.RequiredArgsConstructor; + +/// Domain service for building entity relationship graphs. +/// +/// Resolves an entity's outbound and inbound relations recursively up to a configurable depth, +/// returning a tree of [EntityGraphNode] records containing summary information +/// for each connected entity. +/// +/// **Business purpose:** +/// - Visualizing entity dependency graphs in the catalog UI +/// - Understanding relationship chains (e.g., service → database → infrastructure) +/// - Providing hierarchical views for impact analysis and change propagation +/// +/// **Design decisions:** +/// - Uses depth-limited traversal to prevent unbounded recursion +/// - Optimized with recursive CTE and batch loading to minimize database queries +/// - A per-request `visitedNodeIds` set prevents exponential recursion: without it, +/// inbound relation scanning would re-expand already-visited nodes at every depth +/// level, producing O(2^depth) calls even for small graphs (OOM at depth ≥ 10). +/// - Relation and property filtering are domain concerns applied during graph construction, +/// so that callers (e.g. the REST controller) receive a graph that already respects +/// the requested scope instead of carrying unnecessary data to the Infrastructure layer. +@Service +@RequiredArgsConstructor +public class EntityGraphService { + + private static final int MAX_DEPTH = 10; + + private final EntityRepositoryPort entityRepositoryPort; + private final EntityGraphRepositoryPort entityGraphRepositoryPort; + private final EntityTemplateValidationService entityTemplateValidationService; + + /// Builds the relationship graph for an entity starting from its composite key. + /// + /// Relation and property filtering are applied here in the domain layer so that + /// callers receive a correctly scoped graph without needing to know about + /// filtering + /// logic. + /// + /// @param templateIdentifier the template identifier of the root entity + /// @param entityIdentifier the business identifier of the root entity + /// @param depth the maximum traversal depth (clamped to [1, MAX_DEPTH]) + /// @param includeProperties when true, each graph node carries the entity's + /// full property list (subject to propertyFilter) + /// @param relationFilter when non-empty, only relations whose name is in this + /// set are included in the graph; an empty set means no filter — all relations + /// are included + /// @param propertyFilter when non-empty, each node's property list is + /// restricted to properties whose name is in this set; an empty set means no + /// filter — all properties are included + /// @return the root graph node with all resolved (and filtered) relations + /// @throws EntityNotFoundException when no entity matches the given identifiers + @Transactional(readOnly = true) + public EntityGraphNode getEntityGraph(String templateIdentifier, String entityIdentifier, + int depth, boolean includeProperties, Set relationFilter, + Set propertyFilter) { + int effectiveDepth = Math.clamp(depth, 1, MAX_DEPTH); + + entityTemplateValidationService.validateTemplateExists(templateIdentifier); + + Entity rootEntity = entityRepositoryPort + .findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) + .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); + + Map entityMap = entityGraphRepositoryPort + .findEntityGraph(templateIdentifier, entityIdentifier, effectiveDepth, includeProperties); + + EntityCompositeKey rootKey = new EntityCompositeKey(rootEntity.templateIdentifier(), + rootEntity.identifier()); + + // One shared visited set per request — each node is fully expanded at most + // once, + // preventing O(2^depth) recursion from mutual outbound/inbound re-expansion. + Set visitedNodeIds = new HashSet<>(); + + return buildGraphNode(rootKey, entityMap, effectiveDepth, includeProperties, relationFilter, + propertyFilter, visitedNodeIds); + } + + /// Builds a graph node from a pre-loaded entity map (no database calls). + /// + /// [visitedNodeIds] tracks nodes that have already been fully built in this + /// traversal. + /// When a node is encountered again (cycle or shared reference), a stub leaf is + /// returned + /// immediately to cut the recursion — preventing the exponential blowup that + /// arises from + /// inbound scanning re-expanding the same nodes at every depth level. + private EntityGraphNode buildGraphNode(EntityCompositeKey key, + Map entityMap, int remainingDepth, boolean includeProperties, + Set relationFilter, Set propertyFilter, Set visitedNodeIds) { + Entity entity = entityMap.get(key); + if (entity == null) { + return new EntityGraphNode(key.templateIdentifier(), key.identifier(), key.identifier(), + List.of(), List.of(), List.of()); + } + + // Guard: return a stub leaf if this node was already fully built in another + // branch. + // This breaks both directed cycles (A→B→A) and shared references (A→B, C→B). + // Properties are still included so data is not silently dropped for shared + // nodes. + var nodeId = entity.templateIdentifier() + ":" + entity.identifier(); + if (!visitedNodeIds.add(nodeId)) { + List stubProperties = resolveProperties(entity, includeProperties, propertyFilter); + return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), + stubProperties, List.of(), List.of()); + } + + // Depth exhausted — return a leaf with no relations but still carry properties + // so the deepest reachable entities expose their data when include_data=true. + if (remainingDepth <= 0) { + List leafProperties = resolveProperties(entity, includeProperties, propertyFilter); + return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), + leafProperties, List.of(), List.of()); + } + + List outboundRelations = entity.relations().stream() + .filter(relation -> relationFilter.isEmpty() || relationFilter.contains(relation.name())) + .map(relation -> new EntityGraphRelation(relation.name(), + relation.targetEntityIdentifiers().stream() + .map(targetId -> buildGraphNode(findKeyByIdentifier(targetId, entityMap), entityMap, + remainingDepth - 1, includeProperties, relationFilter, propertyFilter, + visitedNodeIds)) + .toList())) + .toList(); + + List inboundRelations = buildRelationsAsTargetFromMap(entity.identifier(), + entityMap, remainingDepth - 1, includeProperties, relationFilter, propertyFilter, + visitedNodeIds); + + List properties = resolveProperties(entity, includeProperties, propertyFilter); + return new EntityGraphNode(entity.templateIdentifier(), entity.identifier(), entity.name(), + properties, outboundRelations, inboundRelations); + } + + /// Looks up a composite key from the map by identifier alone. + /// Falls back to a synthetic key if no match is found (entity not in graph). + private EntityCompositeKey findKeyByIdentifier(String identifier, + Map entityMap) { + return entityMap.keySet().stream().filter(k -> k.identifier().equals(identifier)).findFirst() + .orElse(new EntityCompositeKey("", identifier)); + } + + /// Builds incoming relations (where this entity is the target) from the + /// pre-loaded entity map. + /// Passes [visitedNodeIds] through so that source nodes already expanded + /// elsewhere are not + /// re-expanded here, preventing the mutual recursion that causes OOM at high + /// depths. + private List buildRelationsAsTargetFromMap(String targetIdentifier, + Map entityMap, int remainingDepth, boolean includeProperties, + Set relationFilter, Set propertyFilter, Set visitedNodeIds) { + Map> sourcesByRelationName = new HashMap<>(); + + for (Map.Entry entry : entityMap.entrySet()) { + Entity sourceEntity = entry.getValue(); + for (Relation relation : sourceEntity.relations()) { + if (relation.targetEntityIdentifiers().contains(targetIdentifier) + && (relationFilter.isEmpty() || relationFilter.contains(relation.name()))) { + sourcesByRelationName.computeIfAbsent(relation.name(), k -> new ArrayList<>()) + .add(entry.getKey()); + } + } + } + + return sourcesByRelationName.entrySet().stream() + .map(e -> new EntityGraphRelation(e.getKey(), + e.getValue().stream() + .map(sourceKey -> buildGraphNode(sourceKey, entityMap, remainingDepth, + includeProperties, relationFilter, propertyFilter, visitedNodeIds)) + .toList())) + .toList(); + } + + /// Returns the entity's properties filtered by [propertyFilter] when active, + /// or an empty list when [includeProperties] is false. + private List resolveProperties(Entity entity, boolean includeProperties, + Set propertyFilter) { + if (!includeProperties) { + return List.of(); + } + if (propertyFilter.isEmpty()) { + return entity.properties(); + } + return entity.properties().stream().filter(p -> propertyFilter.contains(p.name())).toList(); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java index 4404f8a..0ce8107 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java @@ -5,9 +5,10 @@ /// Centralized OpenAPI documentation constants for consistent API descriptions. /// -/// **Documentation standardization rationale:** Maintains consistency across all API -/// endpoints by centralizing descriptions, response messages, and field documentation. -/// Prevents duplication and ensures uniform language throughout the API. +/// **Documentation standardization rationale:** Maintains consistency across all +/// API endpoints by centralizing descriptions, response messages, and field +/// documentation. Prevents duplication and ensures uniform language throughout +/// the API. /// /// **Organization strategy:** /// - HTTP status codes and standard responses @@ -15,8 +16,9 @@ /// - Schema and field descriptions for comprehensive API documentation /// - Pagination parameter descriptions for consistent query interfaces /// -/// **Maintenance benefits:** Single point of truth for API documentation strings, -/// enabling easy updates and internationalization if needed in the future. +/// **Maintenance benefits:** Single point of truth for API documentation +/// strings, enabling easy updates and internationalization if needed in +/// the future. @NoArgsConstructor(access = AccessLevel.PRIVATE) public class SwaggerDescription { @@ -155,4 +157,24 @@ public class SwaggerDescription { Optional filter query using a simple expression language. See more details in the API documentation. Example: `name:idp` for entities with names containing 'idp'. """; public static final String RESPONSE_INVALID_QUERY = "Invalid filter query syntax"; + + // --- Entity Graph (flat nodes & edges) descriptions --- + public static final String PARAM_DEPTH_DESCRIPTION = "Maximum traversal depth for relationship resolution. Clamped between 1 and 10."; + public static final String ENDPOINT_GET_ENTITY_GRAPH_FLAT_SUMMARY = "Get entity relationship graph as flat nodes and edges"; + public static final String ENDPOINT_GET_ENTITY_GRAPH_FLAT_DESCRIPTION = "Retrieves the entity relationship graph as a flat nodes-and-edges structure, suitable for frontend visualization tools such as React Flow, Vis.js, and Cytoscape."; + public static final String RESPONSE_ENTITY_GRAPH_FLAT_SUCCESS = "Flat entity graph successfully retrieved"; + public static final String ENTITY_GRAPH_FLAT_NODES_DESCRIPTION = "All entity nodes in the graph"; + public static final String ENTITY_GRAPH_FLAT_EDGES_DESCRIPTION = "All directed relation edges in the graph"; + public static final String ENTITY_GRAPH_FLAT_NODE_ID_DESCRIPTION = "Unique node identifier composed of templateIdentifier:identifier"; + public static final String ENTITY_GRAPH_FLAT_NODE_LABEL_DESCRIPTION = "Human-readable entity name"; + public static final String ENTITY_GRAPH_FLAT_NODE_TEMPLATE_DESCRIPTION = "Template identifier this entity belongs to"; + public static final String ENTITY_GRAPH_FLAT_NODE_IDENTIFIER_DESCRIPTION = "Business identifier of the entity within its template"; + public static final String ENTITY_GRAPH_FLAT_EDGE_ID_DESCRIPTION = "Unique edge identifier"; + public static final String ENTITY_GRAPH_FLAT_EDGE_SOURCE_DESCRIPTION = "Node id of the source entity"; + public static final String ENTITY_GRAPH_FLAT_EDGE_TARGET_DESCRIPTION = "Node id of the target entity"; + public static final String ENTITY_GRAPH_FLAT_EDGE_TYPE_DESCRIPTION = "Relation name as defined in the entity template"; + public static final String ENTITY_GRAPH_FLAT_NODE_DATA_DESCRIPTION = "Entity property values keyed by property name; present only when include_data=true is requested"; + public static final String PARAM_INCLUDE_DATA_DESCRIPTION = "When true, each graph node includes a data object containing the entity's property values. Defaults to false."; + public static final String PARAM_RELATIONS_DESCRIPTION = "When provided, only relations whose name matches one of the listed values are traversed and included. Omit to include all relations."; + public static final String PARAM_PROPERTIES_DESCRIPTION = "When provided, each node's data object is restricted to the listed property names. Requires include_data=true to have any effect. Omit to include all properties."; } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java new file mode 100644 index 0000000..5aa6a68 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphController.java @@ -0,0 +1,92 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.controller; + +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITY_GRAPH_FLAT_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITY_GRAPH_FLAT_SUMMARY; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.NOT_FOUND_CODE; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.OK_CODE; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_DEPTH_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_INCLUDE_DATA_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_PROPERTIES_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_RELATIONS_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_GRAPH_FLAT_SUCCESS; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER; +import static org.springframework.http.HttpStatus.OK; + +import java.util.List; +import java.util.Set; + +import jakarta.validation.constraints.NotBlank; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; +import com.decathlon.idp_core.domain.service.entity_graph.EntityGraphService; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityGraphFlatDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.handler.ApiExceptionHandler.ErrorResponse; +import com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity.EntityGraphFlatDtoOutMapper; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +/// REST controller for entity relationship graph operations. +/// +/// Provides endpoints to retrieve flat (nodes and edges) relationship graphs +/// starting from a specified entity, suitable for frontend visualization tools +/// such as React Flow, Vis.js, and Cytoscape. +@RestController +@RequestMapping("/api/v1/entities") +@RequiredArgsConstructor +@Tag(name = "Entity Graph", description = "Entity relationship graph operations") +public class EntityGraphController { + + private final EntityGraphService entityGraphService; + + /// Retrieves the entity relationship graph as a flat nodes-and-edges structure. + /// + /// Returns all entities as nodes and all directed relations as edges. Nodes are + /// deduplicated; edges encode directionality. Suitable for React Flow, Vis.js, + /// Cytoscape, and similar frontend graph visualization libraries. + /// + /// @param templateIdentifier the template identifier of the root entity + /// @param entityIdentifier the business identifier of the root entity + /// @param depth the maximum traversal depth (default 1, clamped between 1 and + /// 10) + /// @param includeData when true, each node includes a data object with entity + /// property values + /// @param relations when provided, only relations with matching names are + /// included + /// @param properties when provided, each node's data object is restricted to + /// the listed property names + /// @return flat DTO containing nodes and edges arrays + @GetMapping("/{templateIdentifier}/{entityIdentifier}/graph") + @ResponseStatus(OK) + @Operation(summary = ENDPOINT_GET_ENTITY_GRAPH_FLAT_SUMMARY, description = ENDPOINT_GET_ENTITY_GRAPH_FLAT_DESCRIPTION, responses = { + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_ENTITY_GRAPH_FLAT_SUCCESS, content = @Content(schema = @Schema(implementation = EntityGraphFlatDtoOut.class))), + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = @Content(schema = @Schema(implementation = ErrorResponse.class)))}) + public EntityGraphFlatDtoOut getEntityGraph(@PathVariable @NotBlank String templateIdentifier, + @PathVariable @NotBlank String entityIdentifier, + @Parameter(description = PARAM_DEPTH_DESCRIPTION) @RequestParam(defaultValue = "1") int depth, + @Parameter(description = PARAM_INCLUDE_DATA_DESCRIPTION) @RequestParam(name = "include_data", defaultValue = "false") boolean includeData, + @Parameter(description = PARAM_RELATIONS_DESCRIPTION) @RequestParam(required = false) List relations, + @Parameter(description = PARAM_PROPERTIES_DESCRIPTION) @RequestParam(required = false) List properties) { + + // Convert the nullable lists to Sets for O(1) lookup; empty set means no filter + Set relationFilter = relations != null ? Set.copyOf(relations) : Set.of(); + Set propertyFilter = properties != null ? Set.copyOf(properties) : Set.of(); + + EntityGraphNode graphNode = entityGraphService.getEntityGraph(templateIdentifier, + entityIdentifier, depth, includeData, relationFilter, propertyFilter); + + return EntityGraphFlatDtoOutMapper.toFlatDto(graphNode); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphEdgeDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphEdgeDtoOut.java new file mode 100644 index 0000000..d94bef1 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphEdgeDtoOut.java @@ -0,0 +1,27 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity; + +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_EDGE_ID_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_EDGE_SOURCE_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_EDGE_TARGET_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_EDGE_TYPE_DESCRIPTION; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; + +/// Output DTO representing a directed relation edge in the flat entity graph. +/// +/// Encodes a single directional connection between two entity nodes, identified +/// by their composite-key-derived node IDs. +@JsonNaming(SnakeCaseStrategy.class) +public record EntityGraphEdgeDtoOut( + + @Schema(description = ENTITY_GRAPH_FLAT_EDGE_ID_DESCRIPTION) String id, + + @Schema(description = ENTITY_GRAPH_FLAT_EDGE_SOURCE_DESCRIPTION) String source, + + @Schema(description = ENTITY_GRAPH_FLAT_EDGE_TARGET_DESCRIPTION) String target, + + @Schema(description = ENTITY_GRAPH_FLAT_EDGE_TYPE_DESCRIPTION) String type) { +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphFlatDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphFlatDtoOut.java new file mode 100644 index 0000000..a612785 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphFlatDtoOut.java @@ -0,0 +1,30 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity; + +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_EDGES_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_NODES_DESCRIPTION; + +import java.util.List; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; + +/// Top-level response DTO for the flat entity graph representation. +/// +/// Separates entities from their connections into two parallel collections, +/// following the de-facto standard expected by frontend visualization libraries +/// such as React Flow, Vis.js, and Cytoscape. This format avoids nesting and +/// any risk of infinite loops caused by circular relations. +@JsonNaming(SnakeCaseStrategy.class) +public record EntityGraphFlatDtoOut( + + @Schema(description = ENTITY_GRAPH_FLAT_NODES_DESCRIPTION) List nodes, + + @Schema(description = ENTITY_GRAPH_FLAT_EDGES_DESCRIPTION) List edges) { + /// Defensive copies prevent external mutation of the returned collections. + public EntityGraphFlatDtoOut { + nodes = nodes != null ? List.copyOf(nodes) : List.of(); + edges = edges != null ? List.copyOf(edges) : List.of(); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeDtoOut.java new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java new file mode 100644 index 0000000..45fad52 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphNodeFlatDtoOut.java @@ -0,0 +1,43 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity; + +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_NODE_DATA_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_NODE_IDENTIFIER_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_NODE_ID_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_NODE_LABEL_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENTITY_GRAPH_FLAT_NODE_TEMPLATE_DESCRIPTION; + +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import io.swagger.v3.oas.annotations.media.Schema; + +/// Output DTO representing a single node in the flat entity graph. +/// +/// Used by frontend visualization tools (React Flow, Vis.js, Cytoscape) that expect +/// entities and their relationships as separate, non-nested collections. +/// +/// The optional `data` field is populated only when `include_data=true` is requested, +/// containing property name-to-value pairs for the entity. +@JsonNaming(SnakeCaseStrategy.class) +public record EntityGraphNodeFlatDtoOut( + + @Schema(description = ENTITY_GRAPH_FLAT_NODE_ID_DESCRIPTION) String id, + + @Schema(description = ENTITY_GRAPH_FLAT_NODE_LABEL_DESCRIPTION) String label, + + @Schema(description = ENTITY_GRAPH_FLAT_NODE_TEMPLATE_DESCRIPTION) String templateIdentifier, + + @Schema(description = ENTITY_GRAPH_FLAT_NODE_IDENTIFIER_DESCRIPTION) String identifier, + + @JsonInclude(Include.NON_EMPTY) @Schema(description = ENTITY_GRAPH_FLAT_NODE_DATA_DESCRIPTION) Map data) { + /// Compact constructor: defensively copies the data map to prevent external + /// mutation + /// of the DTO after construction (EI_EXPOSE_REP2 / EI_EXPOSE_REP). + public EntityGraphNodeFlatDtoOut { + data = data == null ? Map.of() : Map.copyOf(data); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/EntityGraphRelationDtoOut.java new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/RelationAsTargetSummaryDtoOut.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/RelationAsTargetSummaryDtoOut.java new file mode 100644 index 0000000..b5a12ae --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/dto/out/entity/RelationAsTargetSummaryDtoOut.java @@ -0,0 +1,10 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +/// Output DTO representing an incoming relationship where the entity is the target. +@JsonNaming(SnakeCaseStrategy.class) +public record RelationAsTargetSummaryDtoOut(String targetEntityIdentifier, String relationName, + String sourceEntityIdentifier, String sourceEntityName) { +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphDtoOutMapper.java new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java new file mode 100644 index 0000000..83d5785 --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/mapper/entity/EntityGraphFlatDtoOutMapper.java @@ -0,0 +1,141 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.mapper.entity; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.SequencedSet; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; + +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphRelation; +import com.decathlon.idp_core.domain.service.entity_graph.EntityGraphService; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityGraphEdgeDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityGraphFlatDtoOut; +import com.decathlon.idp_core.infrastructure.adapters.api.dto.out.entity.EntityGraphNodeFlatDtoOut; + +/// Mapper for converting a recursive [EntityGraphNode] domain tree into the flat +/// nodes-and-edges representation expected by frontend visualization libraries +/// (React Flow, Vis.js, Cytoscape). +/// +/// **Design:** +/// - Traverses both `relations` (outbound) and `relationsAsTarget` (inbound) depth-first, +/// deduplicating nodes by their composite node ID (templateIdentifier:identifier). +/// - Outbound edges are emitted as `source → target`. +/// - Inbound edges (relationsAsTarget) are emitted as `source → currentNode`, preserving +/// the original direction of the relation. This is critical when the root entity has no +/// outbound relations and is only reachable as a relation target. +/// - A `SequencedSet` of visited node IDs prevents infinite loops in cyclic graphs. +/// - A `Set` of edge signatures (`source|target|label`) deduplicates edges that would +/// otherwise be emitted twice when both sides of a relation are traversed. +/// - Filtering (relation names, property names) is a domain concern handled upstream by +/// [EntityGraphService]; this mapper only flattens the tree it receives. +public final class EntityGraphFlatDtoOutMapper { + + private EntityGraphFlatDtoOutMapper() { + // Utility class — not instantiable + } + + /// Groups mutable traversal accumulators to stay within the method-parameter + /// limit + /// and keep the traversal signature readable. + private record TraversalState(SequencedSet nodes, + List edges, Set visitedNodeIds, + Set emittedEdgeSignatures, AtomicInteger edgeCounter) { + } + + /// Maps a domain graph node tree to a flat [EntityGraphFlatDtoOut]. + /// + /// The domain graph passed here is already filtered by the service layer; + /// this method only performs structural flattening. + /// + /// @param root the root [EntityGraphNode] returned by the domain service + /// @return flat DTO with deduplicated nodes and directed edges + public static EntityGraphFlatDtoOut toFlatDto(EntityGraphNode root) { + if (root == null) { + return new EntityGraphFlatDtoOut(List.of(), List.of()); + } + + var state = new TraversalState(new LinkedHashSet<>(), // nodes — insertion-ordered, deduplicated + new ArrayList<>(), // edges + new HashSet<>(), // visitedNodeIds — prevents infinite loops in cyclic graphs + new HashSet<>(), // emittedEdgeSignatures — prevents duplicate edges + new AtomicInteger(0)); // edgeCounter + + traverse(root, state); + + return new EntityGraphFlatDtoOut(List.copyOf(state.nodes()), List.copyOf(state.edges())); + } + + private static void traverse(EntityGraphNode node, TraversalState state) { + + var nodeId = nodeId(node.templateIdentifier(), node.identifier()); + + // Skip this node if already visited to prevent infinite loops in cyclic graphs + if (!state.visitedNodeIds().add(nodeId)) { + return; + } + + state.nodes().add(new EntityGraphNodeFlatDtoOut(nodeId, node.name(), node.templateIdentifier(), + node.identifier(), toDataMap(node))); + + // Traverse outbound relations: emit edge from currentNode → target. + for (EntityGraphRelation relation : node.relations()) { + for (EntityGraphNode target : relation.targets()) { + var targetId = nodeId(target.templateIdentifier(), target.identifier()); + addEdge(state, nodeId, targetId, relation.name()); + traverse(target, state); + } + } + + // Traverse inbound relations: emit edge from source → currentNode. + // This is essential when the root entity has no outbound relations and is only + // reachable as a target. Without this, traversal would stop at the root with no + // edges. + for (EntityGraphRelation relation : node.relationsAsTarget()) { + for (EntityGraphNode source : relation.targets()) { + var sourceId = nodeId(source.templateIdentifier(), source.identifier()); + addEdge(state, sourceId, nodeId, relation.name()); + traverse(source, state); + } + } + } + + /// Adds a directed edge only if it has not been emitted before, preventing + /// duplicates + /// that arise when the same relation is encountered from both the source and + /// the target + /// during depth-first traversal. + private static void addEdge(TraversalState state, String sourceId, String targetId, + String label) { + + var signature = sourceId + "|" + targetId + "|" + label; + if (state.emittedEdgeSignatures().add(signature)) { + state.edges().add(new EntityGraphEdgeDtoOut("e" + state.edgeCounter().incrementAndGet(), + sourceId, targetId, label)); + } + } + + /// Builds the unique node identifier from the entity's composite key. + /// Format: "templateIdentifier:identifier" — mirrors + /// EntityCompositeKey.toString(). + private static String nodeId(String templateIdentifier, String identifier) { + return templateIdentifier + ":" + identifier; + } + + /// Converts a node's property list to a name→value map for the `data` field. + /// + /// The domain service has already applied any property filter; this method + /// simply converts whatever properties the node carries into the map format + /// expected by the DTO. + /// + /// Returns an empty map when there are no properties; the DTO's + /// @JsonInclude(NON_EMPTY) annotation ensures an empty map is omitted from the + /// JSON output. + private static Map toDataMap(EntityGraphNode node) { + return node.properties().stream().collect(Collectors.toMap(p -> p.name(), p -> p.value())); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java new file mode 100644 index 0000000..c84c1ac --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityGraphAdapter.java @@ -0,0 +1,74 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.decathlon.idp_core.domain.model.entity.Entity; +import com.decathlon.idp_core.domain.model.entity.EntityCompositeKey; +import com.decathlon.idp_core.domain.port.EntityGraphRepositoryPort; +import com.decathlon.idp_core.infrastructure.adapters.persistence.mapper.EntityPersistenceMapper; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.EntityJpaEntity; +import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.JpaEntityRepository; + +import lombok.RequiredArgsConstructor; + +/// Persistence adapter dedicated to entity relationship graph traversal. +/// +/// Separated from [PostgresEntityAdapter] because graph queries use a distinct +/// recursive CTE strategy that has no overlap with standard CRUD operations, +/// following the Interface Segregation Principle. +/// +/// **Query strategy:** +/// 1. One recursive CTE query to collect all (identifier, template_identifier) pairs in the graph. +/// 2. One batch query to load entities with their relations (avoids N+1). +/// 3. One batch query to load properties separately (avoids MultipleBagFetchException). +@Component +@RequiredArgsConstructor +public class PostgresEntityGraphAdapter implements EntityGraphRepositoryPort { + + private final JpaEntityRepository jpaEntityRepository; + private final EntityPersistenceMapper mapper; + + @Override + @Transactional(readOnly = true) + public Map findEntityGraph(String templateIdentifier, + String entityIdentifier, int depth, boolean includeProperties) { + // Step 1: collect all (identifier, template_identifier) pairs via recursive + // CTE. + // The CTE always traverses ALL relation types to discover all reachable nodes. + // Relation name filtering is applied at the service level when building edges, + // so nodes reachable via any path are included even if the filter only matches + // edges at deeper levels (e.g. filtering "owns" still returns B→C when A→B→C). + List graphPairs = jpaEntityRepository.findEntityGraphIdentifiers(templateIdentifier, + entityIdentifier, depth); + + if (graphPairs.isEmpty()) { + return Map.of(); + } + + // Step 2: extract unique identifiers for batch loading + List identifiers = graphPairs.stream().map(pair -> (String) pair[0]).distinct() + .toList(); + + // Step 3: batch-load entities with relations, then optionally properties in a + // separate + // query. Properties are skipped when not requested to avoid the extra + // round-trip and + // keep payloads lean. The two-query split also avoids Hibernate's + // MultipleBagFetchException. + List jpaEntities = jpaEntityRepository + .findAllByIdentifierInWithRelations(identifiers); + if (includeProperties) { + jpaEntityRepository.findAllByIdentifierInWithProperties(identifiers); + } + + // Step 4: map to domain and key by composite key for O(1) lookup + return jpaEntities.stream().map(mapper::toDomain).collect(Collectors.toMap( + e -> new EntityCompositeKey(e.templateIdentifier(), e.identifier()), Function.identity())); + } +} diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java index 2289c9b..1ddc3bb 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java @@ -36,6 +36,118 @@ Optional findByTemplateIdentifierAndIdentifier(String templateI Page findByTemplateIdentifier(String templateIdentifier, Pageable pageable); + /// Batch fetch entities by identifiers with eager loading of relations and + /// properties. Uses two separate queries to avoid Hibernate's + /// MultipleBagFetchException. First fetches entities with relations, then + /// fetches properties separately. + @Query("SELECT DISTINCT e FROM EntityJpaEntity e LEFT JOIN FETCH e.relations WHERE e.identifier IN :identifiers") + List findAllByIdentifierInWithRelations( + @Param("identifiers") Collection identifiers); + + /// Fetch properties for entities that were already loaded. This is called after + /// findAllByIdentifierInWithRelations to complete the entity graph. + @Query("SELECT DISTINCT e FROM EntityJpaEntity e LEFT JOIN FETCH e.properties WHERE e.identifier IN :identifiers") + List findAllByIdentifierInWithProperties( + @Param("identifiers") Collection identifiers); + + @Query(value = """ + WITH RECURSIVE + -- Traverse outbound relations (this entity -> targets) + outbound_graph(identifier, template_identifier, depth) AS ( + SELECT e.identifier, e.template_identifier, 0 + FROM entity e + WHERE e.identifier = :entityIdentifier + AND e.template_identifier = :templateIdentifier + + UNION ALL + + SELECT e2.identifier, e2.template_identifier, og.depth + 1 + FROM outbound_graph og + JOIN entity e ON e.identifier = og.identifier AND e.template_identifier = og.template_identifier + JOIN entity_relations er ON er.entity_id = e.id + JOIN relation r ON r.id = er.relation_id + JOIN relation_target_entities rte ON rte.relation_id = r.id + JOIN entity e2 ON e2.identifier = rte.target_entity_identifier + WHERE og.depth < :depth + ), + -- Traverse inbound relations (sources -> this entity as target) + inbound_graph(identifier, template_identifier, depth) AS ( + SELECT e.identifier, e.template_identifier, 0 + FROM entity e + WHERE e.identifier = :entityIdentifier + AND e.template_identifier = :templateIdentifier + + UNION ALL + + SELECT e2.identifier, e2.template_identifier, ig.depth + 1 + FROM inbound_graph ig + JOIN entity e ON e.identifier = ig.identifier AND e.template_identifier = ig.template_identifier + JOIN relation_target_entities rte ON rte.target_entity_identifier = e.identifier + JOIN relation r ON r.id = rte.relation_id + JOIN entity_relations er ON er.relation_id = r.id + JOIN entity e2 ON e2.id = er.entity_id + WHERE ig.depth < :depth + ) + SELECT DISTINCT identifier, template_identifier FROM outbound_graph + UNION + SELECT DISTINCT identifier, template_identifier FROM inbound_graph + """, nativeQuery = true) + List findEntityGraphIdentifiers(@Param("templateIdentifier") String templateIdentifier, + @Param("entityIdentifier") String entityIdentifier, @Param("depth") int depth); + + /// Variant of [findEntityGraphIdentifiers] that restricts traversal to the + /// given relation names. When the list is empty, all relation names are + /// followed + /// (no filter). The filter is applied inside both the outbound and inbound + /// recursive CTE steps so that only entities reachable through the specified + /// relations are returned, keeping the result set lean. + @Query(value = """ + WITH RECURSIVE + outbound_graph(identifier, template_identifier, depth) AS ( + SELECT e.identifier, e.template_identifier, 0 + FROM entity e + WHERE e.identifier = :entityIdentifier + AND e.template_identifier = :templateIdentifier + + UNION ALL + + SELECT e2.identifier, e2.template_identifier, og.depth + 1 + FROM outbound_graph og + JOIN entity e ON e.identifier = og.identifier AND e.template_identifier = og.template_identifier + JOIN entity_relations er ON er.entity_id = e.id + JOIN relation r ON r.id = er.relation_id + JOIN relation_target_entities rte ON rte.relation_id = r.id + JOIN entity e2 ON e2.identifier = rte.target_entity_identifier + WHERE og.depth < :depth + AND r.name IN :relationNames + ), + inbound_graph(identifier, template_identifier, depth) AS ( + SELECT e.identifier, e.template_identifier, 0 + FROM entity e + WHERE e.identifier = :entityIdentifier + AND e.template_identifier = :templateIdentifier + + UNION ALL + + SELECT e2.identifier, e2.template_identifier, ig.depth + 1 + FROM inbound_graph ig + JOIN entity e ON e.identifier = ig.identifier AND e.template_identifier = ig.template_identifier + JOIN relation_target_entities rte ON rte.target_entity_identifier = e.identifier + JOIN relation r ON r.id = rte.relation_id + JOIN entity_relations er ON er.relation_id = r.id + JOIN entity e2 ON e2.id = er.entity_id + WHERE ig.depth < :depth + AND r.name IN :relationNames + ) + SELECT DISTINCT identifier, template_identifier FROM outbound_graph + UNION + SELECT DISTINCT identifier, template_identifier FROM inbound_graph + """, nativeQuery = true) + List findEntityGraphIdentifiersFilteredByRelations( + @Param("templateIdentifier") String templateIdentifier, + @Param("entityIdentifier") String entityIdentifier, @Param("depth") int depth, + @Param("relationNames") Collection relationNames); + @Modifying(clearAutomatically = true, flushAutomatically = true) @Query(""" DELETE FROM PropertyJpaEntity p 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 13129fc..4e642d6 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaRelationRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaRelationRepository.java @@ -15,7 +15,9 @@ public interface JpaRelationRepository extends JpaRepository { @Query(""" - SELECT tei AS targetEntityIdentifier, r.name AS relationName, e.identifier AS sourceEntityIdentifier, e.name AS sourceEntityName + SELECT new com.decathlon.idp_core.domain.model.entity.RelationAsTargetSummary( + tei, r.name, e.identifier, e.name + ) FROM EntityJpaEntity e JOIN e.relations r JOIN r.targetEntityIdentifiers tei diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java new file mode 100644 index 0000000..66419a5 --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_graph/EntityGraphServiceTest.java @@ -0,0 +1,472 @@ +package com.decathlon.idp_core.domain.service.entity_graph; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; +import com.decathlon.idp_core.domain.model.entity.Entity; +import com.decathlon.idp_core.domain.model.entity.EntityCompositeKey; +import com.decathlon.idp_core.domain.model.entity.Property; +import com.decathlon.idp_core.domain.model.entity.Relation; +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphNode; +import com.decathlon.idp_core.domain.model.entity_graph.EntityGraphRelation; +import com.decathlon.idp_core.domain.port.EntityGraphRepositoryPort; +import com.decathlon.idp_core.domain.port.EntityRepositoryPort; +import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateValidationService; + +@ExtendWith(MockitoExtension.class) +@DisplayName("EntityGraphService Tests") +class EntityGraphServiceTest { + + @Mock + private EntityRepositoryPort entityRepositoryPort; + + @Mock + private EntityGraphRepositoryPort entityGraphRepositoryPort; + + @Mock + private EntityTemplateValidationService entityTemplateValidationService; + + @InjectMocks + private EntityGraphService entityGraphService; + + // --- Fixtures --- + + private Entity entity(String templateIdentifier, String identifier, String name) { + return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, List.of(), + List.of()); + } + + private Entity entityWithRelations(String templateIdentifier, String identifier, String name, + List relations) { + return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, List.of(), + relations); + } + + private Relation relation(String name, String targetTemplateIdentifier, String... targetIds) { + return new Relation(UUID.randomUUID(), name, targetTemplateIdentifier, List.of(targetIds)); + } + + private EntityCompositeKey key(String templateIdentifier, String identifier) { + return new EntityCompositeKey(templateIdentifier, identifier); + } + + private static final String TEMPLATE = "web-service"; + + // --- Helper to stub both ports --- + + private void stubGraph(Map entityMap) { + when( + entityGraphRepositoryPort.findEntityGraph(anyString(), anyString(), anyInt(), anyBoolean())) + .thenReturn(entityMap); + } + + // ======================== + @Nested + @DisplayName("Root Entity Not Found") + class RootEntityNotFound { + + @Test + @DisplayName("Should throw EntityNotFoundException when root entity does not exist") + void shouldThrowWhenRootEntityNotFound() { + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "missing")) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> entityGraphService.getEntityGraph(TEMPLATE, "missing", 1, false, + Set.of(), Set.of())).isInstanceOf(EntityNotFoundException.class); + + verify(entityGraphRepositoryPort, never()).findEntityGraph(anyString(), anyString(), anyInt(), + anyBoolean()); + } + } + + // ======================== + @Nested + @DisplayName("Single Root — No Relations") + class SingleRootNoRelations { + + @Test + @DisplayName("Should return leaf node when entity has no relations") + void shouldReturnLeafNodeWhenNoRelations() { + Entity api = entity(TEMPLATE, "api", "API Service"); + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, + Set.of(), Set.of()); + + assertThat(result.identifier()).isEqualTo("api"); + assertThat(result.name()).isEqualTo("API Service"); + assertThat(result.relations()).isEmpty(); + assertThat(result.relationsAsTarget()).isEmpty(); + } + } + + // ======================== + @Nested + @DisplayName("Outbound Relations") + class OutboundRelations { + + @Test + @DisplayName("Should resolve outbound relation targets at depth 1") + void shouldResolveOutboundRelations() { + Entity api = entityWithRelations(TEMPLATE, "api", "API Service", + List.of(relation("uses-db", "database", "postgres"))); + Entity postgres = entity("database", "postgres", "Postgres DB"); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api, key("database", "postgres"), postgres)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, + Set.of(), Set.of()); + + assertThat(result.relations()).hasSize(1); + assertThat(result.relations().get(0).name()).isEqualTo("uses-db"); + assertThat(result.relations().get(0).targets()).hasSize(1); + assertThat(result.relations().get(0).targets().get(0).identifier()).isEqualTo("postgres"); + } + + @Test + @DisplayName("Should return fallback node when target is not in the pre-loaded entity map") + void shouldReturnFallbackNodeWhenTargetNotInMap() { + Entity api = entityWithRelations(TEMPLATE, "api", "API Service", + List.of(relation("uses-db", "database", "missing-db"))); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, + Set.of(), Set.of()); + + assertThat(result.relations()).hasSize(1); + EntityGraphNode fallback = result.relations().get(0).targets().get(0); + assertThat(fallback.identifier()).isEqualTo("missing-db"); + } + } + + // ======================== + @Nested + @DisplayName("Inbound Relations (relationsAsTarget)") + class InboundRelations { + + @Test + @DisplayName("Should resolve inbound relations when another entity points to root") + void shouldResolveInboundRelations() { + Entity api = entity(TEMPLATE, "api", "API Service"); + Entity consumer = entityWithRelations(TEMPLATE, "consumer", "Consumer", + List.of(relation("depends-on", TEMPLATE, "api"))); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api, key(TEMPLATE, "consumer"), consumer)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, + Set.of(), Set.of()); + + assertThat(result.relationsAsTarget()).hasSize(1); + assertThat(result.relationsAsTarget().get(0).name()).isEqualTo("depends-on"); + assertThat(result.relationsAsTarget().get(0).targets().get(0).identifier()) + .isEqualTo("consumer"); + } + } + + // ======================== + @Nested + @DisplayName("Depth Clamping") + class DepthClamping { + + @Test + @DisplayName("Should clamp depth below 1 to 1") + void shouldClampDepthBelowOne() { + Entity api = entity(TEMPLATE, "api", "API Service"); + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api)); + + entityGraphService.getEntityGraph(TEMPLATE, "api", 0, false, Set.of(), Set.of()); + + verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "api", 1, false); + } + + @Test + @DisplayName("Should clamp depth above MAX_DEPTH to MAX_DEPTH") + void shouldClampDepthAboveTen() { + Entity api = entity(TEMPLATE, "api", "API Service"); + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api)); + + entityGraphService.getEntityGraph(TEMPLATE, "api", 99, false, Set.of(), Set.of()); + + verify(entityGraphRepositoryPort).findEntityGraph(TEMPLATE, "api", 10, false); + } + } + + // ======================== + @Nested + @DisplayName("Depth Limit — Leaf Nodes at Boundary") + class DepthLimit { + + @Test + @DisplayName("Should return target as leaf node when depth limit is reached") + void shouldReturnLeafNodeAtDepthBoundary() { + Entity api = entityWithRelations(TEMPLATE, "api", "API Service", + List.of(relation("uses-db", "database", "postgres"))); + Entity postgres = entityWithRelations("database", "postgres", "Postgres DB", + List.of(relation("runs-on", "infra", "server-1"))); + Entity server = entity("infra", "server-1", "Server 1"); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api, key("database", "postgres"), postgres, + key("infra", "server-1"), server)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, + Set.of(), Set.of()); + + EntityGraphNode postgresNode = result.relations().get(0).targets().get(0); + assertThat(postgresNode.identifier()).isEqualTo("postgres"); + // At depth=1, postgres is a leaf — no further relations resolved + assertThat(postgresNode.relations()).isEmpty(); + assertThat(postgresNode.relationsAsTarget()).isEmpty(); + } + } + + // ======================== + @Nested + @DisplayName("Multiple Named Relations") + class MultipleRelations { + + @Test + @DisplayName("Should resolve multiple distinct relation types") + void shouldResolveMultipleNamedRelations() { + Entity api = entityWithRelations(TEMPLATE, "api", "API Service", List.of( + relation("uses-db", "database", "postgres"), relation("depends-on", TEMPLATE, "auth"))); + Entity postgres = entity("database", "postgres", "Postgres DB"); + Entity auth = entity(TEMPLATE, "auth", "Auth Service"); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api, key("database", "postgres"), postgres, + key(TEMPLATE, "auth"), auth)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, + Set.of(), Set.of()); + + assertThat(result.relations()).hasSize(2); + assertThat(result.relations().stream().map(EntityGraphRelation::name)) + .containsExactlyInAnyOrder("uses-db", "depends-on"); + } + } + + // ======================== + @Nested + @DisplayName("Relation Filtering") + class RelationFiltering { + + @Test + @DisplayName("Should include only relations matching the relation filter") + void shouldFilterRelationsByName() { + // A --(depends-on)--> B, A --(owns)--> C; filter keeps only 'depends-on' + Entity a = entityWithRelations(TEMPLATE, "a", "A", + List.of(relation("depends-on", TEMPLATE, "b"), relation("owns", TEMPLATE, "c"))); + Entity b = entity(TEMPLATE, "b", "B"); + Entity c = entity(TEMPLATE, "c", "C"); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "a")) + .thenReturn(Optional.of(a)); + stubGraph(Map.of(key(TEMPLATE, "a"), a, key(TEMPLATE, "b"), b, key(TEMPLATE, "c"), c)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 2, false, + Set.of("depends-on"), Set.of()); + + assertThat(result.relations()).hasSize(1); + assertThat(result.relations().get(0).name()).isEqualTo("depends-on"); + } + + @Test + @DisplayName("Should return all relations when relation filter is empty") + void shouldReturnAllRelationsWhenFilterIsEmpty() { + Entity a = entityWithRelations(TEMPLATE, "a", "A", + List.of(relation("depends-on", TEMPLATE, "b"), relation("owns", TEMPLATE, "c"))); + Entity b = entity(TEMPLATE, "b", "B"); + Entity c = entity(TEMPLATE, "c", "C"); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "a")) + .thenReturn(Optional.of(a)); + stubGraph(Map.of(key(TEMPLATE, "a"), a, key(TEMPLATE, "b"), b, key(TEMPLATE, "c"), c)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 2, false, Set.of(), + Set.of()); + + assertThat(result.relations()).hasSize(2); + assertThat(result.relations().stream().map(EntityGraphRelation::name)) + .containsExactlyInAnyOrder("depends-on", "owns"); + } + + @Test + @DisplayName("Should filter inbound relations by name") + void shouldFilterInboundRelationsByName() { + Entity api = entity(TEMPLATE, "api", "API Service"); + Entity consumer = entityWithRelations(TEMPLATE, "consumer", "Consumer", + List.of(relation("depends-on", TEMPLATE, "api"))); + Entity unrelated = entityWithRelations(TEMPLATE, "unrelated", "Unrelated", + List.of(relation("owns", TEMPLATE, "api"))); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api, key(TEMPLATE, "consumer"), consumer, + key(TEMPLATE, "unrelated"), unrelated)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, + Set.of("depends-on"), Set.of()); + + assertThat(result.relationsAsTarget()).hasSize(1); + assertThat(result.relationsAsTarget().get(0).name()).isEqualTo("depends-on"); + } + } + + // ======================== + @Nested + @DisplayName("Property Filtering") + class PropertyFiltering { + + private Entity entityWithProperties(String templateIdentifier, String identifier, String name, + List properties) { + return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, properties, + List.of()); + } + + @Test + @DisplayName("Should include only properties matching the property filter") + void shouldFilterPropertiesByName() { + var propEnv = new Property(UUID.randomUUID(), "env", "prod"); + var propOwner = new Property(UUID.randomUUID(), "owner", "team-a"); + Entity api = entityWithProperties(TEMPLATE, "api", "API Service", + List.of(propEnv, propOwner)); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, true, Set.of(), + Set.of("env")); + + assertThat(result.properties()).hasSize(1); + assertThat(result.properties().get(0).name()).isEqualTo("env"); + } + + @Test + @DisplayName("Should return all properties when property filter is empty") + void shouldReturnAllPropertiesWhenFilterIsEmpty() { + var propEnv = new Property(UUID.randomUUID(), "env", "prod"); + var propOwner = new Property(UUID.randomUUID(), "owner", "team-a"); + Entity api = entityWithProperties(TEMPLATE, "api", "API Service", + List.of(propEnv, propOwner)); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, true, Set.of(), + Set.of()); + + assertThat(result.properties()).hasSize(2); + } + + @Test + @DisplayName("Should return empty properties when includeProperties is false regardless of filter") + void shouldReturnEmptyPropertiesWhenIncludePropertiesIsFalse() { + var propEnv = new Property(UUID.randomUUID(), "env", "prod"); + Entity api = entityWithProperties(TEMPLATE, "api", "API Service", List.of(propEnv)); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "api")) + .thenReturn(Optional.of(api)); + stubGraph(Map.of(key(TEMPLATE, "api"), api)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "api", 1, false, + Set.of(), Set.of("env")); + + assertThat(result.properties()).isEmpty(); + } + } + + // ======================== + @Nested + @DisplayName("Visited Node Guard — OOM Prevention") + class VisitedNodeGuard { + + @Test + @DisplayName("Should complete at depth=10 without exponential recursion for a small graph") + void shouldNotExplodeAtMaxDepthWithSmallGraph() { + // A --(uses)--> B --(uses)--> C; B also has inbound from A and C has inbound + // from B. + // Without the visited-node guard this produces O(2^depth) calls at depth=10. + Entity a = entityWithRelations(TEMPLATE, "a", "A", List.of(relation("uses", TEMPLATE, "b"))); + Entity b = entityWithRelations(TEMPLATE, "b", "B", List.of(relation("uses", TEMPLATE, "c"))); + Entity c = entity(TEMPLATE, "c", "C"); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "a")) + .thenReturn(Optional.of(a)); + stubGraph(Map.of(key(TEMPLATE, "a"), a, key(TEMPLATE, "b"), b, key(TEMPLATE, "c"), c)); + + // Must complete instantly — any OOM or StackOverflow here means the guard is + // missing. + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 10, false, Set.of(), + Set.of()); + + assertThat(result.identifier()).isEqualTo("a"); + assertThat(result.relations()).hasSize(1); + } + + @Test + @DisplayName("Should return stub leaf for already-visited node instead of re-expanding it") + void shouldReturnStubLeafForRevisitedNode() { + // A --(uses)--> B; B also points back to A (cycle: A→B→A) + Entity a = entityWithRelations(TEMPLATE, "a", "A", List.of(relation("uses", TEMPLATE, "b"))); + Entity b = entityWithRelations(TEMPLATE, "b", "B", List.of(relation("uses", TEMPLATE, "a"))); + + when(entityRepositoryPort.findByTemplateIdentifierAndIdentifier(TEMPLATE, "a")) + .thenReturn(Optional.of(a)); + stubGraph(Map.of(key(TEMPLATE, "a"), a, key(TEMPLATE, "b"), b)); + + EntityGraphNode result = entityGraphService.getEntityGraph(TEMPLATE, "a", 5, false, Set.of(), + Set.of()); + + // A → B is resolved + assertThat(result.relations()).hasSize(1); + EntityGraphNode nodeB = result.relations().get(0).targets().get(0); + assertThat(nodeB.identifier()).isEqualTo("b"); + + // B → A is a revisit: A was already marked visited, so it returns a stub leaf + // with no further outbound or inbound relations (no infinite loop). + EntityGraphNode stubA = nodeB.relations().get(0).targets().get(0); + assertThat(stubA.identifier()).isEqualTo("a"); + assertThat(stubA.relations()).isEmpty(); + assertThat(stubA.relationsAsTarget()).isEmpty(); + } + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java index 428a036..a15741b 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 @@ -52,8 +52,8 @@ void getEntities_paginated_200() throws Exception { .param("size", "15").accept(APPLICATION_JSON)) .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) .andExpect(jsonPath("$.content").isArray()) - .andExpect(jsonPath("$.content.length()").value(2)) - .andExpect(jsonPath("$.page.total_elements").value(2)) + .andExpect(jsonPath("$.content.length()").value(5)) + .andExpect(jsonPath("$.page.total_elements").value(5)) .andExpect(jsonPath("$.page.total_pages").value(1)) .andExpect(jsonPath("$.page.size").value(15)) .andExpect(jsonPath("$.page.number").value(0)) @@ -111,8 +111,8 @@ void getEntities_invalid_pagination_200() throws Exception { .accept(APPLICATION_JSON)) .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) .andExpect(jsonPath("$.content").isArray()) - .andExpect(jsonPath("$.content.length()").value(2)) - .andExpect(jsonPath("$.page.total_elements").value(2)) + .andExpect(jsonPath("$.content.length()").value(5)) + .andExpect(jsonPath("$.page.total_elements").value(5)) .andExpect(jsonPath("$.page.total_pages").value(1)) .andExpect(jsonPath("$.page.size").value(20)) .andExpect(jsonPath("$.page.number").value(0)) @@ -263,8 +263,8 @@ void getEntities_200_emptyOrBlankQ_returnsAllEntities(String q) throws Exception mockMvc .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER).param("q", q) .accept(APPLICATION_JSON)) - .andExpect(status().isOk()).andExpect(jsonPath("$.content.length()").value(2)) - .andExpect(jsonPath("$.page.total_elements").value(2)); + .andExpect(status().isOk()).andExpect(jsonPath("$.content.length()").value(5)) + .andExpect(jsonPath("$.page.total_elements").value(5)); } @Test diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java new file mode 100644 index 0000000..38590cb --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityGraphControllerTest.java @@ -0,0 +1,202 @@ +package com.decathlon.idp_core.infrastructure.adapters.api.controller; + +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +import com.decathlon.idp_core.AbstractIntegrationTest; + +/// Integration tests for the EntityGraphController REST API endpoint. +/// +/// Tests are based on the three-node chain seeded in R__2_Insert_entities_test_data.sql: +/// +/// graph-svc-a --[uses]--> graph-svc-b --[uses]--> graph-svc-c +/// graph-svc-a --[monitors]--> graph-svc-b +/// +/// Key scenarios verified: +/// +/// - No filter: all nodes and edges are returned +/// - Filter "uses": full chain traversed (a→b→c), "monitors" edge excluded at every depth +/// - Filter "monitors": only a→b returned; c is unreachable via "monitors" edges +/// - 404 for unknown entity +/// - 401 without authentication +@DisplayName("GET /api/v1/entities/{templateIdentifier}/{entityIdentifier}/graph") +public class EntityGraphControllerTest extends AbstractIntegrationTest { + + private static final String GRAPH_PATH = "/api/v1/entities/{templateId}/{entityId}/graph"; + private static final String TEMPLATE = "web-service"; + private static final String ENTITY_A = "graph-svc-a"; + private static final String ENTITY_B = "graph-svc-b"; + private static final String ENTITY_C = "graph-svc-c"; + + @Autowired + private MockMvc mockMvc; + + @Nested + @DisplayName("Without relation filter") + class NoFilter { + + @Test + @WithMockUser + @DisplayName("Should return all nodes and edges when no filter is applied (depth=3)") + void shouldReturnAllNodesAndEdgesWithNoFilter() throws Exception { + mockMvc + .perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A).param("depth", "3").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) + // All three nodes must be present + .andExpect( + jsonPath("$.nodes[*].identifier", containsInAnyOrder(ENTITY_A, ENTITY_B, ENTITY_C))) + // Three edges: a-[uses]->b, a-[monitors]->b, b-[uses]->c + .andExpect(jsonPath("$.edges", hasSize(3))); + } + } + + @Nested + @DisplayName("With 'uses' relation filter") + class UsesFilter { + + @Test + @WithMockUser + @DisplayName("Should traverse full chain via 'uses' edges and exclude 'monitors' edge (depth=3)") + void shouldTraverseFullChainWithUsesFilter() throws Exception { + mockMvc + .perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A).param("depth", "3") + .param("relations", "uses").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) + // All three nodes are reachable via "uses" chain: a→b→c + .andExpect( + jsonPath("$.nodes[*].identifier", containsInAnyOrder(ENTITY_A, ENTITY_B, ENTITY_C))) + // Only the two "uses" edges: a-[uses]->b and b-[uses]->c + .andExpect(jsonPath("$.edges", hasSize(2))) + .andExpect(jsonPath("$.edges[*].type", containsInAnyOrder("uses", "uses"))); + } + + @Test + @WithMockUser + @DisplayName("Should still reach graph-svc-c at depth 2 when filtering by 'uses'") + void shouldReachNodeCAtDepthTwoWithUsesFilter() throws Exception { + // This specifically verifies that the filter applies recursively: + // at depth=2, a→b (level 1) and b→c (level 2) must both be traversed. + mockMvc + .perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A).param("depth", "2") + .param("relations", "uses").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) + .andExpect( + jsonPath("$.nodes[*].identifier", containsInAnyOrder(ENTITY_A, ENTITY_B, ENTITY_C))) + .andExpect(jsonPath("$.edges", hasSize(2))); + } + } + + @Nested + @DisplayName("With 'monitors' relation filter") + class MonitorsFilter { + + @Test + @WithMockUser + @DisplayName("Should return only graph-svc-a and graph-svc-b when filtering by 'monitors' (depth=3)") + void shouldReturnOnlyRootAndDirectTargetWithMonitorsFilter() throws Exception { + // "monitors" only exists at level 1 (a→b). Since b has no "monitors" edges, + // graph-svc-c must NOT appear in the result. + mockMvc + .perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A).param("depth", "3") + .param("relations", "monitors").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) + // Only a and b — c is unreachable via "monitors" + .andExpect(jsonPath("$.nodes", hasSize(2))) + .andExpect(jsonPath("$.nodes[*].identifier", containsInAnyOrder(ENTITY_A, ENTITY_B))) + // One edge only: a-[monitors]->b + .andExpect(jsonPath("$.edges", hasSize(1))) + .andExpect(jsonPath("$.edges[0].type").value("monitors")); + } + } + + @Nested + @DisplayName("Error cases") + class ErrorCases { + + @Test + @WithMockUser + @DisplayName("Should return 404 when entity does not exist") + void shouldReturn404ForUnknownEntity() throws Exception { + mockMvc.perform(get(GRAPH_PATH, TEMPLATE, "non-existent-entity").accept(APPLICATION_JSON)) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("Should return 401 without authentication") + void shouldReturn401WithoutAuthentication() throws Exception { + mockMvc.perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A).accept(APPLICATION_JSON)) + .andExpect(status().isUnauthorized()); + } + } + + @Nested + @DisplayName("With 'properties' filter (include_data=true)") + class PropertyFilter { + + @Test + @WithMockUser + @DisplayName("Should include only requested property in each node's data when one property is requested") + void shouldIncludeOnlyRequestedProperty() throws Exception { + mockMvc + .perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A).param("depth", "3") + .param("include_data", "true").param("properties", "tier").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) + // All three nodes are still returned + .andExpect( + jsonPath("$.nodes[*].identifier", containsInAnyOrder(ENTITY_A, ENTITY_B, ENTITY_C))) + // Each node's data must contain "tier" … + .andExpect(jsonPath("$.nodes[0].data.tier").exists()) + // … but must NOT contain "version" + .andExpect(jsonPath("$.nodes[0].data.version").doesNotExist()); + } + + @Test + @WithMockUser + @DisplayName("Should include multiple requested properties in each node's data") + void shouldIncludeMultipleRequestedProperties() throws Exception { + mockMvc + .perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A).param("depth", "3") + .param("include_data", "true").param("properties", "tier") + .param("properties", "version").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.nodes[0].data.tier").exists()) + .andExpect(jsonPath("$.nodes[0].data.version").exists()); + } + + @Test + @WithMockUser + @DisplayName("Should return empty data when requested property does not exist on entity") + void shouldReturnEmptyDataForUnknownProperty() throws Exception { + mockMvc + .perform( + get(GRAPH_PATH, TEMPLATE, ENTITY_A).param("depth", "3").param("include_data", "true") + .param("properties", "non-existent-prop").accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + // data field is omitted from JSON when empty (@JsonInclude NON_EMPTY) + .andExpect(jsonPath("$.nodes[0].data").doesNotExist()); + } + + @Test + @WithMockUser + @DisplayName("Should include all properties when no property filter is supplied") + void shouldIncludeAllPropertiesWithoutFilter() throws Exception { + mockMvc + .perform(get(GRAPH_PATH, TEMPLATE, ENTITY_A).param("depth", "3") + .param("include_data", "true").accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(jsonPath("$.nodes[0].data.tier").exists()) + .andExpect(jsonPath("$.nodes[0].data.version").exists()); + } + } +} diff --git a/src/test/resources/db/test/R__1_Insert_test_data.sql b/src/test/resources/db/test/R__1_Insert_test_data.sql index bab7cb5..2bc5459 100644 --- a/src/test/resources/db/test/R__1_Insert_test_data.sql +++ b/src/test/resources/db/test/R__1_Insert_test_data.sql @@ -1,6 +1,13 @@ -- Sample data for IDP Core domain models - Enhanced with 10 templates --- Clear existing data (for repeatable migrations) +-- Clear existing data (for repeatable migrations). +-- Deletion order respects FK constraints: child tables first, then parents. +DELETE FROM entity_properties; +DELETE FROM entity_relations; +DELETE FROM relation_target_entities; +DELETE FROM relation; +DELETE FROM entity; +DELETE FROM property; DELETE FROM entity_template_relations_definitions; DELETE FROM entity_template_properties_definitions; DELETE FROM entity_template; diff --git a/src/test/resources/db/test/R__2_Insert_entities_test_data.sql b/src/test/resources/db/test/R__2_Insert_entities_test_data.sql index 4147732..01dbafd 100644 --- a/src/test/resources/db/test/R__2_Insert_entities_test_data.sql +++ b/src/test/resources/db/test/R__2_Insert_entities_test_data.sql @@ -1,5 +1,8 @@ --- Insert sample entities into idp_core.entity -INSERT INTO idp_core.entity (id, identifier, name, template_identifier) +-- ----------------------------------------------------------------------- +-- Sample entity instances +-- ----------------------------------------------------------------------- + +INSERT INTO entity (id, identifier, name, template_identifier) VALUES ('550e8400-e29b-41d4-a716-446655440100', 'web-api-1', 'Web API 1', 'web-service'), ('550e8400-e29b-41d4-a716-446655440101', 'web-api-2', 'Web API 2', 'web-service'), @@ -17,59 +20,146 @@ VALUES ('550e8400-e29b-41d4-a716-446655440113', 'monitoring-service-5', 'Monitoring Service 5', 'monitoring-service'), ('550e8400-e29b-41d4-a716-446655440114', 'monitoring-service-6', 'Monitoring Service 6', 'monitoring-service'); --- Properties for web-api-1 (language=JAVA, environment=PROD) -INSERT INTO idp_core.property (id, name, value) + +-- Add to end of R__1_Insert_test_data.sql + +-- ----------------------------------------------------------------------- +-- Properties for query filter tests (web-api-1 and web-api-2) +-- ----------------------------------------------------------------------- + +-- Properties for web-api-1 (programmingLanguage=JAVA, environment=PROD, port=8080) +INSERT INTO property (id, name, value) VALUES ('aa000000-0000-0000-0000-000000000001', 'programmingLanguage', 'JAVA'), ('aa000000-0000-0000-0000-000000000002', 'environment', 'PROD'), ('aa000000-0000-0000-0000-000000000005', 'port', '8080'); -INSERT INTO idp_core.entity_properties (entity_id, property_id) + +INSERT INTO entity_properties (entity_id, property_id) VALUES ('550e8400-e29b-41d4-a716-446655440100', 'aa000000-0000-0000-0000-000000000001'), ('550e8400-e29b-41d4-a716-446655440100', 'aa000000-0000-0000-0000-000000000002'), ('550e8400-e29b-41d4-a716-446655440100', 'aa000000-0000-0000-0000-000000000005'); --- Properties for web-api-2 (language=PYTHON, environment=DEV) -INSERT INTO idp_core.property (id, name, value) +-- Properties for web-api-2 (programmingLanguage=PYTHON, environment=DEV, port=9090) +INSERT INTO property (id, name, value) VALUES ('aa000000-0000-0000-0000-000000000003', 'programmingLanguage', 'PYTHON'), ('aa000000-0000-0000-0000-000000000004', 'environment', 'DEV'), ('aa000000-0000-0000-0000-000000000006', 'port', '9090'); -INSERT INTO idp_core.entity_properties (entity_id, property_id) + +INSERT INTO entity_properties (entity_id, property_id) VALUES ('550e8400-e29b-41d4-a716-446655440101', 'aa000000-0000-0000-0000-000000000003'), ('550e8400-e29b-41d4-a716-446655440101', 'aa000000-0000-0000-0000-000000000004'), ('550e8400-e29b-41d4-a716-446655440101', 'aa000000-0000-0000-0000-000000000006'); --- Relations for web-api-1 (database -> database-service, targetTemplateIdentifier = database-service) -INSERT INTO idp_core.relation (id, name, target_template_identifier) -VALUES - ('bb000000-0000-0000-0000-000000000001', 'database', 'database-service'); -INSERT INTO idp_core.relation_target_entities (relation_id, target_entity_identifier) -VALUES - ('bb000000-0000-0000-0000-000000000001', 'database-service-1'); -INSERT INTO idp_core.entity_relations (entity_id, relation_id) +-- ----------------------------------------------------------------------- +-- Relations for query filter tests (web-api-1 and web-api-2) +-- ----------------------------------------------------------------------- + +-- database relation for web-api-1 → database-service-1 +INSERT INTO relation (id, name, target_template_identifier) +VALUES ('bb000000-0000-0000-0000-000000000001', 'database', 'database-service'); + +INSERT INTO relation_target_entities (relation_id, target_entity_identifier) +VALUES ('bb000000-0000-0000-0000-000000000001', 'database-service-1'); + +INSERT INTO entity_relations (entity_id, relation_id) +VALUES ('550e8400-e29b-41d4-a716-446655440100', 'bb000000-0000-0000-0000-000000000001'); + +-- database relation for web-api-2 → cache-service-1 +INSERT INTO relation (id, name, target_template_identifier) +VALUES ('bb000000-0000-0000-0000-000000000002', 'database', 'cache-service'); + +INSERT INTO relation_target_entities (relation_id, target_entity_identifier) +VALUES ('bb000000-0000-0000-0000-000000000002', 'cache-service-1'); + +INSERT INTO entity_relations (entity_id, relation_id) +VALUES ('550e8400-e29b-41d4-a716-446655440101', 'bb000000-0000-0000-0000-000000000002'); + +-- api-link relation for web-api-1 → microservice-1 +INSERT INTO relation (id, name, target_template_identifier) +VALUES ('bb000000-0000-0000-0000-000000000003', 'api-link', 'microservice'); + +INSERT INTO relation_target_entities (relation_id, target_entity_identifier) +VALUES ('bb000000-0000-0000-0000-000000000003', 'microservice-1'); + +INSERT INTO entity_relations (entity_id, relation_id) +VALUES ('550e8400-e29b-41d4-a716-446655440100', 'bb000000-0000-0000-0000-000000000003'); +-- ----------------------------------------------------------------------- +-- Graph test data: 3-level chain of entities connected via two relation +-- types ("uses" and "monitors") for integration testing of the graph API. +-- +-- Graph topology (depth-3 chain): +-- graph-svc-a --[uses]--> graph-svc-b --[uses]--> graph-svc-c +-- graph-svc-a --[monitors]--> graph-svc-b +-- +-- This setup allows us to verify: +-- 1. Graph traversal works at all depths (not just root level) +-- 2. Relation name filtering excludes the correct edges/nodes at every depth +-- 3. "uses" filter returns: a → b → c (2 edges, 3 nodes) +-- 4. "monitors" filter returns: a → b (1 edge, 2 nodes; c not reachable) +-- ----------------------------------------------------------------------- + +INSERT INTO entity (id, identifier, name, template_identifier) VALUES - ('550e8400-e29b-41d4-a716-446655440100', 'bb000000-0000-0000-0000-000000000001'); + ('aa000001-0000-0000-0000-000000000001', 'graph-svc-a', 'Graph Service A', 'web-service'), + ('aa000001-0000-0000-0000-000000000002', 'graph-svc-b', 'Graph Service B', 'web-service'), + ('aa000001-0000-0000-0000-000000000003', 'graph-svc-c', 'Graph Service C', 'web-service'); --- Relations for web-api-2 (database -> cache-service, targetTemplateIdentifier = cache-service) -INSERT INTO idp_core.relation (id, name, target_template_identifier) +-- Relations owned by graph-svc-a: "uses" → b, "monitors" → b +INSERT INTO relation (id, name, target_template_identifier) VALUES - ('bb000000-0000-0000-0000-000000000002', 'database', 'cache-service'); -INSERT INTO idp_core.relation_target_entities (relation_id, target_entity_identifier) + ('bb000001-0000-0000-0000-000000000001', 'uses', 'web-service'), + ('bb000001-0000-0000-0000-000000000002', 'monitors', 'web-service'); + +-- Relation owned by graph-svc-b: "uses" → c +INSERT INTO relation (id, name, target_template_identifier) VALUES - ('bb000000-0000-0000-0000-000000000002', 'cache-service-1'); -INSERT INTO idp_core.entity_relations (entity_id, relation_id) + ('bb000002-0000-0000-0000-000000000001', 'uses', 'web-service'); + +-- Target entity identifiers for each relation +INSERT INTO relation_target_entities (relation_id, target_entity_identifier) VALUES - ('550e8400-e29b-41d4-a716-446655440101', 'bb000000-0000-0000-0000-000000000002'); + ('bb000001-0000-0000-0000-000000000001', 'graph-svc-b'), -- a -[uses]-> b + ('bb000001-0000-0000-0000-000000000002', 'graph-svc-b'), -- a -[monitors]-> b + ('bb000002-0000-0000-0000-000000000001', 'graph-svc-c'); -- b -[uses]-> c --- api-link relation for web-api-1 targeting microservice-1 (supports q=relation=api-link;relation.api-link.name:microservice) -INSERT INTO idp_core.relation (id, name, target_template_identifier) +-- Link relations to their owner entities +INSERT INTO entity_relations (entity_id, relation_id) VALUES - ('bb000000-0000-0000-0000-000000000003', 'api-link', 'microservice'); -INSERT INTO idp_core.relation_target_entities (relation_id, target_entity_identifier) + ('aa000001-0000-0000-0000-000000000001', 'bb000001-0000-0000-0000-000000000001'), -- a owns "uses" relation + ('aa000001-0000-0000-0000-000000000001', 'bb000001-0000-0000-0000-000000000002'), -- a owns "monitors" relation + ('aa000001-0000-0000-0000-000000000002', 'bb000002-0000-0000-0000-000000000001'); -- b owns "uses" relation + +-- ----------------------------------------------------------------------- +-- Property data for graph test entities (used by the property-filter tests). +-- +-- Each graph entity gets two properties: "tier" and "version". +-- This lets us verify: +-- 1. No filter → both properties appear in node data +-- 2. Filter "tier" → only tier present, version absent +-- 3. Filter "tier"+"version" → both present +-- 4. Filter "non-existent" → data field omitted entirely (NON_EMPTY) +-- ----------------------------------------------------------------------- + +INSERT INTO property (id, name, value) VALUES - ('bb000000-0000-0000-0000-000000000003', 'microservice-1'); -INSERT INTO idp_core.entity_relations (entity_id, relation_id) + -- graph-svc-a + ('cc000001-0000-0000-0000-000000000001', 'tier', 'gold'), + ('cc000001-0000-0000-0000-000000000002', 'version', '1.0.0'), + -- graph-svc-b + ('cc000001-0000-0000-0000-000000000003', 'tier', 'silver'), + ('cc000001-0000-0000-0000-000000000004', 'version', '2.0.0'), + -- graph-svc-c + ('cc000001-0000-0000-0000-000000000005', 'tier', 'bronze'), + ('cc000001-0000-0000-0000-000000000006', 'version', '3.0.0'); + +INSERT INTO entity_properties (entity_id, property_id) VALUES - ('550e8400-e29b-41d4-a716-446655440100', 'bb000000-0000-0000-0000-000000000003'); + ('aa000001-0000-0000-0000-000000000001', 'cc000001-0000-0000-0000-000000000001'), -- a.tier + ('aa000001-0000-0000-0000-000000000001', 'cc000001-0000-0000-0000-000000000002'), -- a.version + ('aa000001-0000-0000-0000-000000000002', 'cc000001-0000-0000-0000-000000000003'), -- b.tier + ('aa000001-0000-0000-0000-000000000002', 'cc000001-0000-0000-0000-000000000004'), -- b.version + ('aa000001-0000-0000-0000-000000000003', 'cc000001-0000-0000-0000-000000000005'), -- c.tier + ('aa000001-0000-0000-0000-000000000003', 'cc000001-0000-0000-0000-000000000006'); -- c.version