diff --git a/src/ScadaLink.TemplateEngine/Services/TemplateDeletionService.cs b/src/ScadaLink.TemplateEngine/Services/TemplateDeletionService.cs index 2ea7d63..e18d552 100644 --- a/src/ScadaLink.TemplateEngine/Services/TemplateDeletionService.cs +++ b/src/ScadaLink.TemplateEngine/Services/TemplateDeletionService.cs @@ -30,6 +30,11 @@ public class TemplateDeletionService if (template == null) return Result.Failure($"Template with ID {templateId} not found."); + if (template.IsDerived) + return Result.Failure( + $"Cannot delete template '{template.Name}': it is a derived template. " + + "Remove the owning composition on its parent template instead."); + var errors = new List(); // Check 1: Instances reference this template @@ -40,16 +45,33 @@ public class TemplateDeletionService errors.Add($"Cannot delete template '{template.Name}': {instances.Count} instance(s) reference it ({names}{(instances.Count > 10 ? "..." : "")})."); } - // Check 2: Child templates reference it as parent + // Check 2: Child templates reference it as parent. Split derived vs. regular. var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken); - var childTemplates = allTemplates.Where(t => t.ParentTemplateId == templateId).ToList(); - if (childTemplates.Count > 0) + var inheritors = allTemplates.Where(t => t.ParentTemplateId == templateId).ToList(); + var regularChildren = inheritors.Where(t => !t.IsDerived).ToList(); + var derivatives = inheritors.Where(t => t.IsDerived).ToList(); + + if (regularChildren.Count > 0) { - var names = string.Join(", ", childTemplates.Select(t => t.Name).Take(10)); - errors.Add($"Cannot delete template '{template.Name}': {childTemplates.Count} child template(s) inherit from it ({names}{(childTemplates.Count > 10 ? "..." : "")})."); + var names = string.Join(", ", regularChildren.Select(t => t.Name).Take(10)); + errors.Add($"Cannot delete template '{template.Name}': {regularChildren.Count} child template(s) inherit from it ({names}{(regularChildren.Count > 10 ? "..." : "")})."); } - // Check 3: Other templates compose it + if (derivatives.Count > 0) + { + var compIds = derivatives.Select(d => d.OwnerCompositionId).Where(id => id.HasValue).Select(id => id!.Value).ToHashSet(); + var ownerLookup = allTemplates + .SelectMany(t => t.Compositions.Select(c => new { Owner = t, Composition = c })) + .Where(x => compIds.Contains(x.Composition.Id)) + .ToDictionary(x => x.Composition.Id, x => $"'{x.Owner.Name}' (as '{x.Composition.InstanceName}')"); + var details = string.Join(", ", derivatives.Take(10).Select(d => + d.OwnerCompositionId.HasValue && ownerLookup.TryGetValue(d.OwnerCompositionId.Value, out var label) + ? label + : $"'{d.Name}'")); + errors.Add($"Cannot delete template '{template.Name}': it is the base of {derivatives.Count} derived template(s) used in {details}{(derivatives.Count > 10 ? "..." : "")}. Remove those compositions first."); + } + + // Check 3: Other templates compose it directly (e.g., pre-Phase-3 data). var composingTemplates = new List<(string TemplateName, string InstanceName)>(); foreach (var t in allTemplates) { diff --git a/src/ScadaLink.TemplateEngine/TemplateService.cs b/src/ScadaLink.TemplateEngine/TemplateService.cs index 28df3cd..d29547a 100644 --- a/src/ScadaLink.TemplateEngine/TemplateService.cs +++ b/src/ScadaLink.TemplateEngine/TemplateService.cs @@ -115,19 +115,48 @@ public class TemplateService if (template == null) return Result.Failure($"Template with ID {templateId} not found."); + // Derived templates are owned by their composition row and must be removed + // by deleting the composition (which cascades) — block direct deletion. + if (template.IsDerived) + return Result.Failure( + $"Cannot delete template '{template.Name}': it is a derived template. " + + "Remove the owning composition on its parent template instead."); + // Check for instances referencing this template var instances = await _repository.GetInstancesByTemplateIdAsync(templateId, cancellationToken); if (instances.Count > 0) return Result.Failure( $"Cannot delete template '{template.Name}': it is referenced by {instances.Count} instance(s)."); - // Check for child templates inheriting from this template + // Check for child templates inheriting from this template. + // Split derived vs. regular children — the message and remediation differ. var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken); - var children = allTemplates.Where(t => t.ParentTemplateId == templateId).ToList(); - if (children.Count > 0) + var inheritors = allTemplates.Where(t => t.ParentTemplateId == templateId).ToList(); + var derivatives = inheritors.Where(t => t.IsDerived).ToList(); + var regularChildren = inheritors.Where(t => !t.IsDerived).ToList(); + + if (regularChildren.Count > 0) return Result.Failure( - $"Cannot delete template '{template.Name}': it is inherited by {children.Count} child template(s): " + - string.Join(", ", children.Select(c => $"'{c.Name}'"))); + $"Cannot delete template '{template.Name}': it is inherited by {regularChildren.Count} child template(s): " + + string.Join(", ", regularChildren.Select(c => $"'{c.Name}'"))); + + if (derivatives.Count > 0) + { + // Name each derivative by its owning parent template + composition slot. + var ownerCompIds = derivatives.Select(d => d.OwnerCompositionId).Where(id => id.HasValue).Select(id => id!.Value).ToHashSet(); + var ownerLookup = allTemplates + .SelectMany(t => t.Compositions.Select(c => new { Owner = t, Composition = c })) + .Where(x => ownerCompIds.Contains(x.Composition.Id)) + .ToDictionary(x => x.Composition.Id, x => $"'{x.Owner.Name}' (as '{x.Composition.InstanceName}')"); + + var details = derivatives + .Select(d => d.OwnerCompositionId.HasValue && ownerLookup.TryGetValue(d.OwnerCompositionId.Value, out var label) + ? label + : $"'{d.Name}'"); + return Result.Failure( + $"Cannot delete template '{template.Name}': it is the base of {derivatives.Count} derived template(s) used in: " + + string.Join(", ", details) + ". Remove those compositions first."); + } // Check for templates composing this template var composedBy = allTemplates @@ -533,39 +562,64 @@ public class TemplateService if (template == null) return Result.Failure($"Template with ID {templateId} not found."); - var composedTemplate = await _repository.GetTemplateByIdAsync(composedTemplateId, cancellationToken); - if (composedTemplate == null) + var baseTemplate = await _repository.GetTemplateByIdAsync(composedTemplateId, cancellationToken); + if (baseTemplate == null) return Result.Failure($"Composed template with ID {composedTemplateId} not found."); + // Only base templates can be composed; derived templates are slot-owned. + if (baseTemplate.IsDerived) + return Result.Failure( + $"Cannot compose template '{baseTemplate.Name}': it is a derived template. Compose its base instead."); + // Check for duplicate instance name if (template.Compositions.Any(c => c.InstanceName == instanceName)) return Result.Failure( $"Composition instance name '{instanceName}' already exists on template '{template.Name}'."); - // Check composition acyclicity + // Acyclicity is checked against the base, not the to-be-created derived template — + // the derived inherits from the base, so a base→base cycle is the meaningful check. var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken); var cycleError = CycleDetector.DetectCompositionCycle(templateId, composedTemplateId, allTemplates); if (cycleError != null) return Result.Failure(cycleError); - // Check cross-graph cycle var crossCycleError = CycleDetector.DetectCrossGraphCycle(templateId, null, composedTemplateId, allTemplates); if (crossCycleError != null) return Result.Failure(crossCycleError); - var composition = new TemplateComposition(instanceName) + var probeComposition = new TemplateComposition(instanceName) { TemplateId = templateId, ComposedTemplateId = composedTemplateId }; - // Check for naming collisions with the new composition - var testTemplate = CloneTemplateWithNewComposition(template, composition); + var testTemplate = CloneTemplateWithNewComposition(template, probeComposition); var collisions = CollisionDetector.DetectCollisions(testTemplate, allTemplates); 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)) + return Result.Failure( + $"Cannot create derived template '{derivedName}': a template with that name already exists."); + + var derived = BuildDerivedTemplate(baseTemplate, derivedName); + + await _repository.AddTemplateAsync(derived, cancellationToken); + await _repository.SaveChangesAsync(cancellationToken); + + var composition = new TemplateComposition(instanceName) + { + TemplateId = templateId, + 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); @@ -581,13 +635,65 @@ public class TemplateService if (composition == null) return Result.Failure($"Composition with ID {compositionId} not found."); + // Identify the slot-owned derived template (post Phase-3 migration this is the + // typical case; pre-migration the composition may still point at a base). + var composedTemplate = await _repository.GetTemplateByIdAsync(composition.ComposedTemplateId, cancellationToken); + await _repository.DeleteTemplateCompositionAsync(compositionId, cancellationToken); await _auditService.LogAsync(user, "Delete", "TemplateComposition", compositionId.ToString(), composition.InstanceName, null, cancellationToken); + + 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 _repository.SaveChangesAsync(cancellationToken); return Result.Success(true); } + private static Template BuildDerivedTemplate(Template baseTemplate, string derivedName) + { + var derived = new Template(derivedName) + { + Description = baseTemplate.Description, + ParentTemplateId = baseTemplate.Id, + IsDerived = true, + }; + + foreach (var attr in baseTemplate.Attributes) + { + derived.Attributes.Add(new TemplateAttribute(attr.Name) + { + Value = attr.Value, + DataType = attr.DataType, + IsLocked = attr.IsLocked, + Description = attr.Description, + DataSourceReference = attr.DataSourceReference, + IsInherited = true, + LockedInDerived = false, + }); + } + + foreach (var script in baseTemplate.Scripts) + { + derived.Scripts.Add(new TemplateScript(script.Name, script.Code) + { + IsLocked = script.IsLocked, + TriggerType = script.TriggerType, + TriggerConfiguration = script.TriggerConfiguration, + ParameterDefinitions = script.ParameterDefinitions, + ReturnDefinition = script.ReturnDefinition, + MinTimeBetweenRuns = script.MinTimeBetweenRuns, + IsInherited = true, + LockedInDerived = false, + }); + } + + return derived; + } + // ======================================================================== // WP-7: Path-Qualified Canonical Naming (via TemplateResolver) // ======================================================================== diff --git a/tests/ScadaLink.TemplateEngine.Tests/TemplateServiceTests.cs b/tests/ScadaLink.TemplateEngine.Tests/TemplateServiceTests.cs index d28f51c..6804df1 100644 --- a/tests/ScadaLink.TemplateEngine.Tests/TemplateServiceTests.cs +++ b/tests/ScadaLink.TemplateEngine.Tests/TemplateServiceTests.cs @@ -288,9 +288,11 @@ public class TemplateServiceTests // ======================================================================== [Fact] - public async Task AddComposition_Success() + public async Task AddComposition_Success_DerivesTemplate() { var moduleTemplate = new Template("Module") { Id = 2 }; + moduleTemplate.Attributes.Add(new TemplateAttribute("SetPoint") { Id = 10, TemplateId = 2, Value = "75", DataType = DataType.Float }); + moduleTemplate.Scripts.Add(new TemplateScript("Compute", "return 1;") { Id = 20, TemplateId = 2 }); var template = new Template("Parent") { Id = 1 }; _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny())).ReturnsAsync(template); @@ -298,11 +300,123 @@ public class TemplateServiceTests _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny())) .ReturnsAsync(new List