b68c313372
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
107 lines
4.9 KiB
C#
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}'";
|
|
}
|