feat(m9/T26a): read-only inheritance resolve service + GetResolvedTemplateMembersCommand

This commit is contained in:
Joseph Doherty
2026-06-18 12:14:24 -04:00
parent 1ca2e0b130
commit 26e2cdef23
5 changed files with 720 additions and 0 deletions
@@ -0,0 +1,285 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine;
/// <summary>
/// Read-only AUTHORING resolver (M9/T26a). Given a template id and the full
/// template lookup, computes the EFFECTIVE inherited member set fresh from the
/// whole inheritance chain (root → leaf, arbitrary depth, cycle-guarded via
/// <see cref="TemplateResolver.BuildInheritanceChain"/>), annotated per member
/// with origin (own / inherited-from-X) and lock state, plus a staleness
/// summary comparing the template's STORED member rows against the resolved set.
///
/// <para>
/// The inheritance precedence here MIRRORS
/// <see cref="Flattening.FlatteningService"/>'s <c>ResolveInherited*</c> methods
/// EXACTLY so the editor preview agrees with what a deploy would produce:
/// walk base → derived (derived wins); an <c>IsInherited</c> placeholder row on
/// a derived template never shadows the live base value (it only contributes a
/// row when no ancestor defines the member); a locked member, once seen, is not
/// overridden by a downstream template; and an ancestor's
/// <c>LockedInDerived</c> flag is propagated so the editor can render the
/// member read-only.
/// </para>
///
/// <para>
/// This is inheritance only — composition and instance overrides are the
/// instance flattener's concern (there are no instances in the
/// template-authoring context). It NEVER mutates stored rows and is not on the
/// deploy path.
/// </para>
/// </summary>
public static class TemplateInheritanceResolver
{
/// <summary>
/// Resolves the effective inherited member set for <paramref name="templateId"/>.
/// </summary>
/// <param name="templateId">The template to resolve members for.</param>
/// <param name="allTemplates">The complete set of templates (for chain lookups).</param>
/// <returns>
/// The resolved member set (attributes / alarms / scripts / native alarm
/// sources), each annotated with origin + lock state, plus the staleness
/// summary. Returns an empty set when the template id is not found.
/// </returns>
public static ResolvedTemplateMembers Resolve(int templateId, IReadOnlyList<Template> allTemplates)
{
// Duplicate-tolerant lookup (matches TemplateResolver.ResolveAllMembers):
// an Id of 0 is a valid node (import-staging / not-yet-saved rows).
var lookup = CycleDetector.BuildLookup(allTemplates);
if (!lookup.TryGetValue(templateId, out var self))
return new ResolvedTemplateMembers { TemplateId = templateId };
// Root → leaf (derived last). The OWN template is the last element.
var chain = TemplateResolver.BuildInheritanceChain(templateId, lookup);
var attributes = ResolveAttributes(chain, templateId);
var alarms = ResolveAlarms(chain, templateId);
var scripts = ResolveScripts(chain, templateId);
var nativeAlarmSources = ResolveNativeAlarmSources(chain, templateId);
var staleness = ComputeStaleness(self, attributes, alarms, scripts, nativeAlarmSources);
return new ResolvedTemplateMembers
{
TemplateId = templateId,
TemplateName = self.Name,
ParentTemplateId = self.ParentTemplateId,
Attributes = attributes,
Alarms = alarms,
Scripts = scripts,
NativeAlarmSources = nativeAlarmSources,
Staleness = staleness
};
}
// ── per-member-type winners (origin-tracking) ──
/// <summary>The winning row for a member, plus where it came from.</summary>
private sealed record Winner<T>(T Row, Template Origin, bool BaseLocked);
/// <summary>
/// Generic base → derived walk that picks the winning row per member name,
/// replicating FlatteningService precedence:
/// derived wins; an <c>IsInherited</c> placeholder does not shadow a live
/// ancestor value; a locked member is not overridden downstream; the
/// ancestor <c>LockedInDerived</c> flag is tracked.
/// </summary>
private static Dictionary<string, Winner<T>> ResolveWinners<T>(
IReadOnlyList<Template> chain,
Func<Template, IEnumerable<T>> select,
Func<T, string> nameOf,
Func<T, bool> isInherited,
Func<T, bool> isLocked,
Func<T, bool> lockedInDerived)
{
var result = new Dictionary<string, Winner<T>>(StringComparer.Ordinal);
// chain is root-first; walk root → derived so the derived template
// (last) wins, mirroring FlatteningService (which walks its
// most-derived-first chain back-to-front for the same effect).
foreach (var template in chain)
{
foreach (var row in select(template))
{
var name = nameOf(row);
if (result.TryGetValue(name, out var existing))
{
// A locked ancestor row is not overridden by a downstream template.
if (isLocked(existing.Row))
{
if (lockedInDerived(row))
result[name] = existing with { BaseLocked = true };
continue;
}
// IsInherited placeholders never shadow the live ancestor row.
if (isInherited(row))
{
if (lockedInDerived(row))
result[name] = existing with { BaseLocked = true };
continue;
}
}
var baseLocked = (existing?.BaseLocked ?? false) || lockedInDerived(row);
result[name] = new Winner<T>(row, template, baseLocked);
}
}
return result;
}
private static IReadOnlyList<ResolvedTemplateMemberInfo> ResolveAttributes(
IReadOnlyList<Template> chain, int selfId)
{
var winners = ResolveWinners(
chain,
t => t.Attributes,
a => a.Name,
a => a.IsInherited,
a => a.IsLocked,
a => a.LockedInDerived);
return Project(winners, selfId, w => w.Row.Value);
}
private static IReadOnlyList<ResolvedTemplateMemberInfo> ResolveAlarms(
IReadOnlyList<Template> chain, int selfId)
{
var winners = ResolveWinners(
chain,
t => t.Alarms,
a => a.Name,
a => a.IsInherited,
a => a.IsLocked,
a => a.LockedInDerived);
// No single scalar value for an alarm; surface the priority as the preview value.
return Project(winners, selfId, w => w.Row.PriorityLevel.ToString());
}
private static IReadOnlyList<ResolvedTemplateMemberInfo> ResolveScripts(
IReadOnlyList<Template> chain, int selfId)
{
var winners = ResolveWinners(
chain,
t => t.Scripts,
s => s.Name,
s => s.IsInherited,
s => s.IsLocked,
s => s.LockedInDerived);
return Project(winners, selfId, w => w.Row.Code);
}
private static IReadOnlyList<ResolvedTemplateMemberInfo> ResolveNativeAlarmSources(
IReadOnlyList<Template> chain, int selfId)
{
var winners = ResolveWinners(
chain,
t => t.NativeAlarmSources,
s => s.Name,
s => s.IsInherited,
s => s.IsLocked,
s => s.LockedInDerived);
return Project(winners, selfId, w => w.Row.SourceReference);
}
private static IReadOnlyList<ResolvedTemplateMemberInfo> Project<T>(
Dictionary<string, Winner<T>> winners,
int selfId,
Func<Winner<T>, string?> effectiveValue)
{
return winners.Values
.Select(w => new ResolvedTemplateMemberInfo
{
Name = NameOf(w.Row),
IsInherited = w.Origin.Id != selfId,
OriginTemplateId = w.Origin.Id,
OriginTemplateName = w.Origin.Name,
IsLocked = IsLockedOf(w.Row),
IsBaseLocked = w.BaseLocked && w.Origin.Id != selfId,
EffectiveValue = effectiveValue(w)
})
.OrderBy(m => m.Name, StringComparer.Ordinal)
.ToList();
}
private static string NameOf<T>(T row) => row switch
{
TemplateAttribute a => a.Name,
TemplateAlarm a => a.Name,
TemplateScript s => s.Name,
TemplateNativeAlarmSource s => s.Name,
_ => string.Empty
};
private static bool IsLockedOf<T>(T row) => row switch
{
TemplateAttribute a => a.IsLocked,
TemplateAlarm a => a.IsLocked,
TemplateScript s => s.IsLocked,
TemplateNativeAlarmSource s => s.IsLocked,
_ => false
};
// ── staleness ──
/// <summary>
/// Compares the template's STORED member rows against the freshly-resolved
/// set and counts how many INHERITED members drift: a freshly-resolved
/// inherited member with no stored row, or whose stored placeholder value
/// differs from the live resolved value. Own (non-inherited) members never
/// count toward staleness — they are authoritative by definition.
/// </summary>
private static ResolvedTemplateStaleness ComputeStaleness(
Template self,
IReadOnlyList<ResolvedTemplateMemberInfo> attributes,
IReadOnlyList<ResolvedTemplateMemberInfo> alarms,
IReadOnlyList<ResolvedTemplateMemberInfo> scripts,
IReadOnlyList<ResolvedTemplateMemberInfo> nativeAlarmSources)
{
int differing = 0;
differing += CountDrift(attributes,
self.Attributes.ToDictionary(a => a.Name, a => (string?)a.Value, StringComparer.Ordinal));
differing += CountDrift(alarms,
self.Alarms.ToDictionary(a => a.Name, a => (string?)a.PriorityLevel.ToString(), StringComparer.Ordinal));
differing += CountDrift(scripts,
self.Scripts.ToDictionary(s => s.Name, s => (string?)s.Code, StringComparer.Ordinal));
differing += CountDrift(nativeAlarmSources,
self.NativeAlarmSources.ToDictionary(s => s.Name, s => (string?)s.SourceReference, StringComparer.Ordinal));
return new ResolvedTemplateStaleness
{
IsStale = differing > 0,
DifferingMemberCount = differing
};
}
private static int CountDrift(
IReadOnlyList<ResolvedTemplateMemberInfo> resolved,
IReadOnlyDictionary<string, string?> storedValuesByName)
{
int count = 0;
foreach (var m in resolved)
{
if (!m.IsInherited)
continue; // own rows are authoritative — never stale
if (!storedValuesByName.TryGetValue(m.Name, out var storedValue))
{
// Inherited member with no stored placeholder row (e.g. a base
// member added after the derived template was created).
count++;
continue;
}
if (!string.Equals(storedValue, m.EffectiveValue, StringComparison.Ordinal))
count++; // stored placeholder is stale vs. the live base value
}
return count;
}
}