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,159 @@
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
|
||||
namespace ScadaLink.TemplateEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Detects naming collisions across composed module members using canonical (path-qualified) names.
|
||||
/// Two members from different composed modules collide if they produce the same canonical name.
|
||||
/// Members from different module instance names cannot collide because the prefix differentiates them.
|
||||
/// </summary>
|
||||
public static class CollisionDetector
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a resolved member with its canonical name and origin.
|
||||
/// </summary>
|
||||
public sealed record ResolvedMember(
|
||||
string CanonicalName,
|
||||
string MemberType, // "Attribute", "Alarm", "Script"
|
||||
string OriginDescription);
|
||||
|
||||
/// <summary>
|
||||
/// Detects naming collisions among all members (direct + composed) of a template.
|
||||
/// </summary>
|
||||
/// <param name="template">The template to check.</param>
|
||||
/// <param name="allTemplates">All templates in the system (for resolving composed templates).</param>
|
||||
/// <returns>List of collision descriptions. Empty if no collisions.</returns>
|
||||
public static IReadOnlyList<string> DetectCollisions(
|
||||
Template template,
|
||||
IReadOnlyList<Template> allTemplates)
|
||||
{
|
||||
var lookup = allTemplates.ToDictionary(t => t.Id);
|
||||
var allMembers = new List<ResolvedMember>();
|
||||
|
||||
// Collect direct (top-level) members
|
||||
CollectDirectMembers(template, prefix: null, originPrefix: template.Name, allMembers);
|
||||
|
||||
// Collect members from composed modules recursively
|
||||
foreach (var composition in template.Compositions)
|
||||
{
|
||||
if (lookup.TryGetValue(composition.ComposedTemplateId, out var composedTemplate))
|
||||
{
|
||||
CollectComposedMembers(
|
||||
composedTemplate,
|
||||
prefix: composition.InstanceName,
|
||||
lookup,
|
||||
allMembers,
|
||||
visited: new HashSet<int>());
|
||||
}
|
||||
}
|
||||
|
||||
// Collect inherited members (walk parent chain)
|
||||
CollectInheritedMembers(template, lookup, allMembers, new HashSet<int> { template.Id });
|
||||
|
||||
// Detect duplicates by canonical name
|
||||
var collisions = new List<string>();
|
||||
var grouped = allMembers.GroupBy(m => m.CanonicalName, StringComparer.Ordinal);
|
||||
|
||||
foreach (var group in grouped)
|
||||
{
|
||||
var members = group.ToList();
|
||||
if (members.Count > 1)
|
||||
{
|
||||
// Only report collision if members come from different origins
|
||||
var distinctOrigins = members.Select(m => m.OriginDescription).Distinct().ToList();
|
||||
if (distinctOrigins.Count > 1)
|
||||
{
|
||||
var origins = string.Join(", ", members.Select(m => $"{m.MemberType} from {m.OriginDescription}"));
|
||||
collisions.Add($"Naming collision on '{group.Key}': {origins}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return collisions;
|
||||
}
|
||||
|
||||
private static void CollectDirectMembers(
|
||||
Template template,
|
||||
string? prefix,
|
||||
string originPrefix,
|
||||
List<ResolvedMember> members)
|
||||
{
|
||||
foreach (var attr in template.Attributes)
|
||||
{
|
||||
var canonicalName = prefix == null ? attr.Name : $"{prefix}.{attr.Name}";
|
||||
members.Add(new ResolvedMember(canonicalName, "Attribute", originPrefix));
|
||||
}
|
||||
|
||||
foreach (var alarm in template.Alarms)
|
||||
{
|
||||
var canonicalName = prefix == null ? alarm.Name : $"{prefix}.{alarm.Name}";
|
||||
members.Add(new ResolvedMember(canonicalName, "Alarm", originPrefix));
|
||||
}
|
||||
|
||||
foreach (var script in template.Scripts)
|
||||
{
|
||||
var canonicalName = prefix == null ? script.Name : $"{prefix}.{script.Name}";
|
||||
members.Add(new ResolvedMember(canonicalName, "Script", originPrefix));
|
||||
}
|
||||
}
|
||||
|
||||
private static void CollectComposedMembers(
|
||||
Template template,
|
||||
string prefix,
|
||||
Dictionary<int, Template> lookup,
|
||||
List<ResolvedMember> members,
|
||||
HashSet<int> visited)
|
||||
{
|
||||
if (!visited.Add(template.Id))
|
||||
return;
|
||||
|
||||
// Add direct members of this composed template with the prefix
|
||||
CollectDirectMembers(template, prefix, $"module '{prefix}'", members);
|
||||
|
||||
// Recurse into nested compositions
|
||||
foreach (var composition in template.Compositions)
|
||||
{
|
||||
if (lookup.TryGetValue(composition.ComposedTemplateId, out var nested))
|
||||
{
|
||||
var nestedPrefix = $"{prefix}.{composition.InstanceName}";
|
||||
CollectComposedMembers(nested, nestedPrefix, lookup, members, visited);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void CollectInheritedMembers(
|
||||
Template template,
|
||||
Dictionary<int, Template> lookup,
|
||||
List<ResolvedMember> members,
|
||||
HashSet<int> visited)
|
||||
{
|
||||
if (!template.ParentTemplateId.HasValue)
|
||||
return;
|
||||
|
||||
if (!lookup.TryGetValue(template.ParentTemplateId.Value, out var parent))
|
||||
return;
|
||||
|
||||
if (!visited.Add(parent.Id))
|
||||
return;
|
||||
|
||||
// Inherited direct members (no prefix)
|
||||
CollectDirectMembers(parent, prefix: null, $"parent '{parent.Name}'", members);
|
||||
|
||||
// Inherited composed modules
|
||||
foreach (var composition in parent.Compositions)
|
||||
{
|
||||
if (lookup.TryGetValue(composition.ComposedTemplateId, out var composedTemplate))
|
||||
{
|
||||
CollectComposedMembers(
|
||||
composedTemplate,
|
||||
composition.InstanceName,
|
||||
lookup,
|
||||
members,
|
||||
new HashSet<int>());
|
||||
}
|
||||
}
|
||||
|
||||
// Continue up the inheritance chain
|
||||
CollectInheritedMembers(parent, lookup, members, visited);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user