Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EffectiveNameGuard.cs
T
Joseph Doherty b68c313372 feat(uns-v3): implement EffectiveNameGuard authoring-time uniqueness guard (B3-WP2)
Implements IEffectiveNameGuard (the Wave-A contract): per-equipment effective-name
uniqueness across UnsTagReferences (DisplayNameOverride else backing raw tag Name),
VirtualTags (Name), and ScriptedAlarms (Name). Ordinal comparison, self-row exclusion
by (kind, id), one pooled context per CheckAsync call. Registered Scoped in
EndpointRouteBuilderExtensions so WP1's UnsTreeService consumer goes live.

Verified the deploy-time DraftValidator UnsEffectiveNameCollision rule already
computes reference effective names from the backing raw tag's current Name (catches
rename-induced collisions), spans all three sources, compares ordinal, and names both
colliding sources + the equipment — no hardening needed. DraftSnapshotFactory already
loads Tags/UnsTagReferences/VirtualTags/ScriptedAlarms.

Tests: 8 guard tests (in-memory EF) + 3 deploy-gate tests (rename-induced collision,
override-vs-VT, ordinal case-sensitivity). All green.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 06:01:10 -04:00

107 lines
4.9 KiB
C#

using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <summary>
/// Default <see cref="IEffectiveNameGuard"/>. Loads the owning equipment's current effective-name
/// set — references (<c>DisplayNameOverride</c> else the backing raw tag's <c>Name</c>),
/// VirtualTags (<c>Name</c>), and ScriptedAlarms (<c>Name</c>) — and reports the first collision
/// with a proposed name. Comparison is <see cref="StringComparer.Ordinal"/> to match the
/// <c>{EquipmentId}/{EffectiveName}</c> NodeId semantics and the deploy-time
/// <c>DraftValidator</c> <c>UnsEffectiveNameCollision</c> rule.
/// </summary>
/// <remarks>
/// Each call creates and disposes its own pooled context — the same per-call pattern
/// <see cref="UnsTreeService"/> uses — so the guard is safe to register <c>Scoped</c>.
/// </remarks>
public sealed class EffectiveNameGuard(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory) : IEffectiveNameGuard
{
/// <inheritdoc />
public async Task<string?> CheckAsync(
string equipmentId,
string proposedEffectiveName,
EffectiveNameSourceKind kind,
string? excludeSourceId,
CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
// References for this equipment. The effective name is the display-name override else the
// backing raw tag's current Name, so the two sets (references + tags) are loaded and joined
// in memory — the per-equipment set is small and this mirrors the deploy rule exactly
// (a reference whose backing tag is missing contributes no effective name, so it is skipped).
var references = await db.UnsTagReferences
.AsNoTracking()
.Where(r => r.EquipmentId == equipmentId)
.Select(r => new { r.UnsTagReferenceId, r.TagId, r.DisplayNameOverride })
.ToListAsync(ct);
var tagIds = references.Select(r => r.TagId).Distinct(StringComparer.Ordinal).ToList();
var tagNameById = (await db.Tags
.AsNoTracking()
.Where(t => tagIds.Contains(t.TagId))
.Select(t => new { t.TagId, t.Name })
.ToListAsync(ct))
.GroupBy(t => t.TagId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().Name, StringComparer.Ordinal);
foreach (var r in references)
{
if (IsSelf(EffectiveNameSourceKind.Reference, r.UnsTagReferenceId, kind, excludeSourceId))
continue;
var effective = r.DisplayNameOverride
?? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null);
if (Collides(effective, proposedEffectiveName))
return Message(proposedEffectiveName, "reference", r.UnsTagReferenceId, equipmentId);
}
var virtualTags = await db.VirtualTags
.AsNoTracking()
.Where(v => v.EquipmentId == equipmentId)
.Select(v => new { v.VirtualTagId, v.Name })
.ToListAsync(ct);
foreach (var v in virtualTags)
{
if (IsSelf(EffectiveNameSourceKind.VirtualTag, v.VirtualTagId, kind, excludeSourceId))
continue;
if (Collides(v.Name, proposedEffectiveName))
return Message(proposedEffectiveName, "VirtualTag", v.VirtualTagId, equipmentId);
}
var scriptedAlarms = await db.ScriptedAlarms
.AsNoTracking()
.Where(a => a.EquipmentId == equipmentId)
.Select(a => new { a.ScriptedAlarmId, a.Name })
.ToListAsync(ct);
foreach (var a in scriptedAlarms)
{
if (IsSelf(EffectiveNameSourceKind.ScriptedAlarm, a.ScriptedAlarmId, kind, excludeSourceId))
continue;
if (Collides(a.Name, proposedEffectiveName))
return Message(proposedEffectiveName, "ScriptedAlarm", a.ScriptedAlarmId, equipmentId);
}
return null;
}
/// <summary>True when a row is the self-row being edited — same kind AND same logical id — so it
/// is excluded from the collision set (a rename / override-change of a row cannot collide with itself).</summary>
private static bool IsSelf(
EffectiveNameSourceKind rowKind,
string rowId,
EffectiveNameSourceKind editedKind,
string? excludeSourceId) =>
excludeSourceId is not null
&& rowKind == editedKind
&& string.Equals(rowId, excludeSourceId, StringComparison.Ordinal);
private static bool Collides(string? existing, string proposed) =>
existing is not null && string.Equals(existing, proposed, StringComparison.Ordinal);
private static string Message(string name, string sourceLabel, string sourceId, string equipmentId) =>
$"effective name '{name}' already used by {sourceLabel} '{sourceId}' in equipment '{equipmentId}'";
}