Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
@@ -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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,14 @@ PaginatedResult<Entity> 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);

}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -186,6 +187,19 @@ public ResponseEntity<ErrorResponse> 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<ErrorResponse> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,4 +145,9 @@ public void deleteByTemplateIdentifierAndIdentifier(final String templateIdentif
jpaEntityRepository.deleteByTemplateIdentifierAndIdentifier(templateIdentifier,
entityIdentifier);
}

@Override
public void deleteAllByTemplateIdentifier(String templateIdentifier) {
jpaEntityRepository.deleteAllByTemplateIdentifier(templateIdentifier);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -35,4 +37,17 @@ public interface JpaEntityTemplateRepository extends JpaRepository<EntityTemplat

@Transactional
void deleteByIdentifier(String identifier);

/// Counts templates (excluding the one identified by `identifier` itself) that have
/// at least one relation definition whose `targetTemplateIdentifier` equals `identifier`.
///
/// Used to enforce the template relation-target integrity business rule before deletion.
@Query("""
SELECT COUNT(et)
FROM EntityTemplateJpaEntity et
JOIN et.relationsDefinitions rd
WHERE rd.targetTemplateIdentifier = :identifier
AND et.identifier <> :identifier
""")
long countRelationTargetingTemplate(@Param("identifier") String identifier);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand All @@ -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:
Expand Down
Loading