82 lines
4.1 KiB
C#
82 lines
4.1 KiB
C#
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
|
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Server.Security;
|
|
|
|
/// <summary>
|
|
/// Builds the <see cref="NodeScope"/> path index consumed by <see cref="NodeScopeResolver"/>
|
|
/// from a Config-DB snapshot of a single published generation. Runs once per generation
|
|
/// (or on every generation change) at the Server bootstrap layer; the produced index is
|
|
/// immutable + hot-path readable per ADR-001 Task B.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>The index key is the driver-side full reference (<c>Tag.TagConfig</c>) — the same
|
|
/// string the dispatch layer passes to <see cref="NodeScopeResolver.Resolve"/>. The value
|
|
/// is a <see cref="NodeScope"/> with every UNS level populated:
|
|
/// <c>ClusterId / NamespaceId / UnsAreaId / UnsLineId / EquipmentId / TagId</c>. Tag rows
|
|
/// with null <c>EquipmentId</c> (SystemPlatform-namespace Galaxy tags per decision #120)
|
|
/// are excluded from the index — the cluster-only fallback path in the resolver handles
|
|
/// them without needing an index entry.</para>
|
|
///
|
|
/// <para>Duplicate keys are not expected but would be indicative of corrupt data — the
|
|
/// builder throws <see cref="InvalidOperationException"/> on collision so a config drift
|
|
/// surfaces at bootstrap instead of producing silently-last-wins scopes at dispatch.</para>
|
|
/// </remarks>
|
|
public static class ScopePathIndexBuilder
|
|
{
|
|
/// <summary>
|
|
/// Build a fullReference → NodeScope index from the four Config-DB collections for a
|
|
/// single namespace. Callers must filter inputs to a single
|
|
/// <see cref="Namespace"/> + the same <see cref="ConfigGeneration"/> upstream.
|
|
/// </summary>
|
|
/// <param name="clusterId">Owning cluster — populates <see cref="NodeScope.ClusterId"/>.</param>
|
|
/// <param name="namespaceId">Owning namespace — populates <see cref="NodeScope.NamespaceId"/>.</param>
|
|
/// <param name="content">Pre-loaded rows for the namespace.</param>
|
|
public static IReadOnlyDictionary<string, NodeScope> Build(
|
|
string clusterId,
|
|
string namespaceId,
|
|
EquipmentNamespaceContent content)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(namespaceId);
|
|
ArgumentNullException.ThrowIfNull(content);
|
|
|
|
var areaByLine = content.Lines.ToDictionary(l => l.UnsLineId, l => l.UnsAreaId, StringComparer.OrdinalIgnoreCase);
|
|
var lineByEquipment = content.Equipment.ToDictionary(e => e.EquipmentId, e => e.UnsLineId, StringComparer.OrdinalIgnoreCase);
|
|
|
|
var index = new Dictionary<string, NodeScope>(StringComparer.Ordinal);
|
|
|
|
foreach (var tag in content.Tags)
|
|
{
|
|
// Null EquipmentId = SystemPlatform-namespace tag per decision #110 — skip; the
|
|
// cluster-only resolver fallback handles those without needing an index entry.
|
|
if (string.IsNullOrEmpty(tag.EquipmentId)) continue;
|
|
|
|
// Broken FK — Tag references a missing Equipment row. Skip rather than crash;
|
|
// sp_ValidateDraft should have caught this at publish, so any drift here is
|
|
// unexpected but non-fatal.
|
|
if (!lineByEquipment.TryGetValue(tag.EquipmentId, out var lineId)) continue;
|
|
if (!areaByLine.TryGetValue(lineId, out var areaId)) continue;
|
|
|
|
var scope = new NodeScope
|
|
{
|
|
ClusterId = clusterId,
|
|
NamespaceId = namespaceId,
|
|
UnsAreaId = areaId,
|
|
UnsLineId = lineId,
|
|
EquipmentId = tag.EquipmentId,
|
|
TagId = tag.TagConfig,
|
|
Kind = NodeHierarchyKind.Equipment,
|
|
};
|
|
|
|
if (!index.TryAdd(tag.TagConfig, scope))
|
|
throw new InvalidOperationException(
|
|
$"Duplicate fullReference '{tag.TagConfig}' in Equipment namespace '{namespaceId}'. " +
|
|
"Config data is corrupt — two Tag rows produced the same wire-level address.");
|
|
}
|
|
|
|
return index;
|
|
}
|
|
}
|