From b0a3b10cea20f88226884bd7491014bd1196b880 Mon Sep 17 00:00:00 2001 From: Matthieu WALTERSPIELER SALVI Date: Thu, 9 Jul 2026 15:56:06 +0200 Subject: [PATCH] feat(entity-template): implement cascade delete for templates and add validation for relation targets --- .../domain/constant/ValidationMessages.java | 1 + ...tityTemplateIsRelationTargetException.java | 26 +++++++++++++++++ .../domain/port/EntityRepositoryPort.java | 10 +++++++ .../port/EntityTemplateRepositoryPort.java | 10 +++++++ .../EntityTemplateService.java | 16 +++++++---- .../EntityTemplateValidationService.java | 14 +++++++--- .../controller/EntityTemplateController.java | 17 +++++------ .../api/handler/ApiExceptionHandler.java | 14 ++++++++++ .../persistence/PostgresEntityAdapter.java | 5 ++++ .../PostgresEntityTemplateAdapter.java | 5 ++++ .../repository/JpaEntityRepository.java | 9 ++++++ .../JpaEntityTemplateRepository.java | 15 ++++++++++ .../EntityTemplateServiceTest.java | 23 +++++++++++++++ .../EntityTemplateControllerTest.java | 28 +++++++++++++++++-- 14 files changed, 172 insertions(+), 21 deletions(-) create mode 100644 src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateIsRelationTargetException.java diff --git a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java index e32977de..0c2df57f 100644 --- a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java +++ b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java @@ -57,6 +57,7 @@ public class ValidationMessages { public static final String RELATION_TARGET_ENTITY_NOT_FOUND = "Relation '%s': target entity '%s' does not exist"; public static final String RELATION_TARGET_TEMPLATE_CANNOT_CHANGE = "Cannot change target template of relation '%s' from '%s' to '%s'. Target template cannot be modified after creation. Please delete and recreate the relation instead."; public static final String RELATION_CANNOT_TARGET_ITSELF = "Relation '%s' cannot reference its own template '%s' as the target."; + public static final String TEMPLATE_IS_RELATION_TARGET = "Cannot delete template '%s': it is referenced as a target in another template's relation definition."; // Entity input validation messages public static final String ENTITY_NAME_MANDATORY = "Entity name is mandatory and cannot be blank"; diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateIsRelationTargetException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateIsRelationTargetException.java new file mode 100644 index 00000000..99afb7cd --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity_template/EntityTemplateIsRelationTargetException.java @@ -0,0 +1,26 @@ +package com.decathlon.idp_core.domain.exception.entity_template; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.TEMPLATE_IS_RELATION_TARGET; + +/// Exception thrown when attempting to delete a template that is still referenced +/// as a `targetTemplateIdentifier` in another template's relation definition. +/// +/// This exception enforces the template relation target integrity business rule: +/// a template may not be deleted while other templates declare relations that +/// point to it as the target, because doing so would create dangling relation +/// definitions and break the integrity of those templates. +/// +/// **Why this exception exists:** +/// - Prevents orphaned relation definitions in other templates +/// - Provides a clear, domain-specific error for API feedback +/// - Mapped to HTTP 400 Bad Request by [ApiExceptionHandler] +public class EntityTemplateIsRelationTargetException extends RuntimeException { + + /// Constructs a new exception for the template identifier that cannot be + /// deleted. + /// + /// @param identifier the identifier of the template blocked from deletion + public EntityTemplateIsRelationTargetException(String identifier) { + super(String.format(TEMPLATE_IS_RELATION_TARGET, identifier)); + } +} diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java index ac4fc633..85439d31 100644 --- a/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java @@ -64,4 +64,14 @@ PaginatedResult search(SearchFilterNode filter, String query, void deleteByTemplateIdentifierAndIdentifier(String templateIdentifier, String entityIdentifier); + /// Deletes all entities that belong to the given template. + /// + /// **Business contract:** Called as part of the template cascade-delete flow after all + /// referential integrity checks have passed. Implementors must ensure that entity-owned + /// child records (properties, relations) are also removed. + /// + /// @param templateIdentifier the business identifier of the template whose entities + /// should be deleted + void deleteAllByTemplateIdentifier(String templateIdentifier); + } diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityTemplateRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityTemplateRepositoryPort.java index 5f4f9106..253c7d1a 100644 --- a/src/main/java/com/decathlon/idp_core/domain/port/EntityTemplateRepositoryPort.java +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityTemplateRepositoryPort.java @@ -35,4 +35,14 @@ public interface EntityTemplateRepositoryPort { EntityTemplate save(EntityTemplate entityTemplate); void deleteByIdentifier(String identifier); + + /// Returns `true` if any template **other than** the one identified by `identifier` + /// declares a relation definition whose `targetTemplateIdentifier` equals `identifier`. + /// + /// Self-referential relation definitions (a template pointing to itself) are excluded + /// because they are removed as part of the cascade when the template itself is deleted. + /// + /// @param identifier the business identifier of the template to check + /// @return `true` when at least one other template targets this template in a relation + boolean existsRelationTargetingTemplate(String identifier); } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateService.java index 877a0846..de95e269 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateService.java @@ -156,19 +156,23 @@ public EntityTemplate updateEntityTemplate(String identifier, return savedTemplate; } - /// Deletes an entity template by business identifier with existence validation. + /// Deletes an entity template by business identifier with existence validation + /// and cascade purge of all its entities. /// - /// **Contract:** Validates template existence before deletion to ensure - /// referential - /// integrity. Deletion cascades through persistence layer according to - /// configured - /// relationships. This operation is irreversible once committed. + /// **Contract:** Validates that the template exists and is not referenced as a + /// relation target in another template. Then purges all entities belonging to + /// the template before deleting the template itself. Both the entity purge and + /// the template deletion happen within the same transaction so any failure + /// rolls back the entire operation. /// /// @param identifier unique business identifier of template to delete /// @throws EntityTemplateNotFoundException when template doesn't exist + /// @throws EntityTemplateIsRelationTargetException when another template + /// references this template as a relation target @Transactional public void deleteEntityTemplate(String identifier) { entityTemplateValidationService.validateForDeletion(identifier); + entityRepositoryPort.deleteAllByTemplateIdentifier(identifier); entityTemplateRepositoryPort.deleteByIdentifier(identifier); } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java index 4ec24faa..c1a478f2 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateValidationService.java @@ -6,6 +6,7 @@ import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateAlreadyExistsException; import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateIdentifierCannotChangeException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateIsRelationTargetException; import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNameAlreadyExistsException; import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; import com.decathlon.idp_core.domain.exception.entity_template.PropertyDefinitionRulesConflictException; @@ -127,18 +128,23 @@ public void validateForUpdate(String currentIdentifier, String existingName, } } - /// Validates that a template identifier is non-null and refers to an existing - /// template. + /// Validates that a template identifier is non-null, refers to an existing + /// template, and is not referenced as a target in another template's relation + /// definition. /// /// @param identifier the identifier of the template to delete /// @throws EntityTemplateNotFoundException when `identifier` is null - /// @throws EntityTemplateNotFoundException when no template matches - /// `identifier` + /// @throws EntityTemplateNotFoundException when no template matches `identifier` + /// @throws EntityTemplateIsRelationTargetException when another template + /// declares a relation whose `targetTemplateIdentifier` equals `identifier` public void validateForDeletion(String identifier) { if (identifier == null) { throw new EntityTemplateNotFoundException("identifier", "null"); } validateTemplateExists(identifier); + if (entityTemplateRepositoryPort.existsRelationTargetingTemplate(identifier)) { + throw new EntityTemplateIsRelationTargetException(identifier); + } } /// Checks that the entity template exists. diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateController.java index b82c6a3d..9218d3bf 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateController.java @@ -13,7 +13,6 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_PUT_TEMPLATE_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_PUT_TEMPLATE_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.NO_CONTENT_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.OK_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_PAGE_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_SIZE_DESCRIPTION; @@ -27,7 +26,6 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_TEMPLATE_UPDATED; import static org.springframework.http.HttpStatus.CREATED; -import static org.springframework.http.HttpStatus.NO_CONTENT; import static org.springframework.http.HttpStatus.OK; import jakarta.validation.Valid; @@ -169,15 +167,18 @@ public EntityTemplateDtoOut updateTemplate(@PathVariable(name = "identifier") St /// Deletes entity template by business identifier with safety checks. /// - /// **API contract:** Permanently removes template with HTTP 204 response. - /// Operation is idempotent - returns success even for non-existent templates. - /// **Warning:** Irreversible operation requiring referential integrity - /// validation. + /// **API contract:** Validates existence and referential integrity before + /// permanently removing the template and cascade-purging all its entities. + /// Returns HTTP 200 on success. Returns HTTP 400 when another template still + /// references this template as a relation target. Returns HTTP 404 when the + /// template does not exist. @Operation(summary = ENDPOINT_DELETE_TEMPLATE_SUMMARY, description = ENDPOINT_DELETE_TEMPLATE_DESCRIPTION) - @ApiResponse(responseCode = NO_CONTENT_CODE, description = RESPONSE_TEMPLATE_DELETED) + @ApiResponse(responseCode = OK_CODE, description = RESPONSE_TEMPLATE_DELETED) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = "Cannot delete template: it is a target for other relations", content = { + @Content(schema = @Schema(implementation = ErrorResponse.class))}) @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_TEMPLATE_NOT_FOUND_IDENTIFIER, content = { @Content(schema = @Schema(implementation = ErrorResponse.class))}) - @ResponseStatus(NO_CONTENT) + @ResponseStatus(OK) @DeleteMapping("/{identifier}") public void deleteTemplate(@PathVariable String identifier) { entityTemplateService.deleteEntityTemplate(identifier); diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java index 9a21a6d0..673837dc 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java @@ -26,6 +26,7 @@ import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateAlreadyExistsException; import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateIdentifierCannotChangeException; +import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateIsRelationTargetException; import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNameAlreadyExistsException; import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; import com.decathlon.idp_core.domain.exception.entity_template.PropertyDefinitionRulesConflictException; @@ -186,6 +187,19 @@ public ResponseEntity handleTargetTemplateNotFoundException( return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); } + /// Handles domain exception when attempting to delete a template that is still + /// referenced as a relation target in another template. + /// + /// **HTTP mapping:** Maps domain [EntityTemplateIsRelationTargetException] to + /// HTTP 400 Bad Request to signal a business rule violation that must be + /// resolved by the caller before retrying the deletion. + @ExceptionHandler(EntityTemplateIsRelationTargetException.class) + public ResponseEntity handleEntityTemplateIsRelationTargetException( + EntityTemplateIsRelationTargetException ex) { + log.warn("Template deletion blocked – still a relation target: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + /// Handles domain exception when type changes are attempted. /// /// **HTTP mapping:** Maps domain PropertyTypeChangeException to HTTP 400 status diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java index a5638d5b..39b5dd03 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java @@ -145,4 +145,9 @@ public void deleteByTemplateIdentifierAndIdentifier(final String templateIdentif jpaEntityRepository.deleteByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier); } + + @Override + public void deleteAllByTemplateIdentifier(String templateIdentifier) { + jpaEntityRepository.deleteAllByTemplateIdentifier(templateIdentifier); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityTemplateAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityTemplateAdapter.java index af72cfd0..21d197a8 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityTemplateAdapter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityTemplateAdapter.java @@ -90,6 +90,11 @@ public void deleteByIdentifier(String identifier) { jpaEntityTemplateRepository.deleteByIdentifier(identifier); } + @Override + public boolean existsRelationTargetingTemplate(String identifier) { + return jpaEntityTemplateRepository.countRelationTargetingTemplate(identifier) > 0; + } + // ── Merge helpers to update a managed JPA entity from domain values ── private void mergeIntoExisting(EntityTemplateJpaEntity jpa, EntityTemplate domain) { diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java index d2a07286..4d9c1a1a 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 @@ -13,6 +13,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; import com.decathlon.idp_core.domain.model.entity.EntitySummary; import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.EntityJpaEntity; @@ -157,4 +158,12 @@ void deleteByTemplateIdentifierAndIdentifier( @Param("templateIdentifier") String templateIdentifier, @Param("entityIdentifier") String entityIdentifier); + /// Deletes all entities belonging to the given template. + /// + /// Spring Data loads each entity and removes it via `em.remove()`, which triggers + /// JPA cascade (`CascadeType.ALL`, `orphanRemoval = true`) so entity-owned properties + /// and relations are also removed. + @Transactional + void deleteAllByTemplateIdentifier(String templateIdentifier); + } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityTemplateRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityTemplateRepository.java index 21b4218e..d0c77ea5 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityTemplateRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityTemplateRepository.java @@ -7,6 +7,8 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; @@ -35,4 +37,17 @@ public interface JpaEntityTemplateRepository extends JpaRepository :identifier + """) + long countRelationTargetingTemplate(@Param("identifier") String identifier); } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateServiceTest.java index a6cb3177..522d0f39 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity_template/EntityTemplateServiceTest.java @@ -261,4 +261,27 @@ void shouldMatchRemovedPropertiesCaseInsensitively() { .deletePropertiesByTemplateIdentifierAndPropertyName(anyString(), any()); } } + + @Nested + @DisplayName("deleteEntityTemplate - cascade delete") + class DeleteTemplateTests { + + private static final String TEMPLATE_IDENTIFIER = "web-service"; + + /// Tests that deleteEntityTemplate validates and then cascades to entities + @Test + @DisplayName("Should call deleteAllByTemplateIdentifier before deleting template") + void shouldCascadeDeleteEntitiesBeforeTemplate() { + entityTemplateService.deleteEntityTemplate(TEMPLATE_IDENTIFIER); + + // Verify validation was called + verify(entityTemplateValidationService).validateForDeletion(TEMPLATE_IDENTIFIER); + + // Verify entity cascade-delete was called first + verify(entityRepositoryPort).deleteAllByTemplateIdentifier(TEMPLATE_IDENTIFIER); + + // Verify template deletion was called + verify(entityTemplateRepositoryPort).deleteByIdentifier(TEMPLATE_IDENTIFIER); + } + } } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java index d3c01815..3376e793 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java @@ -1012,13 +1012,13 @@ class DeleteTemplateTests { /// @throws Exception if the MockMvc request fails @Test @WithMockUser() - @DisplayName("Should delete template and return 204") - void deleteTemplate_204() throws Exception { + @DisplayName("Should delete template and return 200") + void deleteTemplate_200() throws Exception { // Use an existing template ID from test data String templateId = "monitoring-service"; mockMvc.perform(MockMvcRequestBuilders.delete(ENTITY_TEMPLATE_PATH + "/" + templateId) - .accept(APPLICATION_JSON).with(csrf())).andExpect(status().isNoContent()); + .accept(APPLICATION_JSON).with(csrf())).andExpect(status().isOk()); assertNotNull(templateId, "Test executed successfully"); } @@ -1044,6 +1044,28 @@ void deleteTemplate_404_not_found() throws Exception { assertNotNull(nonExistentId, "Test executed successfully"); } + /// Tests the DELETE /api/v1/entity-templates/{id} endpoint when the template + /// is still referenced as a relation target in another template. + /// This test verifies that: + /// - Returns HTTP 400 Bad Request with appropriate error message + /// @throws Exception if the MockMvc request fails + @Test + @WithMockUser() + @DisplayName("Should return 400 when template is a relation target") + void deleteTemplate_400_is_relation_target() throws Exception { + // Use "microservice" which is referenced as a target in other templates (from test data) + String templateId = "microservice"; + + mockMvc + .perform(MockMvcRequestBuilders.delete(ENTITY_TEMPLATE_PATH + "/" + templateId) + .accept(APPLICATION_JSON).with(csrf())) + .andExpect(status().isBadRequest()).andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.error_description").exists()); + + assertNotNull(templateId, "Test executed successfully"); + } + /// Tests the DELETE /api/v1/entity-templates/{id} endpoint when authentication /// is missing. /// This test verifies that: