Files
lmxopcua/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/EquipmentNamespaceContentLoader.cs
Joseph Doherty a29828e41e 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>
2026-04-20 03:19:45 -04:00

87 lines
4.0 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}