874e32a93e
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
197 lines
8.2 KiB
C#
197 lines
8.2 KiB
C#
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.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)
|
|
{
|
|
// Duplicate-tolerant lookup (see CycleDetector.BuildLookup): a plain
|
|
// ToDictionary(t => t.Id) throws if two templates share an Id (e.g.
|
|
// not-yet-saved templates carrying Id 0).
|
|
var lookup = CycleDetector.BuildLookup(allTemplates);
|
|
var allMembers = new List<ResolvedMember>();
|
|
|
|
// Collect direct (top-level) members. Inherited placeholder rows are skipped:
|
|
// the inheritance walk below re-adds them under the parent's origin.
|
|
CollectDirectMembers(template, prefix: null, originPrefix: template.Name, allMembers, skipInherited: true);
|
|
|
|
// 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;
|
|
}
|
|
|
|
/// <param name="skipInherited">
|
|
/// When <c>true</c>, <c>IsInherited</c> placeholder rows are skipped. Those rows
|
|
/// are materialized copies of members the template inherits from its parent
|
|
/// chain, and they are ALSO re-added — under the parent's name — by
|
|
/// <see cref="CollectInheritedMembers"/>. Counting both yielded two distinct
|
|
/// origins for the same canonical name and a spurious collision, which blocked
|
|
/// every attempt to add an attribute/composition to a derived template.
|
|
/// Pass <c>false</c> only for the composed-module walk, where
|
|
/// placeholder rows are the sole representation of a derived module's inherited
|
|
/// members (that walk does not climb the composed template's parent chain).
|
|
/// </param>
|
|
private static void CollectDirectMembers(
|
|
Template template,
|
|
string? prefix,
|
|
string originPrefix,
|
|
List<ResolvedMember> members,
|
|
bool skipInherited)
|
|
{
|
|
foreach (var attr in template.Attributes)
|
|
{
|
|
if (skipInherited && attr.IsInherited) continue;
|
|
var canonicalName = prefix == null ? attr.Name : $"{prefix}.{attr.Name}";
|
|
members.Add(new ResolvedMember(canonicalName, "Attribute", originPrefix));
|
|
}
|
|
|
|
foreach (var alarm in template.Alarms)
|
|
{
|
|
if (skipInherited && alarm.IsInherited) continue;
|
|
var canonicalName = prefix == null ? alarm.Name : $"{prefix}.{alarm.Name}";
|
|
members.Add(new ResolvedMember(canonicalName, "Alarm", originPrefix));
|
|
}
|
|
|
|
foreach (var script in template.Scripts)
|
|
{
|
|
if (skipInherited && script.IsInherited) continue;
|
|
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)
|
|
{
|
|
// `visited` is the current RECURSION PATH, not a global visited-set: an id is
|
|
// added on entry and REMOVED on exit (finally), so a template legitimately
|
|
// composed into more than one slot is re-descended for each slot. The early
|
|
// return fires only on a genuine cycle — an id already on the ACTIVE path.
|
|
// (A global visited-set would blind the detector to every collision reachable
|
|
// only through the second-and-later slot of a repeatedly-composed template.)
|
|
if (!visited.Add(template.Id))
|
|
return;
|
|
|
|
try
|
|
{
|
|
// Add direct members of this composed template with the prefix. Inherited
|
|
// placeholder rows are KEPT here: the composed-module walk does not climb the
|
|
// composed template's parent chain, so the placeholders are the only
|
|
// representation of a derived module's inherited members.
|
|
CollectDirectMembers(template, prefix, $"module '{prefix}'", members, skipInherited: false);
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
visited.Remove(template.Id);
|
|
}
|
|
}
|
|
|
|
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). Skip the parent's OWN inherited
|
|
// placeholders too: the next recursion adds the grandparent's real rows, so
|
|
// counting the parent's copies would re-introduce the collision one level up.
|
|
CollectDirectMembers(parent, prefix: null, $"parent '{parent.Name}'", members, skipInherited: true);
|
|
|
|
// 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);
|
|
}
|
|
}
|