refactor(adminui): strip alias/relay machinery from UnsTreeService + EquipmentPage; Galaxy tags use standard TagModal
This commit is contained in:
@@ -86,35 +86,11 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
public async Task<IReadOnlyList<EquipmentTagRow>> LoadTagsForEquipmentAsync(string equipmentId, CancellationToken ct = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
// Left-join each tag to its driver so we can tell Galaxy aliases apart while still surfacing a
|
||||
// tag whose driver row is missing (it is simply treated as a non-alias). EF can't parse the
|
||||
// TagConfig JSON in-query, so we materialise then map IsAlias/Source in memory.
|
||||
var rows = await db.Tags.AsNoTracking()
|
||||
return await db.Tags.AsNoTracking()
|
||||
.Where(t => t.EquipmentId == equipmentId)
|
||||
.OrderBy(t => t.Name)
|
||||
.GroupJoin(db.DriverInstances.AsNoTracking(), t => t.DriverInstanceId, d => d.DriverInstanceId,
|
||||
(t, ds) => new { Tag = t, Drivers = ds })
|
||||
.SelectMany(x => x.Drivers.DefaultIfEmpty(),
|
||||
(x, d) => new
|
||||
{
|
||||
x.Tag.TagId,
|
||||
x.Tag.Name,
|
||||
x.Tag.DriverInstanceId,
|
||||
x.Tag.DataType,
|
||||
x.Tag.AccessLevel,
|
||||
DriverType = d != null ? d.DriverType : null,
|
||||
x.Tag.TagConfig,
|
||||
})
|
||||
.Select(t => new EquipmentTagRow(t.TagId, t.Name, t.DriverInstanceId, t.DataType, t.AccessLevel))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return rows.Select(r =>
|
||||
{
|
||||
var isAlias = r.DriverType == "GalaxyMxGateway";
|
||||
var fullName = isAlias ? ExtractTagConfigFullName(r.TagConfig) : null;
|
||||
var source = fullName is not null ? $"galaxy:{fullName}" : null;
|
||||
return new EquipmentTagRow(r.TagId, r.Name, r.DriverInstanceId, r.DataType, r.AccessLevel, isAlias, source);
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -204,16 +180,6 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
.FirstOrDefaultAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AliasTagEditDto?> LoadAliasTagAsync(string tagId, CancellationToken ct = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
var t = await db.Tags.AsNoTracking().FirstOrDefaultAsync(x => x.TagId == tagId, ct);
|
||||
if (t is null) return null;
|
||||
return new AliasTagEditDto(t.TagId, t.Name, t.DriverInstanceId, t.DataType, t.AccessLevel,
|
||||
ExtractTagConfigFullName(t.TagConfig) ?? string.Empty, t.RowVersion);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<VirtualTagEditDto?> LoadVirtualTagAsync(string virtualTagId, CancellationToken ct = default)
|
||||
{
|
||||
@@ -774,29 +740,6 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<(string DriverInstanceId, string Display, string DriverConfig)>>
|
||||
LoadGalaxyGatewaysForEquipmentAsync(string equipmentId, CancellationToken ct = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
var cluster = await ResolveEquipmentClusterAsync(db, equipmentId, ct);
|
||||
if (cluster is null)
|
||||
{
|
||||
return Array.Empty<(string, string, string)>();
|
||||
}
|
||||
|
||||
var gateways = await db.DriverInstances
|
||||
.Where(d => d.ClusterId == cluster && d.DriverType == "GalaxyMxGateway")
|
||||
.OrderBy(d => d.DriverInstanceId)
|
||||
.Select(d => new { d.DriverInstanceId, d.Name, d.DriverConfig })
|
||||
.ToListAsync(ct);
|
||||
|
||||
return gateways
|
||||
.Select(d => (d.DriverInstanceId, Display: $"{d.DriverInstanceId} — {d.Name}", d.DriverConfig))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UnsMutationResult> CreateTagAsync(
|
||||
string equipmentId,
|
||||
@@ -903,103 +846,6 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UnsMutationResult> CreateAliasTagAsync(
|
||||
string equipmentId,
|
||||
AliasTagInput input,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
if (await db.Tags.AnyAsync(t => t.TagId == input.TagId, ct))
|
||||
{
|
||||
return new UnsMutationResult(false, $"Tag '{input.TagId}' already exists.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(input.FullName))
|
||||
{
|
||||
return new UnsMutationResult(false, "Alias is missing a Galaxy reference.");
|
||||
}
|
||||
|
||||
if (!await db.Equipment.AnyAsync(e => e.EquipmentId == equipmentId, ct))
|
||||
{
|
||||
return new UnsMutationResult(false, $"Equipment '{equipmentId}' not found.");
|
||||
}
|
||||
|
||||
var equipmentCluster = await ResolveEquipmentClusterAsync(db, equipmentId, ct);
|
||||
var guard = await CheckAliasDriverGuardAsync(db, input.DriverInstanceId, equipmentCluster, ct);
|
||||
if (guard is not null)
|
||||
{
|
||||
return guard.Value;
|
||||
}
|
||||
|
||||
if (await db.Tags.AnyAsync(t => t.EquipmentId == equipmentId && t.Name == input.Name, ct))
|
||||
{
|
||||
return new UnsMutationResult(false, $"A tag named '{input.Name}' already exists on this equipment.");
|
||||
}
|
||||
|
||||
db.Tags.Add(BuildAliasTag(equipmentId, input));
|
||||
await db.SaveChangesAsync(ct);
|
||||
return new UnsMutationResult(true, null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UnsMutationResult> UpdateAliasTagAsync(
|
||||
string tagId,
|
||||
AliasTagInput input,
|
||||
byte[] rowVersion,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
var entity = await db.Tags.FirstOrDefaultAsync(t => t.TagId == tagId, ct);
|
||||
if (entity is null)
|
||||
{
|
||||
return new UnsMutationResult(false, "Row no longer exists.");
|
||||
}
|
||||
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(input.FullName))
|
||||
{
|
||||
return new UnsMutationResult(false, "Alias is missing a Galaxy reference.");
|
||||
}
|
||||
|
||||
var equipmentCluster = await ResolveEquipmentClusterAsync(db, entity.EquipmentId, ct);
|
||||
var guard = await CheckAliasDriverGuardAsync(db, input.DriverInstanceId, equipmentCluster, ct);
|
||||
if (guard is not null)
|
||||
{
|
||||
return guard.Value;
|
||||
}
|
||||
|
||||
if (await db.Tags.AnyAsync(
|
||||
t => t.EquipmentId == entity.EquipmentId && t.Name == input.Name && t.TagId != tagId,
|
||||
ct))
|
||||
{
|
||||
return new UnsMutationResult(false, $"A tag named '{input.Name}' already exists on this equipment.");
|
||||
}
|
||||
|
||||
// EquipmentId and FolderPath (null) are preserved — alias tags are always equipment-bound.
|
||||
entity.DriverInstanceId = input.DriverInstanceId;
|
||||
entity.Name = input.Name;
|
||||
entity.DataType = input.DataType;
|
||||
entity.AccessLevel = input.AccessLevel;
|
||||
entity.FolderPath = null;
|
||||
entity.WriteIdempotent = false;
|
||||
entity.PollGroupId = null;
|
||||
entity.TagConfig = BuildAliasTagConfig(input.FullName);
|
||||
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(ct);
|
||||
return new UnsMutationResult(true, null);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
return new UnsMutationResult(false, "Another user changed this tag while you were editing.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UnsMutationResult> DeleteTagAsync(
|
||||
string tagId,
|
||||
@@ -1032,195 +878,6 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RelayConversionResult> ConvertRelayVirtualTagsToAliasesAsync(
|
||||
string? equipmentId, bool dryRun, CancellationToken ct = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
// Candidate relay virtual tags: the whole draft, or one equipment when scoped.
|
||||
var candidates = await db.VirtualTags
|
||||
.Where(v => equipmentId == null || v.EquipmentId == equipmentId)
|
||||
.OrderBy(v => v.EquipmentId).ThenBy(v => v.Name)
|
||||
.ToListAsync(ct);
|
||||
|
||||
// Source-by-id for every script a candidate references. A small map keeps the per-vtag relay
|
||||
// parse a dictionary lookup rather than a query.
|
||||
var scriptIds = candidates.Select(v => v.ScriptId).Distinct().ToList();
|
||||
var scripts = (await db.Scripts.Where(s => scriptIds.Contains(s.ScriptId)).ToListAsync(ct))
|
||||
.ToDictionary(s => s.ScriptId, s => s, StringComparer.Ordinal);
|
||||
|
||||
// Pre-load all existing (EquipmentId, Name) tag pairs so per-candidate name-collision checks
|
||||
// are O(1) HashSet lookups rather than per-row DB queries. The set reflects the state BEFORE
|
||||
// this batch; intra-batch alias-vs-alias collisions per equipment cannot occur because vtag
|
||||
// names are unique within an equipment's VirtualTags table.
|
||||
var existingTagNames = (await db.Tags
|
||||
.Where(t => t.EquipmentId != null)
|
||||
.Select(t => new { t.EquipmentId, t.Name })
|
||||
.ToListAsync(ct))
|
||||
.Select(t => new EquipmentName(t.EquipmentId!, t.Name))
|
||||
.ToHashSet(EquipmentNameComparer.Instance);
|
||||
|
||||
var converted = new List<RelayConversionItem>();
|
||||
var skipped = new List<RelayConversionSkip>();
|
||||
var aliasTagsToAdd = new List<Tag>();
|
||||
var toRemoveVtags = new List<VirtualTag>();
|
||||
|
||||
// Per-equipment caches so a sweep resolves each equipment's cluster, gateway, and (lazily) its
|
||||
// derivable {{equip}} base only once.
|
||||
var clusterCache = new Dictionary<string, string?>(StringComparer.Ordinal);
|
||||
var gatewayCache = new Dictionary<string, string?>(StringComparer.Ordinal);
|
||||
var equipBaseCache = new Dictionary<string, string?>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var vtag in candidates)
|
||||
{
|
||||
var source = scripts.GetValueOrDefault(vtag.ScriptId)?.SourceCode;
|
||||
if (!EquipmentScriptPaths.TryParseRelayBody(source, out var rawRef) || rawRef is null)
|
||||
{
|
||||
skipped.Add(new RelayConversionSkip(vtag.EquipmentId, vtag.Name,
|
||||
"Script body is not a pure relay (return ctx.GetTag(\"…\").Value;) — convert manually."));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!vtag.Enabled)
|
||||
{
|
||||
skipped.Add(new RelayConversionSkip(vtag.EquipmentId, vtag.Name,
|
||||
"Virtual tag is disabled — convert manually if intended."));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (vtag.Historize)
|
||||
{
|
||||
skipped.Add(new RelayConversionSkip(vtag.EquipmentId, vtag.Name,
|
||||
"Virtual tag is historized — a Tag has no historize column; convert manually."));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve (and cache) the equipment's cluster and its first Galaxy gateway.
|
||||
if (!gatewayCache.TryGetValue(vtag.EquipmentId, out var gatewayId))
|
||||
{
|
||||
if (!clusterCache.TryGetValue(vtag.EquipmentId, out var cluster))
|
||||
{
|
||||
cluster = await ResolveEquipmentClusterAsync(db, vtag.EquipmentId, ct);
|
||||
clusterCache[vtag.EquipmentId] = cluster;
|
||||
}
|
||||
|
||||
gatewayId = cluster is null
|
||||
? null
|
||||
: await db.DriverInstances
|
||||
.Where(d => d.ClusterId == cluster && d.DriverType == "GalaxyMxGateway")
|
||||
.OrderBy(d => d.DriverInstanceId)
|
||||
.Select(d => d.DriverInstanceId)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
gatewayCache[vtag.EquipmentId] = gatewayId;
|
||||
}
|
||||
|
||||
if (gatewayId is null)
|
||||
{
|
||||
skipped.Add(new RelayConversionSkip(vtag.EquipmentId, vtag.Name,
|
||||
"There is no Galaxy gateway in this equipment's cluster to bind the alias to."));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve the alias FullName, expanding the {{equip}} token against the equipment's derivable
|
||||
// tag base (its existing Galaxy-alias tag FullNames) when present.
|
||||
string fullName;
|
||||
if (EquipmentScriptPaths.ContainsEquipToken(rawRef))
|
||||
{
|
||||
if (!equipBaseCache.TryGetValue(vtag.EquipmentId, out var equipBase))
|
||||
{
|
||||
var configs = await db.Tags
|
||||
.Where(t => t.EquipmentId == vtag.EquipmentId && t.DriverInstanceId == gatewayId)
|
||||
.Select(t => t.TagConfig)
|
||||
.ToListAsync(ct);
|
||||
equipBase = EquipmentScriptPaths.DeriveEquipmentBase(configs.Select(ExtractTagConfigFullName));
|
||||
equipBaseCache[vtag.EquipmentId] = equipBase;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(equipBase))
|
||||
{
|
||||
skipped.Add(new RelayConversionSkip(vtag.EquipmentId, vtag.Name,
|
||||
$"Relay uses the equipment-relative {EquipmentScriptPaths.EquipToken} token but the equipment "
|
||||
+ "has no derivable tag base (add at least one Galaxy alias tag first)."));
|
||||
continue;
|
||||
}
|
||||
|
||||
var expandedSource = EquipmentScriptPaths.SubstituteEquipmentToken(source!, equipBase);
|
||||
if (!EquipmentScriptPaths.TryParseRelayBody(expandedSource, out var concrete) || concrete is null)
|
||||
{
|
||||
skipped.Add(new RelayConversionSkip(vtag.EquipmentId, vtag.Name,
|
||||
$"Could not expand the {EquipmentScriptPaths.EquipToken} token into a concrete Galaxy reference."));
|
||||
continue;
|
||||
}
|
||||
|
||||
fullName = concrete;
|
||||
}
|
||||
else
|
||||
{
|
||||
fullName = rawRef;
|
||||
}
|
||||
|
||||
// Check for a name collision with an already-existing Tag on this equipment. If a Tag with
|
||||
// the same (EquipmentId, Name) exists, converting would violate the unique index and abort
|
||||
// the whole SaveChangesAsync — skip this vtag and report it instead.
|
||||
if (existingTagNames.Contains(new EquipmentName(vtag.EquipmentId, vtag.Name)))
|
||||
{
|
||||
skipped.Add(new RelayConversionSkip(vtag.EquipmentId, vtag.Name,
|
||||
$"A tag named '{vtag.Name}' already exists on this equipment — convert manually if intended."));
|
||||
continue;
|
||||
}
|
||||
|
||||
converted.Add(new RelayConversionItem(vtag.EquipmentId, vtag.Name, fullName, vtag.DataType));
|
||||
|
||||
if (!dryRun)
|
||||
{
|
||||
aliasTagsToAdd.Add(BuildAliasTag(
|
||||
vtag.EquipmentId,
|
||||
new AliasTagInput(NewTagId(), vtag.Name, gatewayId, vtag.DataType, TagAccessLevel.Read, fullName)));
|
||||
toRemoveVtags.Add(vtag);
|
||||
}
|
||||
}
|
||||
|
||||
if (!dryRun && toRemoveVtags.Count > 0)
|
||||
{
|
||||
// Snapshot every (VirtualTagId, ScriptId) pair in the whole draft BEFORE staging removals, so
|
||||
// the orphan check sees a stable view (EF still reports a RemoveRange'd entity as present in a
|
||||
// LINQ-to-entities query until SaveChanges). A script stays alive if any virtual tag NOT being
|
||||
// removed still binds it, or any ScriptedAlarm uses it as a predicate.
|
||||
var allVtagPairs = await db.VirtualTags
|
||||
.Select(v => new { v.VirtualTagId, v.ScriptId })
|
||||
.ToListAsync(ct);
|
||||
var alarmScriptIds = (await db.ScriptedAlarms.Select(a => a.PredicateScriptId).ToListAsync(ct))
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
var removedVtagIds = toRemoveVtags.Select(v => v.VirtualTagId).ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
db.Tags.AddRange(aliasTagsToAdd);
|
||||
db.VirtualTags.RemoveRange(toRemoveVtags);
|
||||
|
||||
foreach (var scriptId in toRemoveVtags.Select(v => v.ScriptId).Distinct(StringComparer.Ordinal))
|
||||
{
|
||||
var stillUsedByVtag = allVtagPairs.Any(p => p.ScriptId == scriptId && !removedVtagIds.Contains(p.VirtualTagId));
|
||||
var stillUsedByAlarm = alarmScriptIds.Contains(scriptId);
|
||||
if (!stillUsedByVtag && !stillUsedByAlarm && scripts.TryGetValue(scriptId, out var s))
|
||||
{
|
||||
db.Scripts.Remove(s);
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
return new RelayConversionResult(converted, skipped, Applied: !dryRun);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mints a unique alias <c>TagId</c> following the codebase's id-minting convention —
|
||||
/// <c>"<prefix>-" + Guid.ToString("N")[..12]</c> (decision #125). A collision with another
|
||||
/// in-flight id is negligible given the 48-bit GUID prefix; the <c>TagId</c> unique index is the
|
||||
/// backstop.
|
||||
/// </summary>
|
||||
private static string NewTagId() => $"TAG-{Guid.NewGuid().ToString("N")[..12]}";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<(string ScriptId, string Display)>> LoadScriptsAsync(CancellationToken ct = default)
|
||||
{
|
||||
@@ -1496,32 +1153,6 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the <c>FullName</c> string from a tag's <c>TagConfig</c> JSON (the Galaxy reference an
|
||||
/// alias surfaces), or <c>null</c> when the config is empty, not a JSON object, lacks a string
|
||||
/// <c>FullName</c>, or is malformed. A small local copy mirrors the composer's own extraction —
|
||||
/// consistent with this codebase, where the composer and validator each keep their own.
|
||||
/// </summary>
|
||||
private static string? ExtractTagConfigFullName(string? tagConfig)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tagConfig))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(tagConfig);
|
||||
return doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Object
|
||||
&& doc.RootElement.TryGetProperty("FullName", out var fn)
|
||||
&& fn.ValueKind == System.Text.Json.JsonValueKind.String ? fn.GetString() : null;
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an equipment to its cluster via <c>Equipment.UnsLineId → UnsLine.UnsAreaId →
|
||||
/// UnsArea.ClusterId</c>. Returns <c>null</c> when the equipment, its line, or its area cannot be
|
||||
@@ -1586,89 +1217,6 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Galaxy-aware driver guard for aliases: the driver must be a Galaxy gateway
|
||||
/// (<c>DriverType == "GalaxyMxGateway"</c>) in the equipment's cluster. Distinct from
|
||||
/// <see cref="CheckTagDriverGuardAsync"/>, which requires an Equipment-kind namespace and would reject
|
||||
/// the gateway. Returns <c>null</c> when the binding is allowed, or a populated failure otherwise.
|
||||
/// </summary>
|
||||
private static async Task<UnsMutationResult?> CheckAliasDriverGuardAsync(
|
||||
OtOpcUaConfigDbContext db,
|
||||
string driverInstanceId,
|
||||
string? equipmentCluster,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(driverInstanceId))
|
||||
return new UnsMutationResult(false, "An alias must be bound to a Galaxy gateway.");
|
||||
|
||||
var driver = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct);
|
||||
if (driver is null)
|
||||
{
|
||||
return new UnsMutationResult(false, $"Driver '{driverInstanceId}' not found.");
|
||||
}
|
||||
|
||||
if (driver.DriverType != "GalaxyMxGateway")
|
||||
{
|
||||
return new UnsMutationResult(false, $"Driver '{driverInstanceId}' is not a Galaxy gateway.");
|
||||
}
|
||||
|
||||
if (driver.ClusterId != equipmentCluster)
|
||||
{
|
||||
return new UnsMutationResult(
|
||||
false,
|
||||
$"Galaxy gateway '{driverInstanceId}' is in cluster '{driver.ClusterId}' but the equipment is in cluster '{equipmentCluster}'.");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serialises a Galaxy reference into the alias TagConfig envelope <c>{"FullName":"<ref>"}</c>.
|
||||
/// </summary>
|
||||
private static string BuildAliasTagConfig(string fullName) =>
|
||||
System.Text.Json.JsonSerializer.Serialize(new Dictionary<string, string> { ["FullName"] = fullName });
|
||||
|
||||
/// <summary>
|
||||
/// Builds the alias <see cref="Tag"/> entity for an equipment: a Galaxy-gateway-bound, equipment-scoped
|
||||
/// tag with a null <c>FolderPath</c>, no poll group, non-idempotent writes, and the
|
||||
/// <c>{"FullName":…}</c> TagConfig. A pure builder — reused by the relay→alias converter so create and
|
||||
/// conversion produce byte-identical rows.
|
||||
/// </summary>
|
||||
private static Tag BuildAliasTag(string equipmentId, AliasTagInput input) => new()
|
||||
{
|
||||
TagId = input.TagId,
|
||||
DriverInstanceId = input.DriverInstanceId,
|
||||
EquipmentId = equipmentId,
|
||||
Name = input.Name,
|
||||
FolderPath = null,
|
||||
DataType = input.DataType,
|
||||
AccessLevel = input.AccessLevel,
|
||||
WriteIdempotent = false,
|
||||
PollGroupId = null,
|
||||
TagConfig = BuildAliasTagConfig(input.FullName),
|
||||
};
|
||||
|
||||
/// <summary>Lightweight key for the pre-loaded (EquipmentId, Name) tag-name collision set.</summary>
|
||||
private readonly record struct EquipmentName(string EquipmentId, string Name);
|
||||
|
||||
/// <summary>
|
||||
/// Equality comparer for <see cref="EquipmentName"/> using ordinal string comparison on both fields,
|
||||
/// matching the database unique index semantics for <c>(EquipmentId, Name)</c>.
|
||||
/// </summary>
|
||||
private sealed class EquipmentNameComparer : IEqualityComparer<EquipmentName>
|
||||
{
|
||||
public static readonly EquipmentNameComparer Instance = new();
|
||||
|
||||
public bool Equals(EquipmentName x, EquipmentName y) =>
|
||||
StringComparer.Ordinal.Equals(x.EquipmentId, y.EquipmentId) &&
|
||||
StringComparer.Ordinal.Equals(x.Name, y.Name);
|
||||
|
||||
public int GetHashCode(EquipmentName obj) =>
|
||||
HashCode.Combine(
|
||||
StringComparer.Ordinal.GetHashCode(obj.EquipmentId),
|
||||
StringComparer.Ordinal.GetHashCode(obj.Name));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decision #122: an equipment may only bind to a driver in the same cluster as its line.
|
||||
/// Policy:
|
||||
|
||||
Reference in New Issue
Block a user