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,285 @@
using ScadaLink.Commons.Entities.Instances;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Types;
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.TemplateEngine.Services;
/// <summary>
/// Instance CRUD operations.
/// - Create instance from template at site
/// - Assign to area
/// - Override non-locked attribute values
/// - Cannot add or remove attributes (only override existing ones)
/// - Per-attribute connection binding (bulk assignment support)
/// - Enabled/disabled state with optimistic concurrency
/// - Audit logging
/// </summary>
public class InstanceService
{
private readonly ITemplateEngineRepository _repository;
private readonly IAuditService _auditService;
public InstanceService(ITemplateEngineRepository repository, IAuditService auditService)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_auditService = auditService ?? throw new ArgumentNullException(nameof(auditService));
}
/// <summary>
/// Creates a new instance from a template at a site.
/// </summary>
public async Task<Result<Instance>> CreateInstanceAsync(
string uniqueName,
int templateId,
int siteId,
int? areaId,
string user,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(uniqueName))
return Result<Instance>.Failure("Instance unique name is required.");
// Verify template exists
var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
if (template == null)
return Result<Instance>.Failure($"Template with ID {templateId} not found.");
// Check for duplicate unique name
var existing = await _repository.GetInstanceByUniqueNameAsync(uniqueName, cancellationToken);
if (existing != null)
return Result<Instance>.Failure($"Instance with unique name '{uniqueName}' already exists.");
// Verify area exists if specified
if (areaId.HasValue)
{
var area = await _repository.GetAreaByIdAsync(areaId.Value, cancellationToken);
if (area == null)
return Result<Instance>.Failure($"Area with ID {areaId.Value} not found.");
if (area.SiteId != siteId)
return Result<Instance>.Failure($"Area with ID {areaId.Value} does not belong to site {siteId}.");
}
var instance = new Instance(uniqueName)
{
TemplateId = templateId,
SiteId = siteId,
AreaId = areaId,
State = InstanceState.Disabled // New instances start disabled
};
await _repository.AddInstanceAsync(instance, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
await _auditService.LogAsync(user, "Create", "Instance", instance.Id.ToString(),
uniqueName, instance, cancellationToken);
return Result<Instance>.Success(instance);
}
/// <summary>
/// Assigns an instance to an area.
/// </summary>
public async Task<Result<Instance>> AssignToAreaAsync(
int instanceId,
int? areaId,
string user,
CancellationToken cancellationToken = default)
{
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
if (instance == null)
return Result<Instance>.Failure($"Instance with ID {instanceId} not found.");
if (areaId.HasValue)
{
var area = await _repository.GetAreaByIdAsync(areaId.Value, cancellationToken);
if (area == null)
return Result<Instance>.Failure($"Area with ID {areaId.Value} not found.");
if (area.SiteId != instance.SiteId)
return Result<Instance>.Failure($"Area with ID {areaId.Value} does not belong to the instance's site.");
}
instance.AreaId = areaId;
await _repository.UpdateInstanceAsync(instance, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
await _auditService.LogAsync(user, "AssignArea", "Instance", instance.Id.ToString(),
instance.UniqueName, instance, cancellationToken);
return Result<Instance>.Success(instance);
}
/// <summary>
/// Sets an attribute override for an instance. Only non-locked attributes can be overridden.
/// Cannot add or remove attributes — only override values of existing template attributes.
/// </summary>
public async Task<Result<InstanceAttributeOverride>> SetAttributeOverrideAsync(
int instanceId,
string attributeName,
string? overrideValue,
string user,
CancellationToken cancellationToken = default)
{
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
if (instance == null)
return Result<InstanceAttributeOverride>.Failure($"Instance with ID {instanceId} not found.");
// Verify attribute exists in the template and is not locked
var templateAttrs = await _repository.GetAttributesByTemplateIdAsync(instance.TemplateId, cancellationToken);
var templateAttr = templateAttrs.FirstOrDefault(a => a.Name == attributeName);
if (templateAttr == null)
return Result<InstanceAttributeOverride>.Failure(
$"Attribute '{attributeName}' does not exist in template {instance.TemplateId}. Cannot add new attributes via overrides.");
if (templateAttr.IsLocked)
return Result<InstanceAttributeOverride>.Failure(
$"Attribute '{attributeName}' is locked and cannot be overridden.");
// Find existing override or create new one
var overrides = await _repository.GetOverridesByInstanceIdAsync(instanceId, cancellationToken);
var existingOverride = overrides.FirstOrDefault(o => o.AttributeName == attributeName);
if (existingOverride != null)
{
existingOverride.OverrideValue = overrideValue;
await _repository.UpdateInstanceAttributeOverrideAsync(existingOverride, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
await _auditService.LogAsync(user, "UpdateOverride", "InstanceAttributeOverride",
existingOverride.Id.ToString(), attributeName, existingOverride, cancellationToken);
return Result<InstanceAttributeOverride>.Success(existingOverride);
}
else
{
var newOverride = new InstanceAttributeOverride(attributeName)
{
InstanceId = instanceId,
OverrideValue = overrideValue
};
await _repository.AddInstanceAttributeOverrideAsync(newOverride, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
await _auditService.LogAsync(user, "CreateOverride", "InstanceAttributeOverride",
newOverride.Id.ToString(), attributeName, newOverride, cancellationToken);
return Result<InstanceAttributeOverride>.Success(newOverride);
}
}
/// <summary>
/// Sets connection bindings for an instance in bulk.
/// </summary>
public async Task<Result<IReadOnlyList<InstanceConnectionBinding>>> SetConnectionBindingsAsync(
int instanceId,
IReadOnlyList<(string AttributeName, int DataConnectionId)> bindings,
string user,
CancellationToken cancellationToken = default)
{
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
if (instance == null)
return Result<IReadOnlyList<InstanceConnectionBinding>>.Failure($"Instance with ID {instanceId} not found.");
var existingBindings = await _repository.GetBindingsByInstanceIdAsync(instanceId, cancellationToken);
var existingMap = existingBindings.ToDictionary(b => b.AttributeName, StringComparer.Ordinal);
var results = new List<InstanceConnectionBinding>();
foreach (var (attrName, connId) in bindings)
{
if (existingMap.TryGetValue(attrName, out var existing))
{
existing.DataConnectionId = connId;
await _repository.UpdateInstanceConnectionBindingAsync(existing, cancellationToken);
results.Add(existing);
}
else
{
var binding = new InstanceConnectionBinding(attrName)
{
InstanceId = instanceId,
DataConnectionId = connId
};
await _repository.AddInstanceConnectionBindingAsync(binding, cancellationToken);
results.Add(binding);
}
}
await _repository.SaveChangesAsync(cancellationToken);
await _auditService.LogAsync(user, "SetConnectionBindings", "Instance",
instance.Id.ToString(), instance.UniqueName, bindings, cancellationToken);
return Result<IReadOnlyList<InstanceConnectionBinding>>.Success(results);
}
/// <summary>
/// Enables an instance.
/// </summary>
public async Task<Result<Instance>> EnableAsync(int instanceId, string user, CancellationToken cancellationToken = default)
{
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
if (instance == null)
return Result<Instance>.Failure($"Instance with ID {instanceId} not found.");
instance.State = InstanceState.Enabled;
await _repository.UpdateInstanceAsync(instance, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
await _auditService.LogAsync(user, "Enable", "Instance", instance.Id.ToString(),
instance.UniqueName, instance, cancellationToken);
return Result<Instance>.Success(instance);
}
/// <summary>
/// Disables an instance.
/// </summary>
public async Task<Result<Instance>> DisableAsync(int instanceId, string user, CancellationToken cancellationToken = default)
{
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
if (instance == null)
return Result<Instance>.Failure($"Instance with ID {instanceId} not found.");
instance.State = InstanceState.Disabled;
await _repository.UpdateInstanceAsync(instance, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
await _auditService.LogAsync(user, "Disable", "Instance", instance.Id.ToString(),
instance.UniqueName, instance, cancellationToken);
return Result<Instance>.Success(instance);
}
/// <summary>
/// Deletes an instance.
/// </summary>
public async Task<Result<bool>> DeleteAsync(int instanceId, string user, CancellationToken cancellationToken = default)
{
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
if (instance == null)
return Result<bool>.Failure($"Instance with ID {instanceId} not found.");
await _repository.DeleteInstanceAsync(instanceId, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
await _auditService.LogAsync(user, "Delete", "Instance", instanceId.ToString(),
instance.UniqueName, null, cancellationToken);
return Result<bool>.Success(true);
}
/// <summary>
/// Gets an instance by ID.
/// </summary>
public async Task<Instance?> GetByIdAsync(int instanceId, CancellationToken cancellationToken = default) =>
await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
/// <summary>
/// Gets all instances for a site.
/// </summary>
public async Task<IReadOnlyList<Instance>> GetBySiteIdAsync(int siteId, CancellationToken cancellationToken = default) =>
await _repository.GetInstancesBySiteIdAsync(siteId, cancellationToken);
}