Phase 2 WP-1–13+23: Template Engine CRUD, composition, overrides, locking, collision detection, acyclicity

- WP-23: ITemplateEngineRepository full EF Core implementation
- WP-1: Template CRUD with deletion constraints (instances, children, compositions)
- WP-2–4: Attribute, alarm, script definitions with lock flags and override granularity
- WP-5: Shared script CRUD with syntax validation
- WP-6–7: Composition with recursive nesting and canonical naming
- WP-8–11: Override granularity, locking rules, inheritance/composition scope
- WP-12: Naming collision detection on canonical names (recursive)
- WP-13: Graph acyclicity (inheritance + composition cycles)
Core services: TemplateService, SharedScriptService, TemplateResolver,
LockEnforcer, CollisionDetector, CycleDetector. 358 tests pass.
This commit is contained in:
Joseph Doherty
2026-03-16 20:10:34 -04:00
parent 84ad6bb77d
commit faef2d0de6
47 changed files with 7741 additions and 11 deletions

View File

@@ -0,0 +1,751 @@
using ScadaLink.Commons.Entities.Templates;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Types;
namespace ScadaLink.TemplateEngine;
/// <summary>
/// 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.
/// </summary>
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<Result<Template>> CreateTemplateAsync(
string name,
string? description,
int? parentTemplateId,
string user,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(name))
return Result<Template>.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<Template>.Failure($"Parent template with ID {parentTemplateId.Value} not found.");
}
var template = new Template(name)
{
Description = description,
ParentTemplateId = parentTemplateId
};
// 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<Template>.Success(template);
}
public async Task<Result<Template>> UpdateTemplateAsync(
int templateId,
string name,
string? description,
int? parentTemplateId,
string user,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(name))
return Result<Template>.Failure("Template name is required.");
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
if (template == null)
return Result<Template>.Failure($"Template with ID {templateId} not found.");
// Validate parent change
if (parentTemplateId.HasValue && parentTemplateId.Value != (template.ParentTemplateId ?? 0))
{
var parent = await _repository.GetTemplateByIdAsync(parentTemplateId.Value, cancellationToken);
if (parent == null)
return Result<Template>.Failure($"Parent template with ID {parentTemplateId.Value} not found.");
// Check inheritance acyclicity
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var cycleError = CycleDetector.DetectInheritanceCycle(templateId, parentTemplateId.Value, allTemplates);
if (cycleError != null)
return Result<Template>.Failure(cycleError);
// Check cross-graph cycle
var crossCycleError = CycleDetector.DetectCrossGraphCycle(templateId, parentTemplateId, null, allTemplates);
if (crossCycleError != null)
return Result<Template>.Failure(crossCycleError);
}
template.Name = name;
template.Description = description;
template.ParentTemplateId = parentTemplateId;
// Check for naming collisions after the change
var collisionResult = await ValidateCollisionsAsync(template, cancellationToken);
if (collisionResult != null)
return Result<Template>.Failure(collisionResult);
await _repository.UpdateTemplateAsync(template, cancellationToken);
await _auditService.LogAsync(user, "Update", "Template", templateId.ToString(), name, template, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result<Template>.Success(template);
}
public async Task<Result<bool>> DeleteTemplateAsync(
int templateId,
string user,
CancellationToken cancellationToken = default)
{
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
if (template == null)
return Result<bool>.Failure($"Template with ID {templateId} not found.");
// Check for instances referencing this template
var instances = await _repository.GetInstancesByTemplateIdAsync(templateId, cancellationToken);
if (instances.Count > 0)
return Result<bool>.Failure(
$"Cannot delete template '{template.Name}': it is referenced by {instances.Count} instance(s).");
// Check for child templates inheriting from this template
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var children = allTemplates.Where(t => t.ParentTemplateId == templateId).ToList();
if (children.Count > 0)
return Result<bool>.Failure(
$"Cannot delete template '{template.Name}': it is inherited by {children.Count} child template(s): " +
string.Join(", ", children.Select(c => $"'{c.Name}'")));
// 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<bool>.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<bool>.Success(true);
}
public async Task<Template?> GetTemplateByIdAsync(int templateId, CancellationToken cancellationToken = default)
{
return await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
}
public async Task<IReadOnlyList<Template>> GetAllTemplatesAsync(CancellationToken cancellationToken = default)
{
return await _repository.GetAllTemplatesAsync(cancellationToken);
}
// ========================================================================
// WP-2: Attribute Definitions with Lock Flags
// ========================================================================
public async Task<Result<TemplateAttribute>> AddAttributeAsync(
int templateId,
TemplateAttribute attribute,
string user,
CancellationToken cancellationToken = default)
{
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
if (template == null)
return Result<TemplateAttribute>.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<TemplateAttribute>.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<TemplateAttribute>.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<TemplateAttribute>.Success(attribute);
}
public async Task<Result<TemplateAttribute>> UpdateAttributeAsync(
int attributeId,
TemplateAttribute proposed,
string user,
CancellationToken cancellationToken = default)
{
var existing = await _repository.GetTemplateAttributeByIdAsync(attributeId, cancellationToken);
if (existing == null)
return Result<TemplateAttribute>.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<TemplateAttribute>.Failure(
$"Attribute '{existing.Name}' is locked in parent and cannot be overridden.");
}
// Validate lock change rules
var lockError = LockEnforcer.ValidateLockChange(existing.IsLocked, proposed.IsLocked, existing.Name);
if (lockError != null)
return Result<TemplateAttribute>.Failure(lockError);
// Validate fixed-field granularity
var granularityError = LockEnforcer.ValidateAttributeOverride(existing, proposed);
if (granularityError != null && existing.IsLocked)
return Result<TemplateAttribute>.Failure(granularityError);
// Apply overridable fields
existing.Value = proposed.Value;
existing.Description = proposed.Description;
existing.IsLocked = proposed.IsLocked;
// DataType and DataSourceReference are NOT updated (fixed fields)
await _repository.UpdateTemplateAttributeAsync(existing, cancellationToken);
await _auditService.LogAsync(user, "Update", "TemplateAttribute", attributeId.ToString(), existing.Name, existing, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result<TemplateAttribute>.Success(existing);
}
public async Task<Result<bool>> DeleteAttributeAsync(
int attributeId,
string user,
CancellationToken cancellationToken = default)
{
var attribute = await _repository.GetTemplateAttributeByIdAsync(attributeId, cancellationToken);
if (attribute == null)
return Result<bool>.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<bool>.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<bool>.Success(true);
}
// ========================================================================
// WP-3: Alarm Definitions
// ========================================================================
public async Task<Result<TemplateAlarm>> AddAlarmAsync(
int templateId,
TemplateAlarm alarm,
string user,
CancellationToken cancellationToken = default)
{
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
if (template == null)
return Result<TemplateAlarm>.Failure($"Template with ID {templateId} not found.");
if (template.Alarms.Any(a => a.Name == alarm.Name))
return Result<TemplateAlarm>.Failure(
$"Alarm '{alarm.Name}' already exists on template '{template.Name}'.");
// Validate priority range
if (alarm.PriorityLevel < 0 || alarm.PriorityLevel > 1000)
return Result<TemplateAlarm>.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<TemplateAlarm>.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<TemplateAlarm>.Success(alarm);
}
public async Task<Result<TemplateAlarm>> UpdateAlarmAsync(
int alarmId,
TemplateAlarm proposed,
string user,
CancellationToken cancellationToken = default)
{
var existing = await _repository.GetTemplateAlarmByIdAsync(alarmId, cancellationToken);
if (existing == null)
return Result<TemplateAlarm>.Failure($"Alarm with ID {alarmId} not found.");
// Validate priority range
if (proposed.PriorityLevel < 0 || proposed.PriorityLevel > 1000)
return Result<TemplateAlarm>.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<TemplateAlarm>.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<TemplateAlarm>.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<TemplateAlarm>.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<TemplateAlarm>.Success(existing);
}
public async Task<Result<bool>> DeleteAlarmAsync(
int alarmId,
string user,
CancellationToken cancellationToken = default)
{
var alarm = await _repository.GetTemplateAlarmByIdAsync(alarmId, cancellationToken);
if (alarm == null)
return Result<bool>.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<bool>.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<bool>.Success(true);
}
// ========================================================================
// WP-4: Script Definitions
// ========================================================================
public async Task<Result<TemplateScript>> AddScriptAsync(
int templateId,
TemplateScript script,
string user,
CancellationToken cancellationToken = default)
{
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
if (template == null)
return Result<TemplateScript>.Failure($"Template with ID {templateId} not found.");
if (template.Scripts.Any(s => s.Name == script.Name))
return Result<TemplateScript>.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<TemplateScript>.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<TemplateScript>.Success(script);
}
public async Task<Result<TemplateScript>> UpdateScriptAsync(
int scriptId,
TemplateScript proposed,
string user,
CancellationToken cancellationToken = default)
{
var existing = await _repository.GetTemplateScriptByIdAsync(scriptId, cancellationToken);
if (existing == null)
return Result<TemplateScript>.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<TemplateScript>.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<TemplateScript>.Failure(
$"Script '{existing.Name}' is locked in parent and cannot be overridden.");
}
// Validate fixed fields
var overrideError = LockEnforcer.ValidateScriptOverride(existing, proposed);
if (overrideError != null)
return Result<TemplateScript>.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;
// 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<TemplateScript>.Success(existing);
}
public async Task<Result<bool>> DeleteScriptAsync(
int scriptId,
string user,
CancellationToken cancellationToken = default)
{
var script = await _repository.GetTemplateScriptByIdAsync(scriptId, cancellationToken);
if (script == null)
return Result<bool>.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<bool>.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<bool>.Success(true);
}
// ========================================================================
// WP-6: Composition with Recursive Nesting
// ========================================================================
public async Task<Result<TemplateComposition>> AddCompositionAsync(
int templateId,
int composedTemplateId,
string instanceName,
string user,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(instanceName))
return Result<TemplateComposition>.Failure("Instance name is required for composition.");
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
if (template == null)
return Result<TemplateComposition>.Failure($"Template with ID {templateId} not found.");
var composedTemplate = await _repository.GetTemplateByIdAsync(composedTemplateId, cancellationToken);
if (composedTemplate == null)
return Result<TemplateComposition>.Failure($"Composed template with ID {composedTemplateId} not found.");
// Check for duplicate instance name
if (template.Compositions.Any(c => c.InstanceName == instanceName))
return Result<TemplateComposition>.Failure(
$"Composition instance name '{instanceName}' already exists on template '{template.Name}'.");
// Check composition acyclicity
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var cycleError = CycleDetector.DetectCompositionCycle(templateId, composedTemplateId, allTemplates);
if (cycleError != null)
return Result<TemplateComposition>.Failure(cycleError);
// Check cross-graph cycle
var crossCycleError = CycleDetector.DetectCrossGraphCycle(templateId, null, composedTemplateId, allTemplates);
if (crossCycleError != null)
return Result<TemplateComposition>.Failure(crossCycleError);
var composition = new TemplateComposition(instanceName)
{
TemplateId = templateId,
ComposedTemplateId = composedTemplateId
};
// Check for naming collisions with the new composition
var testTemplate = CloneTemplateWithNewComposition(template, composition);
var collisions = CollisionDetector.DetectCollisions(testTemplate, allTemplates);
if (collisions.Count > 0)
return Result<TemplateComposition>.Failure(string.Join(" ", collisions));
await _repository.AddTemplateCompositionAsync(composition, cancellationToken);
await _auditService.LogAsync(user, "Create", "TemplateComposition", "0", instanceName, composition, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result<TemplateComposition>.Success(composition);
}
public async Task<Result<bool>> DeleteCompositionAsync(
int compositionId,
string user,
CancellationToken cancellationToken = default)
{
var composition = await _repository.GetTemplateCompositionByIdAsync(compositionId, cancellationToken);
if (composition == null)
return Result<bool>.Failure($"Composition with ID {compositionId} not found.");
await _repository.DeleteTemplateCompositionAsync(compositionId, cancellationToken);
await _auditService.LogAsync(user, "Delete", "TemplateComposition", compositionId.ToString(), composition.InstanceName, null, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result<bool>.Success(true);
}
// ========================================================================
// WP-7: Path-Qualified Canonical Naming (via TemplateResolver)
// ========================================================================
/// <summary>
/// Resolves all effective members for a template using canonical (path-qualified) names.
/// </summary>
public async Task<IReadOnlyList<TemplateResolver.ResolvedTemplateMember>> 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)
// ========================================================================
/// <summary>
/// Validates whether overriding a member by canonical name is allowed.
/// Used for composition overrides (WP-11) and inheritance overrides (WP-10).
/// </summary>
public async Task<Result<bool>> ValidateOverrideAsync(
int templateId,
string canonicalName,
CancellationToken cancellationToken = default)
{
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
if (template == null)
return Result<bool>.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<bool>.Failure($"No member found with canonical name '{canonicalName}'.");
if (member.IsLocked)
return Result<bool>.Failure($"Member '{canonicalName}' is locked and cannot be overridden.");
return Result<bool>.Success(true);
}
// ========================================================================
// WP-12: Naming Collision Detection
// ========================================================================
/// <summary>
/// Checks a template for naming collisions across all its members.
/// </summary>
public async Task<IReadOnlyList<string>> 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
// ========================================================================
/// <summary>
/// Validates that a proposed inheritance or composition change does not create a cycle.
/// </summary>
public async Task<Result<bool>> 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<bool>.Failure(inheritanceCycle);
}
if (proposedComposedTemplateId.HasValue)
{
var compositionCycle = CycleDetector.DetectCompositionCycle(templateId, proposedComposedTemplateId.Value, allTemplates);
if (compositionCycle != null)
return Result<bool>.Failure(compositionCycle);
}
var crossCycle = CycleDetector.DetectCrossGraphCycle(templateId, proposedParentId, proposedComposedTemplateId, allTemplates);
if (crossCycle != null)
return Result<bool>.Failure(crossCycle);
return Result<bool>.Success(true);
}
// ========================================================================
// Helper methods
// ========================================================================
private async Task<string?> 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;
}
}