From 57f477fd28d1740b9d0ab36dc2fac3c5364bca60 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 12 May 2026 10:34:55 -0400 Subject: [PATCH] fix(templates): cascade delete through nested derived templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../TemplateService.cs | 24 +++++++++++++++-- .../TemplateServiceTests.cs | 27 +++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/ScadaLink.TemplateEngine/TemplateService.cs b/src/ScadaLink.TemplateEngine/TemplateService.cs index 46e5ef3..17cc646 100644 --- a/src/ScadaLink.TemplateEngine/TemplateService.cs +++ b/src/ScadaLink.TemplateEngine/TemplateService.cs @@ -767,8 +767,7 @@ public class TemplateService if (composedTemplate != null && composedTemplate.IsDerived && composedTemplate.OwnerCompositionId == compositionId) { - await _repository.DeleteTemplateAsync(composedTemplate.Id, cancellationToken); - await _auditService.LogAsync(user, "Delete", "Template", composedTemplate.Id.ToString(), composedTemplate.Name, null, cancellationToken); + await CascadeDeleteDerivedAsync(composedTemplate, user, cancellationToken); } await _repository.SaveChangesAsync(cancellationToken); @@ -776,6 +775,27 @@ public class TemplateService return Result.Success(true); } + /// + /// 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. + /// + 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) { var derived = new Template(derivedName) diff --git a/tests/ScadaLink.TemplateEngine.Tests/TemplateServiceTests.cs b/tests/ScadaLink.TemplateEngine.Tests/TemplateServiceTests.cs index 87ca49e..e3cff5d 100644 --- a/tests/ScadaLink.TemplateEngine.Tests/TemplateServiceTests.cs +++ b/tests/ScadaLink.TemplateEngine.Tests/TemplateServiceTests.cs @@ -436,6 +436,33 @@ public class TemplateServiceTests 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())).ReturnsAsync(outerComposition); + _repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny())).ReturnsAsync(outerDerived); + _repoMock.Setup(r => r.GetTemplateByIdAsync(78, It.IsAny())).ReturnsAsync(nestedDerived); + + var result = await _service.DeleteCompositionAsync(50, "admin"); + + Assert.True(result.IsSuccess); + _repoMock.Verify(r => r.DeleteTemplateCompositionAsync(50, It.IsAny()), Times.Once); + _repoMock.Verify(r => r.DeleteTemplateCompositionAsync(51, It.IsAny()), Times.Once); + _repoMock.Verify(r => r.DeleteTemplateAsync(77, It.IsAny()), Times.Once); + _repoMock.Verify(r => r.DeleteTemplateAsync(78, It.IsAny()), Times.Once); + } + [Fact] public async Task DeleteComposition_CascadesDerivedTemplate() {