fix(templates): cascade delete through nested derived templates
DeleteCompositionAsync only dropped the top-level derived template — the
cascaded inner derived rows (created when composing a composite source)
were left orphaned with dangling OwnerCompositionId references. Any
subsequent attempt to recompose the same source hit the name-collision
guard ('Motor Controller.Pump.TempSensor' already exists).
New CascadeDeleteDerivedAsync walks each composition on the derived
template, recursively removes the slot-owned child derived first, then
the composition row, then the derived itself. Mirrors the recursive
shape of CreateCascadedCompositionAsync.
This commit is contained in:
@@ -767,8 +767,7 @@ public class TemplateService
|
|||||||
|
|
||||||
if (composedTemplate != null && composedTemplate.IsDerived && composedTemplate.OwnerCompositionId == compositionId)
|
if (composedTemplate != null && composedTemplate.IsDerived && composedTemplate.OwnerCompositionId == compositionId)
|
||||||
{
|
{
|
||||||
await _repository.DeleteTemplateAsync(composedTemplate.Id, cancellationToken);
|
await CascadeDeleteDerivedAsync(composedTemplate, user, cancellationToken);
|
||||||
await _auditService.LogAsync(user, "Delete", "Template", composedTemplate.Id.ToString(), composedTemplate.Name, null, cancellationToken);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await _repository.SaveChangesAsync(cancellationToken);
|
await _repository.SaveChangesAsync(cancellationToken);
|
||||||
@@ -776,6 +775,27 @@ public class TemplateService
|
|||||||
return Result<bool>.Success(true);
|
return Result<bool>.Success(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Recursively deletes a derived template along with the cascade of inner
|
||||||
|
/// derived templates the compose flow created. Each composition row on the
|
||||||
|
/// derived has its slot-owned child template removed first, then the row,
|
||||||
|
/// then the derived itself.
|
||||||
|
/// </summary>
|
||||||
|
private async Task CascadeDeleteDerivedAsync(Template derived, string user, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
foreach (var child in derived.Compositions.ToList())
|
||||||
|
{
|
||||||
|
var childDerived = await _repository.GetTemplateByIdAsync(child.ComposedTemplateId, cancellationToken);
|
||||||
|
await _repository.DeleteTemplateCompositionAsync(child.Id, cancellationToken);
|
||||||
|
await _auditService.LogAsync(user, "Delete", "TemplateComposition", child.Id.ToString(), child.InstanceName, null, cancellationToken);
|
||||||
|
if (childDerived != null && childDerived.IsDerived && childDerived.OwnerCompositionId == child.Id)
|
||||||
|
await CascadeDeleteDerivedAsync(childDerived, user, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _repository.DeleteTemplateAsync(derived.Id, cancellationToken);
|
||||||
|
await _auditService.LogAsync(user, "Delete", "Template", derived.Id.ToString(), derived.Name, null, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
private static Template BuildDerivedTemplate(Template baseTemplate, string derivedName)
|
private static Template BuildDerivedTemplate(Template baseTemplate, string derivedName)
|
||||||
{
|
{
|
||||||
var derived = new Template(derivedName)
|
var derived = new Template(derivedName)
|
||||||
|
|||||||
@@ -436,6 +436,33 @@ public class TemplateServiceTests
|
|||||||
Assert.Contains("already exists", result.Error);
|
Assert.Contains("already exists", result.Error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteComposition_CascadesNestedDerivedTemplates()
|
||||||
|
{
|
||||||
|
// Pump.TempSensor is a cascaded derived under Pump (outer derived) that
|
||||||
|
// is owned by composition 50. Deleting composition 50 must drop:
|
||||||
|
// - the outer derived (Pump)
|
||||||
|
// - its nested composition (TempSensor on Pump)
|
||||||
|
// - the nested derived (Pump.TempSensor)
|
||||||
|
var nestedComp = new TemplateComposition("TempSensor") { Id = 51, TemplateId = 77, ComposedTemplateId = 78 };
|
||||||
|
var nestedDerived = new Template("Tank.Pump.TempSensor") { Id = 78, IsDerived = true, OwnerCompositionId = 51, ParentTemplateId = 3 };
|
||||||
|
var outerComposition = new TemplateComposition("Pump") { Id = 50, TemplateId = 1, ComposedTemplateId = 77 };
|
||||||
|
var outerDerived = new Template("Tank.Pump") { Id = 77, IsDerived = true, OwnerCompositionId = 50, ParentTemplateId = 2 };
|
||||||
|
outerDerived.Compositions.Add(nestedComp);
|
||||||
|
|
||||||
|
_repoMock.Setup(r => r.GetTemplateCompositionByIdAsync(50, It.IsAny<CancellationToken>())).ReturnsAsync(outerComposition);
|
||||||
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(outerDerived);
|
||||||
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(78, It.IsAny<CancellationToken>())).ReturnsAsync(nestedDerived);
|
||||||
|
|
||||||
|
var result = await _service.DeleteCompositionAsync(50, "admin");
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
_repoMock.Verify(r => r.DeleteTemplateCompositionAsync(50, It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
_repoMock.Verify(r => r.DeleteTemplateCompositionAsync(51, It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
_repoMock.Verify(r => r.DeleteTemplateAsync(77, It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
_repoMock.Verify(r => r.DeleteTemplateAsync(78, It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DeleteComposition_CascadesDerivedTemplate()
|
public async Task DeleteComposition_CascadesDerivedTemplate()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user