EquipmentNamespaceContentLoader — Config-DB loader that fills the (driverInstanceId, generationId) shape the walker wire-in from PR #155 consumes. Narrow follow-up to PR #155: the ctor plumbing on OpcUaApplicationHost already takes a Func<string, EquipmentNamespaceContent?>? lookup; this PR lands the loader that will back that lookup against the central Config DB at SealedBootstrap time. DI composition in Program.cs is a separate structural PR because it needs the generation-resolve chain restructured to run before OpcUaApplicationHost construction — this one just lands the loader + unit tests so the wiring PR reduces to one factory lambda. Loader scope is one driver instance at one generation: joins Equipment filtered by (DriverInstanceId == driver, GenerationId == gen, Enabled) first, then UnsLines reachable from those Equipment rows, then UnsAreas reachable from those lines, then Tags filtered by (DriverInstanceId == driver, GenerationId == gen). Returns null when the driver has no Equipment at the supplied generation — the wire-in's null-check treats that as "skip the walker; let DiscoverAsync own the whole address space" which is the correct backward-compat behavior for non-Equipment-kind drivers (Modbus / AB CIP / TwinCAT / FOCAS whose namespace-kind is native per decisions #116-#121). Only loads the UNS branches that actually host this driver's Equipment — skips pulling unrelated UNS folders from other drivers' regions of the cluster by deriving lineIds/areaIds from the filtered Equipment set rather than reloading the full UNS tree. Enabled=false Equipment are skipped at the query level so a decommissioned machine doesn't produce a phantom browse folder — Admin still sees it in the diff view via the regular Config-DB queries but the walker's browse output reflects the operational fleet. AsNoTracking on every query because the bootstrap flow is read-only + the result is handed off to a pure-function walker immediately; change tracking would pin rows in the DbContext for the full server lifetime with no corresponding write path. Five new EquipmentNamespaceContentLoaderTests using InMemoryDatabase: (a) null result when driver has no Equipment; (b) baseline happy-path loads the full shape correctly; (c) other driver's rows at the same generation don't leak into this driver's result (per-driver scope contract); (d) same-driver rows at a different generation are skipped (per-generation scope contract per decision #148); (e) Enabled=false Equipment are skipped. Server project builds 0 errors; Server.Tests 186/186 (was 181, +5 new loader tests). Once the wiring PR lands the factory lambda in Program.cs the loader closes over the SealedBootstrap-resolved generationId + the lookup delegate delegates to LoadAsync via IServiceScopeFactory — a one-line composition, no ctor-signature churn on OpcUaApplicationHost because PR #155 already established the seam.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Loads the <see cref="EquipmentNamespaceContent"/> snapshot the
|
||||
/// <see cref="EquipmentNodeWalker"/> consumes, scoped to a single
|
||||
/// (driverInstanceId, generationId) pair. Joins the four row sets the walker expects:
|
||||
/// UnsAreas for the driver's cluster, UnsLines under those areas, Equipment bound to
|
||||
/// this driver + its lines, and Tags bound to this driver + its equipment — all at the
|
||||
/// supplied generation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>The walker is driver-instance-scoped (decisions #116–#121 put the UNS in the
|
||||
/// Equipment-kind namespace owned by one driver instance at a time), so this loader is
|
||||
/// too — a single call returns one driver's worth of rows, never the whole fleet.</para>
|
||||
///
|
||||
/// <para>Returns <c>null</c> when the driver instance has no Equipment rows at the
|
||||
/// supplied generation. The wire-in in <see cref="OpcUaApplicationHost"/> treats null as
|
||||
/// "this driver has no UNS content, skip the walker and let DiscoverAsync own the whole
|
||||
/// address space" — the backward-compat path for drivers whose namespace kind is not
|
||||
/// Equipment (Modbus / AB CIP / TwinCAT / FOCAS).</para>
|
||||
/// </remarks>
|
||||
public sealed class EquipmentNamespaceContentLoader
|
||||
{
|
||||
private readonly OtOpcUaConfigDbContext _db;
|
||||
|
||||
public EquipmentNamespaceContentLoader(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load the walker-shaped snapshot for <paramref name="driverInstanceId"/> at
|
||||
/// <paramref name="generationId"/>. Returns <c>null</c> when the driver has no
|
||||
/// Equipment rows at that generation.
|
||||
/// </summary>
|
||||
public async Task<EquipmentNamespaceContent?> LoadAsync(
|
||||
string driverInstanceId, long generationId, CancellationToken ct)
|
||||
{
|
||||
var equipment = await _db.Equipment
|
||||
.AsNoTracking()
|
||||
.Where(e => e.DriverInstanceId == driverInstanceId && e.GenerationId == generationId && e.Enabled)
|
||||
.ToListAsync(ct).ConfigureAwait(false);
|
||||
|
||||
if (equipment.Count == 0)
|
||||
return null;
|
||||
|
||||
// Filter UNS tree to only the lines + areas that host at least one Equipment bound to
|
||||
// this driver — skips loading unrelated UNS branches from the cluster. LinesByArea
|
||||
// grouping is driven off the Equipment rows so an empty line (no equipment) doesn't
|
||||
// pull a pointless folder into the walker output.
|
||||
var lineIds = equipment.Select(e => e.UnsLineId).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
|
||||
var lines = await _db.UnsLines
|
||||
.AsNoTracking()
|
||||
.Where(l => l.GenerationId == generationId && lineIds.Contains(l.UnsLineId))
|
||||
.ToListAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var areaIds = lines.Select(l => l.UnsAreaId).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
|
||||
var areas = await _db.UnsAreas
|
||||
.AsNoTracking()
|
||||
.Where(a => a.GenerationId == generationId && areaIds.Contains(a.UnsAreaId))
|
||||
.ToListAsync(ct).ConfigureAwait(false);
|
||||
|
||||
// Tags belonging to this driver at this generation. Walker skips Tags with null
|
||||
// EquipmentId (those are SystemPlatform-kind Galaxy tags per decision #120) but we
|
||||
// load them anyway so the same rowset can drive future non-Equipment-kind walks
|
||||
// without re-hitting the DB. Filtering here is a future optimization; today the
|
||||
// per-tag cost is bounded by driver scope.
|
||||
var tags = await _db.Tags
|
||||
.AsNoTracking()
|
||||
.Where(t => t.DriverInstanceId == driverInstanceId && t.GenerationId == generationId)
|
||||
.ToListAsync(ct).ConfigureAwait(false);
|
||||
|
||||
return new EquipmentNamespaceContent(
|
||||
Areas: areas,
|
||||
Lines: lines,
|
||||
Equipment: equipment,
|
||||
Tags: tags);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user