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:
@@ -0,0 +1,394 @@
|
||||
using ScadaLink.Commons.Entities.Instances;
|
||||
using ScadaLink.Commons.Entities.Sites;
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
using ScadaLink.Commons.Types;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
using ScadaLink.Commons.Types.Flattening;
|
||||
|
||||
namespace ScadaLink.TemplateEngine.Flattening;
|
||||
|
||||
/// <summary>
|
||||
/// Implements the template flattening algorithm.
|
||||
/// Takes a template inheritance/composition graph plus instance overrides and connection bindings,
|
||||
/// and produces a fully resolved FlattenedConfiguration.
|
||||
///
|
||||
/// Resolution order (most specific wins):
|
||||
/// 1. Instance overrides (highest priority)
|
||||
/// 2. Child template (most derived first in inheritance chain)
|
||||
/// 3. Parent templates (walking up inheritance chain)
|
||||
/// 4. Composed modules (recursively flattened with path-qualified canonical names)
|
||||
///
|
||||
/// Locked fields cannot be overridden by instance overrides.
|
||||
/// </summary>
|
||||
public class FlatteningService
|
||||
{
|
||||
/// <summary>
|
||||
/// Produces a fully flattened configuration for an instance.
|
||||
/// </summary>
|
||||
/// <param name="instance">The instance to flatten.</param>
|
||||
/// <param name="templateChain">
|
||||
/// The inheritance chain from most-derived to root (index 0 = the instance's template,
|
||||
/// last = the ultimate base template). Each template includes its own attributes, alarms, scripts.
|
||||
/// </param>
|
||||
/// <param name="compositionMap">
|
||||
/// Map of template ID → list of compositions (composed module definitions).
|
||||
/// For each composition, the key is the parent template ID and the value includes the
|
||||
/// composed template's resolved chain.
|
||||
/// </param>
|
||||
/// <param name="composedTemplateChains">
|
||||
/// Map of composed template ID → its inheritance chain (same format as templateChain).
|
||||
/// </param>
|
||||
/// <param name="dataConnections">
|
||||
/// Available data connections for resolving connection bindings.
|
||||
/// </param>
|
||||
/// <returns>A Result containing the FlattenedConfiguration or an error message.</returns>
|
||||
public Result<FlattenedConfiguration> Flatten(
|
||||
Instance instance,
|
||||
IReadOnlyList<Template> templateChain,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<TemplateComposition>> compositionMap,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<Template>> composedTemplateChains,
|
||||
IReadOnlyDictionary<int, DataConnection> dataConnections)
|
||||
{
|
||||
if (templateChain.Count == 0)
|
||||
return Result<FlattenedConfiguration>.Failure("Template chain is empty.");
|
||||
|
||||
try
|
||||
{
|
||||
// Step 1: Resolve attributes from inheritance chain (most-derived-first wins for same name)
|
||||
var attributes = ResolveInheritedAttributes(templateChain);
|
||||
|
||||
// Step 2: Resolve composed module attributes with path-qualified names
|
||||
ResolveComposedAttributes(templateChain, compositionMap, composedTemplateChains, attributes);
|
||||
|
||||
// Step 3: Apply instance overrides (respecting locks)
|
||||
ApplyInstanceOverrides(instance.AttributeOverrides, attributes);
|
||||
|
||||
// Step 4: Apply connection bindings
|
||||
ApplyConnectionBindings(instance.ConnectionBindings, attributes, dataConnections);
|
||||
|
||||
// Step 5: Resolve alarms from inheritance chain
|
||||
var alarms = ResolveInheritedAlarms(templateChain);
|
||||
ResolveComposedAlarms(templateChain, compositionMap, composedTemplateChains, alarms);
|
||||
|
||||
// Step 6: Resolve scripts from inheritance chain
|
||||
var scripts = ResolveInheritedScripts(templateChain);
|
||||
ResolveComposedScripts(templateChain, compositionMap, composedTemplateChains, scripts);
|
||||
|
||||
// Step 7: Resolve alarm on-trigger script references to canonical names
|
||||
ResolveAlarmScriptReferences(alarms, scripts);
|
||||
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = instance.UniqueName,
|
||||
TemplateId = instance.TemplateId,
|
||||
SiteId = instance.SiteId,
|
||||
AreaId = instance.AreaId,
|
||||
Attributes = attributes.Values.OrderBy(a => a.CanonicalName, StringComparer.Ordinal).ToList(),
|
||||
Alarms = alarms.Values.OrderBy(a => a.CanonicalName, StringComparer.Ordinal).ToList(),
|
||||
Scripts = scripts.Values.OrderBy(s => s.CanonicalName, StringComparer.Ordinal).ToList(),
|
||||
GeneratedAtUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
return Result<FlattenedConfiguration>.Success(config);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result<FlattenedConfiguration>.Failure($"Flattening failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, ResolvedAttribute> ResolveInheritedAttributes(
|
||||
IReadOnlyList<Template> templateChain)
|
||||
{
|
||||
var result = new Dictionary<string, ResolvedAttribute>(StringComparer.Ordinal);
|
||||
|
||||
// Walk from base (last) to most-derived (first) so derived values win
|
||||
for (int i = templateChain.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var template = templateChain[i];
|
||||
var source = i == 0 ? "Template" : "Inherited";
|
||||
|
||||
foreach (var attr in template.Attributes)
|
||||
{
|
||||
// If a parent defined this attribute as locked, derived cannot change the value
|
||||
if (result.TryGetValue(attr.Name, out var existing) && existing.IsLocked)
|
||||
continue;
|
||||
|
||||
result[attr.Name] = new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = attr.Name,
|
||||
Value = attr.Value,
|
||||
DataType = attr.DataType.ToString(),
|
||||
IsLocked = attr.IsLocked,
|
||||
Description = attr.Description,
|
||||
DataSourceReference = attr.DataSourceReference,
|
||||
Source = source
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void ResolveComposedAttributes(
|
||||
IReadOnlyList<Template> templateChain,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<TemplateComposition>> compositionMap,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<Template>> composedTemplateChains,
|
||||
Dictionary<string, ResolvedAttribute> attributes)
|
||||
{
|
||||
// Process compositions from each template in the chain
|
||||
foreach (var template in templateChain)
|
||||
{
|
||||
if (!compositionMap.TryGetValue(template.Id, out var compositions))
|
||||
continue;
|
||||
|
||||
foreach (var composition in compositions)
|
||||
{
|
||||
if (!composedTemplateChains.TryGetValue(composition.ComposedTemplateId, out var composedChain))
|
||||
continue;
|
||||
|
||||
var prefix = composition.InstanceName;
|
||||
var composedAttrs = ResolveInheritedAttributes(composedChain);
|
||||
|
||||
foreach (var (name, attr) in composedAttrs)
|
||||
{
|
||||
var canonicalName = $"{prefix}.{name}";
|
||||
// Don't overwrite if already defined (most-derived wins)
|
||||
if (!attributes.ContainsKey(canonicalName))
|
||||
{
|
||||
attributes[canonicalName] = attr with
|
||||
{
|
||||
CanonicalName = canonicalName,
|
||||
Source = "Composed"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse into nested compositions
|
||||
foreach (var composedTemplate in composedChain)
|
||||
{
|
||||
if (!compositionMap.TryGetValue(composedTemplate.Id, out var nestedCompositions))
|
||||
continue;
|
||||
|
||||
foreach (var nested in nestedCompositions)
|
||||
{
|
||||
if (!composedTemplateChains.TryGetValue(nested.ComposedTemplateId, out var nestedChain))
|
||||
continue;
|
||||
|
||||
var nestedPrefix = $"{prefix}.{nested.InstanceName}";
|
||||
var nestedAttrs = ResolveInheritedAttributes(nestedChain);
|
||||
|
||||
foreach (var (name, attr) in nestedAttrs)
|
||||
{
|
||||
var canonicalName = $"{nestedPrefix}.{name}";
|
||||
if (!attributes.ContainsKey(canonicalName))
|
||||
{
|
||||
attributes[canonicalName] = attr with
|
||||
{
|
||||
CanonicalName = canonicalName,
|
||||
Source = "Composed"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyInstanceOverrides(
|
||||
ICollection<InstanceAttributeOverride> overrides,
|
||||
Dictionary<string, ResolvedAttribute> attributes)
|
||||
{
|
||||
foreach (var ovr in overrides)
|
||||
{
|
||||
if (!attributes.TryGetValue(ovr.AttributeName, out var existing))
|
||||
continue; // Cannot add new attributes via overrides
|
||||
|
||||
if (existing.IsLocked)
|
||||
continue; // Locked attributes cannot be overridden
|
||||
|
||||
attributes[ovr.AttributeName] = existing with
|
||||
{
|
||||
Value = ovr.OverrideValue,
|
||||
Source = "Override"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyConnectionBindings(
|
||||
ICollection<InstanceConnectionBinding> bindings,
|
||||
Dictionary<string, ResolvedAttribute> attributes,
|
||||
IReadOnlyDictionary<int, DataConnection> dataConnections)
|
||||
{
|
||||
foreach (var binding in bindings)
|
||||
{
|
||||
if (!attributes.TryGetValue(binding.AttributeName, out var existing))
|
||||
continue;
|
||||
|
||||
if (existing.DataSourceReference == null)
|
||||
continue; // Only data-sourced attributes can have connection bindings
|
||||
|
||||
if (!dataConnections.TryGetValue(binding.DataConnectionId, out var connection))
|
||||
continue;
|
||||
|
||||
attributes[binding.AttributeName] = existing with
|
||||
{
|
||||
BoundDataConnectionId = connection.Id,
|
||||
BoundDataConnectionName = connection.Name,
|
||||
BoundDataConnectionProtocol = connection.Protocol
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, ResolvedAlarm> ResolveInheritedAlarms(
|
||||
IReadOnlyList<Template> templateChain)
|
||||
{
|
||||
var result = new Dictionary<string, ResolvedAlarm>(StringComparer.Ordinal);
|
||||
|
||||
for (int i = templateChain.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var template = templateChain[i];
|
||||
var source = i == 0 ? "Template" : "Inherited";
|
||||
|
||||
foreach (var alarm in template.Alarms)
|
||||
{
|
||||
if (result.TryGetValue(alarm.Name, out var existing) && existing.IsLocked)
|
||||
continue;
|
||||
|
||||
result[alarm.Name] = new ResolvedAlarm
|
||||
{
|
||||
CanonicalName = alarm.Name,
|
||||
Description = alarm.Description,
|
||||
PriorityLevel = alarm.PriorityLevel,
|
||||
IsLocked = alarm.IsLocked,
|
||||
TriggerType = alarm.TriggerType.ToString(),
|
||||
TriggerConfiguration = alarm.TriggerConfiguration,
|
||||
OnTriggerScriptCanonicalName = null, // Resolved later
|
||||
Source = source
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void ResolveComposedAlarms(
|
||||
IReadOnlyList<Template> templateChain,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<TemplateComposition>> compositionMap,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<Template>> composedTemplateChains,
|
||||
Dictionary<string, ResolvedAlarm> alarms)
|
||||
{
|
||||
foreach (var template in templateChain)
|
||||
{
|
||||
if (!compositionMap.TryGetValue(template.Id, out var compositions))
|
||||
continue;
|
||||
|
||||
foreach (var composition in compositions)
|
||||
{
|
||||
if (!composedTemplateChains.TryGetValue(composition.ComposedTemplateId, out var composedChain))
|
||||
continue;
|
||||
|
||||
var prefix = composition.InstanceName;
|
||||
var composedAlarms = ResolveInheritedAlarms(composedChain);
|
||||
|
||||
foreach (var (name, alarm) in composedAlarms)
|
||||
{
|
||||
var canonicalName = $"{prefix}.{name}";
|
||||
if (!alarms.ContainsKey(canonicalName))
|
||||
{
|
||||
alarms[canonicalName] = alarm with
|
||||
{
|
||||
CanonicalName = canonicalName,
|
||||
Source = "Composed"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, ResolvedScript> ResolveInheritedScripts(
|
||||
IReadOnlyList<Template> templateChain)
|
||||
{
|
||||
var result = new Dictionary<string, ResolvedScript>(StringComparer.Ordinal);
|
||||
|
||||
for (int i = templateChain.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var template = templateChain[i];
|
||||
var source = i == 0 ? "Template" : "Inherited";
|
||||
|
||||
foreach (var script in template.Scripts)
|
||||
{
|
||||
if (result.TryGetValue(script.Name, out var existing) && existing.IsLocked)
|
||||
continue;
|
||||
|
||||
result[script.Name] = new ResolvedScript
|
||||
{
|
||||
CanonicalName = script.Name,
|
||||
Code = script.Code,
|
||||
IsLocked = script.IsLocked,
|
||||
TriggerType = script.TriggerType,
|
||||
TriggerConfiguration = script.TriggerConfiguration,
|
||||
ParameterDefinitions = script.ParameterDefinitions,
|
||||
ReturnDefinition = script.ReturnDefinition,
|
||||
MinTimeBetweenRuns = script.MinTimeBetweenRuns,
|
||||
Source = source
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void ResolveComposedScripts(
|
||||
IReadOnlyList<Template> templateChain,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<TemplateComposition>> compositionMap,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<Template>> composedTemplateChains,
|
||||
Dictionary<string, ResolvedScript> scripts)
|
||||
{
|
||||
foreach (var template in templateChain)
|
||||
{
|
||||
if (!compositionMap.TryGetValue(template.Id, out var compositions))
|
||||
continue;
|
||||
|
||||
foreach (var composition in compositions)
|
||||
{
|
||||
if (!composedTemplateChains.TryGetValue(composition.ComposedTemplateId, out var composedChain))
|
||||
continue;
|
||||
|
||||
var prefix = composition.InstanceName;
|
||||
var composedScripts = ResolveInheritedScripts(composedChain);
|
||||
|
||||
foreach (var (name, script) in composedScripts)
|
||||
{
|
||||
var canonicalName = $"{prefix}.{name}";
|
||||
if (!scripts.ContainsKey(canonicalName))
|
||||
{
|
||||
scripts[canonicalName] = script with
|
||||
{
|
||||
CanonicalName = canonicalName,
|
||||
Source = "Composed"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves alarm on-trigger script references from script IDs to canonical names.
|
||||
/// This is done by finding the script in the template chain whose ID matches the alarm's OnTriggerScriptId,
|
||||
/// then mapping to the corresponding canonical name in the resolved scripts.
|
||||
/// </summary>
|
||||
private static void ResolveAlarmScriptReferences(
|
||||
Dictionary<string, ResolvedAlarm> alarms,
|
||||
Dictionary<string, ResolvedScript> scripts)
|
||||
{
|
||||
// Build a lookup of script names (we only have canonical names at this point)
|
||||
// The alarm's OnTriggerScriptCanonicalName will be set by the caller or validation step
|
||||
// For now, this is a placeholder — the actual resolution depends on how alarm trigger configs
|
||||
// reference scripts (by name within the same scope).
|
||||
// The trigger configuration JSON may contain a "scriptName" field.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user