diff --git a/src/ScadaLink.TemplateEngine/TemplateService.cs b/src/ScadaLink.TemplateEngine/TemplateService.cs index 7009d8c..46e5ef3 100644 --- a/src/ScadaLink.TemplateEngine/TemplateService.cs +++ b/src/ScadaLink.TemplateEngine/TemplateService.cs @@ -626,32 +626,83 @@ public class TemplateService if (collisions.Count > 0) return Result.Failure(string.Join(" ", collisions)); - // Derived template name uses dot-separated path: ".". - var derivedName = $"{template.Name}.{instanceName}"; - if (allTemplates.Any(t => t.Name == derivedName)) + // Derived template name uses dot-separated path: ".". The + // cascade may create additional derived templates one level per slot + // (composing $Sensor with a Probe1 slot into $Pump produces both + // $Pump.TempSensor and $Pump.TempSensor.Probe1). Pre-check every name + // the cascade is about to introduce so a deep collision aborts before + // any rows mutate. + var byId = allTemplates.ToDictionary(t => t.Id); + var cascadeNames = EnumerateCascadeNames(template.Name, instanceName, baseTemplate, byId).ToList(); + var existingNames = allTemplates.Select(t => t.Name).ToHashSet(StringComparer.Ordinal); + var nameCollision = cascadeNames.FirstOrDefault(n => existingNames.Contains(n)); + if (nameCollision != null) return Result.Failure( - $"Cannot create derived template '{derivedName}': a template with that name already exists."); + $"Cannot create derived template '{nameCollision}': a template with that name already exists."); - var derived = BuildDerivedTemplate(baseTemplate, derivedName); + var composition = await CreateCascadedCompositionAsync(template, baseTemplate, instanceName, user, cancellationToken); + await _auditService.LogAsync(user, "Create", "TemplateComposition", "0", instanceName, composition, cancellationToken); + await _repository.SaveChangesAsync(cancellationToken); + + return Result.Success(composition); + } + + /// + /// Creates a derived template under that + /// wraps , then recursively cascades the source's + /// own compositions so the slot graph is replicated under the new derived. + /// Used both for the user-initiated top-level compose and the recursive + /// children — neither path re-validates (caller pre-flights). + /// + private async Task CreateCascadedCompositionAsync( + Template outerTemplate, + Template source, + string instanceName, + string user, + CancellationToken cancellationToken) + { + var derivedName = $"{outerTemplate.Name}.{instanceName}"; + var derived = BuildDerivedTemplate(source, derivedName); await _repository.AddTemplateAsync(derived, cancellationToken); await _repository.SaveChangesAsync(cancellationToken); var composition = new TemplateComposition(instanceName) { - TemplateId = templateId, + TemplateId = outerTemplate.Id, ComposedTemplateId = derived.Id }; - await _repository.AddTemplateCompositionAsync(composition, cancellationToken); await _repository.SaveChangesAsync(cancellationToken); derived.OwnerCompositionId = composition.Id; await _repository.UpdateTemplateAsync(derived, cancellationToken); - await _auditService.LogAsync(user, "Create", "TemplateComposition", "0", instanceName, composition, cancellationToken); await _repository.SaveChangesAsync(cancellationToken); - return Result.Success(composition); + // Cascade — replicate each of the source's compositions onto the new + // derived. The child's ParentTemplateId points at the source-side + // child (so override chains stay intact across nesting). + foreach (var childComp in source.Compositions.ToList()) + { + var childSource = await _repository.GetTemplateByIdAsync(childComp.ComposedTemplateId, cancellationToken); + if (childSource == null) continue; + await CreateCascadedCompositionAsync(derived, childSource, childComp.InstanceName, user, cancellationToken); + } + + return composition; + } + + private static IEnumerable EnumerateCascadeNames( + string outerName, string instanceName, Template source, IReadOnlyDictionary byId) + { + var derivedName = $"{outerName}.{instanceName}"; + yield return derivedName; + foreach (var comp in source.Compositions) + { + if (!byId.TryGetValue(comp.ComposedTemplateId, out var child)) continue; + foreach (var name in EnumerateCascadeNames(derivedName, comp.InstanceName, child, byId)) + yield return name; + } } public async Task> RenameCompositionAsync( diff --git a/tests/ScadaLink.TemplateEngine.Tests/TemplateServiceTests.cs b/tests/ScadaLink.TemplateEngine.Tests/TemplateServiceTests.cs index 2bbf276..87ca49e 100644 --- a/tests/ScadaLink.TemplateEngine.Tests/TemplateServiceTests.cs +++ b/tests/ScadaLink.TemplateEngine.Tests/TemplateServiceTests.cs @@ -321,6 +321,49 @@ public class TemplateServiceTests _repoMock.Verify(r => r.AddTemplateCompositionAsync(It.IsAny(), It.IsAny()), Times.Once); } + [Fact] + public async Task AddComposition_CascadesChildCompositions() + { + // $Probe (base) → $Sensor.Probe1 (derived) ← $Sensor composes "Probe1" + // Composing $Sensor into $Pump as "TempSensor" should produce: + // $Pump.TempSensor (derived from $Sensor) + // $Pump.TempSensor.Probe1 (derived from $Sensor.Probe1) + // plus a composition row on $Pump.TempSensor pointing at the new inner derived. + var probe = new Template("Probe") { Id = 10 }; + var sensorProbe1 = new Template("Sensor.Probe1") { Id = 11, IsDerived = true, ParentTemplateId = 10, OwnerCompositionId = 1 }; + var sensor = new Template("Sensor") { Id = 2 }; + sensor.Compositions.Add(new TemplateComposition("Probe1") { Id = 1, TemplateId = 2, ComposedTemplateId = 11 }); + var pump = new Template("Pump") { Id = 1 }; + + _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny())).ReturnsAsync(pump); + _repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny())).ReturnsAsync(sensor); + _repoMock.Setup(r => r.GetTemplateByIdAsync(11, It.IsAny())).ReturnsAsync(sensorProbe1); + _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny())) + .ReturnsAsync(new List