using ScadaLink.Commons.Entities.Templates;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Types;
namespace ScadaLink.TemplateEngine;
///
/// Core service for Template Engine operations.
/// Covers CRUD for templates and their members (attributes, alarms, scripts, compositions),
/// inheritance and composition rules, override/locking validation, collision detection, and acyclicity enforcement.
///
public class TemplateService
{
private readonly ITemplateEngineRepository _repository;
private readonly IAuditService _auditService;
public TemplateService(ITemplateEngineRepository repository, IAuditService auditService)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_auditService = auditService ?? throw new ArgumentNullException(nameof(auditService));
}
// ========================================================================
// WP-1: Template CRUD with Inheritance
// ========================================================================
public async Task> CreateTemplateAsync(
string name,
string? description,
int? parentTemplateId,
string user,
int? folderId = null,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(name))
return Result.Failure("Template name is required.");
// Validate parent exists if specified
if (parentTemplateId.HasValue)
{
var parent = await _repository.GetTemplateByIdAsync(parentTemplateId.Value, cancellationToken);
if (parent == null)
return Result.Failure($"Parent template with ID {parentTemplateId.Value} not found.");
}
var template = new Template(name)
{
Description = description,
ParentTemplateId = parentTemplateId,
FolderId = folderId
};
// Check acyclicity (inheritance) — for new templates this is mostly a parent-exists check,
// but we validate anyway for consistency
if (parentTemplateId.HasValue)
{
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
// The new template doesn't exist yet, so we simulate by adding it to the list
// with a temporary ID. Since it has no children yet, the only cycle would be
// if parentTemplateId somehow pointed at itself (already handled above).
}
await _repository.AddTemplateAsync(template, cancellationToken);
await _auditService.LogAsync(user, "Create", "Template", "0", name, template, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result.Success(template);
}
public async Task> UpdateTemplateAsync(
int templateId,
string name,
string? description,
int? parentTemplateId,
string user,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(name))
return Result.Failure("Template name is required.");
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
if (template == null)
return Result.Failure($"Template with ID {templateId} not found.");
// ParentTemplateId is immutable after creation — set once at create time.
// Reject any attempt to change it (null→value, value→null, or value→other).
if (parentTemplateId != template.ParentTemplateId)
{
return Result.Failure(
"Parent template cannot be changed after creation.");
}
template.Name = name;
template.Description = description;
// Check for naming collisions after the change
var collisionResult = await ValidateCollisionsAsync(template, cancellationToken);
if (collisionResult != null)
return Result.Failure(collisionResult);
await _repository.UpdateTemplateAsync(template, cancellationToken);
await _auditService.LogAsync(user, "Update", "Template", templateId.ToString(), name, template, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result.Success(template);
}
public async Task> DeleteTemplateAsync(
int templateId,
string user,
CancellationToken cancellationToken = default)
{
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
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.
// Split derived vs. regular children — the message and remediation differ.
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
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 {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
.Where(t => t.Compositions.Any(c => c.ComposedTemplateId == templateId))
.ToList();
if (composedBy.Count > 0)
return Result.Failure(
$"Cannot delete template '{template.Name}': it is composed by {composedBy.Count} template(s): " +
string.Join(", ", composedBy.Select(c => $"'{c.Name}'")));
await _repository.DeleteTemplateAsync(templateId, cancellationToken);
await _auditService.LogAsync(user, "Delete", "Template", templateId.ToString(), template.Name, null, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result.Success(true);
}
public async Task> MoveTemplateAsync(
int templateId,
int? newFolderId,
string user,
CancellationToken cancellationToken = default)
{
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
if (template == null)
return Result.Failure($"Template with ID {templateId} not found.");
if (newFolderId.HasValue)
{
var folder = await _repository.GetFolderByIdAsync(newFolderId.Value, cancellationToken);
if (folder == null)
return Result.Failure($"Target folder with ID {newFolderId.Value} not found.");
}
template.FolderId = newFolderId;
await _repository.UpdateTemplateAsync(template, cancellationToken);
await _auditService.LogAsync(user, "Move", "Template", template.Id.ToString(), template.Name, template, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result.Success(template);
}
public async Task GetTemplateByIdAsync(int templateId, CancellationToken cancellationToken = default)
{
return await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
}
public async Task> GetAllTemplatesAsync(CancellationToken cancellationToken = default)
{
return await _repository.GetAllTemplatesAsync(cancellationToken);
}
// ========================================================================
// WP-2: Attribute Definitions with Lock Flags
// ========================================================================
public async Task> AddAttributeAsync(
int templateId,
TemplateAttribute attribute,
string user,
CancellationToken cancellationToken = default)
{
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
if (template == null)
return Result.Failure($"Template with ID {templateId} not found.");
// Check for duplicate name at this template level
if (template.Attributes.Any(a => a.Name == attribute.Name))
return Result.Failure(
$"Attribute '{attribute.Name}' already exists on template '{template.Name}'.");
attribute.TemplateId = templateId;
// If inheriting, validate not trying to add a member that would collide
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var testTemplate = CloneTemplateWithNewAttribute(template, attribute);
var collisions = CollisionDetector.DetectCollisions(testTemplate, allTemplates);
if (collisions.Count > 0)
return Result.Failure(string.Join(" ", collisions));
await _repository.AddTemplateAttributeAsync(attribute, cancellationToken);
await _auditService.LogAsync(user, "Create", "TemplateAttribute", "0", attribute.Name, attribute, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result.Success(attribute);
}
public async Task> UpdateAttributeAsync(
int attributeId,
TemplateAttribute proposed,
string user,
CancellationToken cancellationToken = default)
{
var existing = await _repository.GetTemplateAttributeByIdAsync(attributeId, cancellationToken);
if (existing == null)
return Result.Failure($"Attribute with ID {attributeId} not found.");
// Validate override rules if this is an override (parent has same-named attribute)
var template = await _repository.GetTemplateByIdAsync(existing.TemplateId, cancellationToken);
if (template?.ParentTemplateId != null)
{
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var parentMember = TemplateResolver.FindMemberByCanonicalName(
existing.Name, template.ParentTemplateId.Value, allTemplates);
if (parentMember != null && parentMember.IsLocked)
return Result.Failure(
$"Attribute '{existing.Name}' is locked in parent and cannot be overridden.");
// Derived templates may not override fields the base marked LockedInDerived.
if (template.IsDerived)
{
var baseTemplate = await _repository.GetTemplateByIdAsync(template.ParentTemplateId.Value, cancellationToken);
var baseAttr = baseTemplate?.Attributes.FirstOrDefault(a => a.Name == existing.Name);
if (baseAttr != null && baseAttr.LockedInDerived)
return Result.Failure(
$"Attribute '{existing.Name}' is locked by base template '{baseTemplate!.Name}' and cannot be overridden.");
}
}
// Validate lock change rules
var lockError = LockEnforcer.ValidateLockChange(existing.IsLocked, proposed.IsLocked, existing.Name);
if (lockError != null)
return Result.Failure(lockError);
// Validate fixed-field granularity
var granularityError = LockEnforcer.ValidateAttributeOverride(existing, proposed);
if (granularityError != null && existing.IsLocked)
return Result.Failure(granularityError);
// Apply overridable fields
existing.Value = proposed.Value;
existing.Description = proposed.Description;
existing.IsLocked = proposed.IsLocked;
existing.DataType = proposed.DataType;
existing.DataSourceReference = proposed.DataSourceReference;
if (template?.IsDerived == true)
existing.IsInherited = proposed.IsInherited;
else
existing.LockedInDerived = proposed.LockedInDerived;
await _repository.UpdateTemplateAttributeAsync(existing, cancellationToken);
await _auditService.LogAsync(user, "Update", "TemplateAttribute", attributeId.ToString(), existing.Name, existing, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result.Success(existing);
}
public async Task> DeleteAttributeAsync(
int attributeId,
string user,
CancellationToken cancellationToken = default)
{
var attribute = await _repository.GetTemplateAttributeByIdAsync(attributeId, cancellationToken);
if (attribute == null)
return Result.Failure($"Attribute with ID {attributeId} not found.");
// Cannot remove inherited parent members — only direct members
var template = await _repository.GetTemplateByIdAsync(attribute.TemplateId, cancellationToken);
if (template?.ParentTemplateId != null)
{
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var parentMember = TemplateResolver.FindMemberByCanonicalName(
attribute.Name, template.ParentTemplateId.Value, allTemplates);
if (parentMember != null)
return Result.Failure(
$"Cannot remove attribute '{attribute.Name}': it is inherited from parent template.");
}
await _repository.DeleteTemplateAttributeAsync(attributeId, cancellationToken);
await _auditService.LogAsync(user, "Delete", "TemplateAttribute", attributeId.ToString(), attribute.Name, null, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result.Success(true);
}
// ========================================================================
// WP-3: Alarm Definitions
// ========================================================================
public async Task> AddAlarmAsync(
int templateId,
TemplateAlarm alarm,
string user,
CancellationToken cancellationToken = default)
{
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
if (template == null)
return Result.Failure($"Template with ID {templateId} not found.");
if (template.Alarms.Any(a => a.Name == alarm.Name))
return Result.Failure(
$"Alarm '{alarm.Name}' already exists on template '{template.Name}'.");
// Validate priority range
if (alarm.PriorityLevel < 0 || alarm.PriorityLevel > 1000)
return Result.Failure("Alarm priority must be between 0 and 1000.");
alarm.TemplateId = templateId;
// Check collisions
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var testTemplate = CloneTemplateWithNewAlarm(template, alarm);
var collisions = CollisionDetector.DetectCollisions(testTemplate, allTemplates);
if (collisions.Count > 0)
return Result.Failure(string.Join(" ", collisions));
await _repository.AddTemplateAlarmAsync(alarm, cancellationToken);
await _auditService.LogAsync(user, "Create", "TemplateAlarm", "0", alarm.Name, alarm, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result.Success(alarm);
}
public async Task> UpdateAlarmAsync(
int alarmId,
TemplateAlarm proposed,
string user,
CancellationToken cancellationToken = default)
{
var existing = await _repository.GetTemplateAlarmByIdAsync(alarmId, cancellationToken);
if (existing == null)
return Result.Failure($"Alarm with ID {alarmId} not found.");
// Validate priority range
if (proposed.PriorityLevel < 0 || proposed.PriorityLevel > 1000)
return Result.Failure("Alarm priority must be between 0 and 1000.");
// Validate lock change
var lockError = LockEnforcer.ValidateLockChange(existing.IsLocked, proposed.IsLocked, existing.Name);
if (lockError != null)
return Result.Failure(lockError);
// Check parent lock
var template = await _repository.GetTemplateByIdAsync(existing.TemplateId, cancellationToken);
if (template?.ParentTemplateId != null)
{
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var parentMember = TemplateResolver.FindMemberByCanonicalName(
existing.Name, template.ParentTemplateId.Value, allTemplates);
if (parentMember != null && parentMember.IsLocked)
return Result.Failure(
$"Alarm '{existing.Name}' is locked in parent and cannot be overridden.");
}
// Validate fixed fields
var overrideError = LockEnforcer.ValidateAlarmOverride(existing, proposed);
if (overrideError != null)
return Result.Failure(overrideError);
// Apply overridable fields
existing.PriorityLevel = proposed.PriorityLevel;
existing.TriggerConfiguration = proposed.TriggerConfiguration;
existing.Description = proposed.Description;
existing.OnTriggerScriptId = proposed.OnTriggerScriptId;
existing.IsLocked = proposed.IsLocked;
// Name and TriggerType are NOT updated (fixed)
await _repository.UpdateTemplateAlarmAsync(existing, cancellationToken);
await _auditService.LogAsync(user, "Update", "TemplateAlarm", alarmId.ToString(), existing.Name, existing, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result.Success(existing);
}
public async Task> DeleteAlarmAsync(
int alarmId,
string user,
CancellationToken cancellationToken = default)
{
var alarm = await _repository.GetTemplateAlarmByIdAsync(alarmId, cancellationToken);
if (alarm == null)
return Result.Failure($"Alarm with ID {alarmId} not found.");
var template = await _repository.GetTemplateByIdAsync(alarm.TemplateId, cancellationToken);
if (template?.ParentTemplateId != null)
{
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var parentMember = TemplateResolver.FindMemberByCanonicalName(
alarm.Name, template.ParentTemplateId.Value, allTemplates);
if (parentMember != null)
return Result.Failure(
$"Cannot remove alarm '{alarm.Name}': it is inherited from parent template.");
}
await _repository.DeleteTemplateAlarmAsync(alarmId, cancellationToken);
await _auditService.LogAsync(user, "Delete", "TemplateAlarm", alarmId.ToString(), alarm.Name, null, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result.Success(true);
}
// ========================================================================
// WP-4: Script Definitions
// ========================================================================
public async Task> AddScriptAsync(
int templateId,
TemplateScript script,
string user,
CancellationToken cancellationToken = default)
{
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
if (template == null)
return Result.Failure($"Template with ID {templateId} not found.");
if (template.Scripts.Any(s => s.Name == script.Name))
return Result.Failure(
$"Script '{script.Name}' already exists on template '{template.Name}'.");
script.TemplateId = templateId;
// Check collisions
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var testTemplate = CloneTemplateWithNewScript(template, script);
var collisions = CollisionDetector.DetectCollisions(testTemplate, allTemplates);
if (collisions.Count > 0)
return Result.Failure(string.Join(" ", collisions));
await _repository.AddTemplateScriptAsync(script, cancellationToken);
await _auditService.LogAsync(user, "Create", "TemplateScript", "0", script.Name, script, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result.Success(script);
}
public async Task> UpdateScriptAsync(
int scriptId,
TemplateScript proposed,
string user,
CancellationToken cancellationToken = default)
{
var existing = await _repository.GetTemplateScriptByIdAsync(scriptId, cancellationToken);
if (existing == null)
return Result.Failure($"Script with ID {scriptId} not found.");
// Validate lock change
var lockError = LockEnforcer.ValidateLockChange(existing.IsLocked, proposed.IsLocked, existing.Name);
if (lockError != null)
return Result.Failure(lockError);
// Check parent lock
var template = await _repository.GetTemplateByIdAsync(existing.TemplateId, cancellationToken);
if (template?.ParentTemplateId != null)
{
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var parentMember = TemplateResolver.FindMemberByCanonicalName(
existing.Name, template.ParentTemplateId.Value, allTemplates);
if (parentMember != null && parentMember.IsLocked)
return Result.Failure(
$"Script '{existing.Name}' is locked in parent and cannot be overridden.");
// Derived templates may not override scripts the base marked LockedInDerived.
if (template.IsDerived)
{
var baseTemplate = await _repository.GetTemplateByIdAsync(template.ParentTemplateId.Value, cancellationToken);
var baseScript = baseTemplate?.Scripts.FirstOrDefault(s => s.Name == existing.Name);
if (baseScript != null && baseScript.LockedInDerived)
return Result.Failure(
$"Script '{existing.Name}' is locked by base template '{baseTemplate!.Name}' and cannot be overridden.");
}
}
// Validate fixed fields
var overrideError = LockEnforcer.ValidateScriptOverride(existing, proposed);
if (overrideError != null)
return Result.Failure(overrideError);
// Apply overridable fields
existing.Code = proposed.Code;
existing.TriggerType = proposed.TriggerType;
existing.TriggerConfiguration = proposed.TriggerConfiguration;
existing.MinTimeBetweenRuns = proposed.MinTimeBetweenRuns;
existing.ParameterDefinitions = proposed.ParameterDefinitions;
existing.ReturnDefinition = proposed.ReturnDefinition;
existing.IsLocked = proposed.IsLocked;
if (template?.IsDerived == true)
existing.IsInherited = proposed.IsInherited;
else
existing.LockedInDerived = proposed.LockedInDerived;
// Name is NOT updated (fixed)
await _repository.UpdateTemplateScriptAsync(existing, cancellationToken);
await _auditService.LogAsync(user, "Update", "TemplateScript", scriptId.ToString(), existing.Name, existing, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result.Success(existing);
}
public async Task> DeleteScriptAsync(
int scriptId,
string user,
CancellationToken cancellationToken = default)
{
var script = await _repository.GetTemplateScriptByIdAsync(scriptId, cancellationToken);
if (script == null)
return Result.Failure($"Script with ID {scriptId} not found.");
var template = await _repository.GetTemplateByIdAsync(script.TemplateId, cancellationToken);
if (template?.ParentTemplateId != null)
{
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var parentMember = TemplateResolver.FindMemberByCanonicalName(
script.Name, template.ParentTemplateId.Value, allTemplates);
if (parentMember != null)
return Result.Failure(
$"Cannot remove script '{script.Name}': it is inherited from parent template.");
}
await _repository.DeleteTemplateScriptAsync(scriptId, cancellationToken);
await _auditService.LogAsync(user, "Delete", "TemplateScript", scriptId.ToString(), script.Name, null, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result.Success(true);
}
// ========================================================================
// WP-6: Composition with Recursive Nesting
// ========================================================================
public async Task> AddCompositionAsync(
int templateId,
int composedTemplateId,
string instanceName,
string user,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(instanceName))
return Result.Failure("Instance name is required for composition.");
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
if (template == null)
return Result.Failure($"Template with ID {templateId} not found.");
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}'.");
// 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);
var crossCycleError = CycleDetector.DetectCrossGraphCycle(templateId, null, composedTemplateId, allTemplates);
if (crossCycleError != null)
return Result.Failure(crossCycleError);
var probeComposition = new TemplateComposition(instanceName)
{
TemplateId = templateId,
ComposedTemplateId = composedTemplateId
};
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: ".". 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 '{nameCollision}': a template with that name already exists.");
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 = outerTemplate.Id,
ComposedTemplateId = derived.Id
};
await _repository.AddTemplateCompositionAsync(composition, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
derived.OwnerCompositionId = composition.Id;
await _repository.UpdateTemplateAsync(derived, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
// 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(
int compositionId,
string newInstanceName,
string user,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(newInstanceName))
return Result.Failure("Slot name is required.");
var composition = await _repository.GetTemplateCompositionByIdAsync(compositionId, cancellationToken);
if (composition == null)
return Result.Failure($"Composition with ID {compositionId} not found.");
if (composition.InstanceName == newInstanceName) return Result.Success(composition);
var owner = await _repository.GetTemplateByIdAsync(composition.TemplateId, cancellationToken);
if (owner == null)
return Result.Failure($"Owning template with ID {composition.TemplateId} not found.");
if (owner.Compositions.Any(c => c.Id != compositionId && c.InstanceName == newInstanceName))
return Result.Failure(
$"Slot name '{newInstanceName}' already exists on '{owner.Name}'.");
var derived = await _repository.GetTemplateByIdAsync(composition.ComposedTemplateId, cancellationToken);
if (derived != null && derived.IsDerived && derived.OwnerCompositionId == compositionId)
{
var newDerivedName = $"{owner.Name}.{newInstanceName}";
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
if (allTemplates.Any(t => t.Id != derived.Id && t.Name == newDerivedName))
return Result.Failure(
$"Cannot rename derived template to '{newDerivedName}': a template with that name already exists.");
derived.Name = newDerivedName;
await _repository.UpdateTemplateAsync(derived, cancellationToken);
}
composition.InstanceName = newInstanceName;
await _repository.UpdateTemplateCompositionAsync(composition, cancellationToken);
await _auditService.LogAsync(user, "Update", "TemplateComposition", compositionId.ToString(), newInstanceName, composition, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result.Success(composition);
}
public async Task> DeleteCompositionAsync(
int compositionId,
string user,
CancellationToken cancellationToken = default)
{
var composition = await _repository.GetTemplateCompositionByIdAsync(compositionId, cancellationToken);
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 CascadeDeleteDerivedAsync(composedTemplate, user, cancellationToken);
}
await _repository.SaveChangesAsync(cancellationToken);
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)
{
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)
// ========================================================================
///
/// Resolves all effective members for a template using canonical (path-qualified) names.
///
public async Task> ResolveTemplateMembersAsync(
int templateId,
CancellationToken cancellationToken = default)
{
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
return TemplateResolver.ResolveAllMembers(templateId, allTemplates);
}
// ========================================================================
// WP-8/9/10/11: Override validation (integrated into Update* methods above)
// ========================================================================
///
/// Validates whether overriding a member by canonical name is allowed.
/// Used for composition overrides (WP-11) and inheritance overrides (WP-10).
///
public async Task> ValidateOverrideAsync(
int templateId,
string canonicalName,
CancellationToken cancellationToken = default)
{
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
if (template == null)
return Result.Failure($"Template with ID {templateId} not found.");
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var members = TemplateResolver.ResolveAllMembers(templateId, allTemplates);
var member = members.FirstOrDefault(m => m.CanonicalName == canonicalName);
if (member == null)
return Result.Failure($"No member found with canonical name '{canonicalName}'.");
if (member.IsLocked)
return Result.Failure($"Member '{canonicalName}' is locked and cannot be overridden.");
return Result.Success(true);
}
// ========================================================================
// WP-12: Naming Collision Detection
// ========================================================================
///
/// Checks a template for naming collisions across all its members.
///
public async Task> DetectCollisionsAsync(
int templateId,
CancellationToken cancellationToken = default)
{
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
if (template == null)
return new[] { $"Template with ID {templateId} not found." };
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
return CollisionDetector.DetectCollisions(template, allTemplates);
}
// ========================================================================
// WP-13: Graph Acyclicity
// ========================================================================
///
/// Validates that a proposed inheritance or composition change does not create a cycle.
///
public async Task> ValidateAcyclicityAsync(
int templateId,
int? proposedParentId,
int? proposedComposedTemplateId,
CancellationToken cancellationToken = default)
{
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
if (proposedParentId.HasValue)
{
var inheritanceCycle = CycleDetector.DetectInheritanceCycle(templateId, proposedParentId.Value, allTemplates);
if (inheritanceCycle != null)
return Result.Failure(inheritanceCycle);
}
if (proposedComposedTemplateId.HasValue)
{
var compositionCycle = CycleDetector.DetectCompositionCycle(templateId, proposedComposedTemplateId.Value, allTemplates);
if (compositionCycle != null)
return Result.Failure(compositionCycle);
}
var crossCycle = CycleDetector.DetectCrossGraphCycle(templateId, proposedParentId, proposedComposedTemplateId, allTemplates);
if (crossCycle != null)
return Result.Failure(crossCycle);
return Result.Success(true);
}
// ========================================================================
// Helper methods
// ========================================================================
private async Task ValidateCollisionsAsync(Template template, CancellationToken cancellationToken)
{
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var collisions = CollisionDetector.DetectCollisions(template, allTemplates);
if (collisions.Count > 0)
return string.Join(" ", collisions);
return null;
}
private static Template CloneTemplateWithNewAttribute(Template original, TemplateAttribute newAttr)
{
var clone = new Template(original.Name)
{
Id = original.Id,
ParentTemplateId = original.ParentTemplateId,
Description = original.Description,
};
foreach (var a in original.Attributes) clone.Attributes.Add(a);
clone.Attributes.Add(newAttr);
foreach (var a in original.Alarms) clone.Alarms.Add(a);
foreach (var s in original.Scripts) clone.Scripts.Add(s);
foreach (var c in original.Compositions) clone.Compositions.Add(c);
return clone;
}
private static Template CloneTemplateWithNewAlarm(Template original, TemplateAlarm newAlarm)
{
var clone = new Template(original.Name)
{
Id = original.Id,
ParentTemplateId = original.ParentTemplateId,
Description = original.Description,
};
foreach (var a in original.Attributes) clone.Attributes.Add(a);
foreach (var a in original.Alarms) clone.Alarms.Add(a);
clone.Alarms.Add(newAlarm);
foreach (var s in original.Scripts) clone.Scripts.Add(s);
foreach (var c in original.Compositions) clone.Compositions.Add(c);
return clone;
}
private static Template CloneTemplateWithNewScript(Template original, TemplateScript newScript)
{
var clone = new Template(original.Name)
{
Id = original.Id,
ParentTemplateId = original.ParentTemplateId,
Description = original.Description,
};
foreach (var a in original.Attributes) clone.Attributes.Add(a);
foreach (var a in original.Alarms) clone.Alarms.Add(a);
foreach (var s in original.Scripts) clone.Scripts.Add(s);
clone.Scripts.Add(newScript);
foreach (var c in original.Compositions) clone.Compositions.Add(c);
return clone;
}
private static Template CloneTemplateWithNewComposition(Template original, TemplateComposition newComp)
{
var clone = new Template(original.Name)
{
Id = original.Id,
ParentTemplateId = original.ParentTemplateId,
Description = original.Description,
};
foreach (var a in original.Attributes) clone.Attributes.Add(a);
foreach (var a in original.Alarms) clone.Alarms.Add(a);
foreach (var s in original.Scripts) clone.Scripts.Add(s);
foreach (var c in original.Compositions) clone.Compositions.Add(c);
clone.Compositions.Add(newComp);
return clone;
}
}