feat(config): make Equipment.DriverInstanceId nullable + driver-less AdminUI support + migration

This commit is contained in:
Joseph Doherty
2026-06-08 06:49:28 -04:00
parent a94d03a194
commit d2dbf7b0d7
8 changed files with 1823 additions and 9 deletions
@@ -106,6 +106,15 @@ In `cmd_populate_equipment`:
become no-ops; remove for clarity. Keep the `Namespace` INSERT/teardown.
- Fix the stale `# … "an Equipment namespace has a driver" expectations` comment.
### AdminUI — two production derefs (found at build time, not by the grep sweep)
Making the column nullable surfaced two `.razor` sites the impact grep missed (caught by `TreatWarningsAsErrors`):
- `Components/Pages/Clusters/TagEdit.razor:191``db.Equipment.Where(e => driverIds.Contains(e.DriverInstanceId))` (CS8604).
Behavior-preserving fix: guard `e.DriverInstanceId != null && …` (SQL already excludes NULL from an `IN` set, so this only satisfies the compiler).
- `Components/Pages/Clusters/EquipmentEdit.razor` — the equipment editor loads `DriverInstanceId` into a non-null
`FormModel` (line 183, CS8601) and **mandates** a driver on save (`"Pick a driver instance."`). Decision: give it
**full driver-less support**`FormModel.DriverInstanceId``string?`, add a "(none / driver-less)" option to the
driver dropdown, relax the mandatory-driver validation, and persist NULL when none is selected (normalize empty → null).
### Noted, not changing (YAGNI)
- `sp_ComputeGenerationDiff` includes `DriverInstanceId` in a `CHECKSUM(...)`. It is NULL-tolerant for
this one-time transition and sits on the **dormant** generation-diff path (the active deploy gate is
@@ -20,9 +20,15 @@
**Files:**
- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Equipment.cs` (line ~23)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/TagEdit.razor` (~line 191 — null-guard the EF predicate)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/EquipmentEdit.razor` (FormModel + dropdown + save — full driver-less support)
- Create: `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/<timestamp>_NullableEquipmentDriverInstanceId.cs` (generated by `dotnet ef`)
- Auto-modify: `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs` (regenerated by `dotnet ef`)
**AdminUI surface (found at build time — the nullable change breaks two `.razor` sites under `TreatWarningsAsErrors`):**
- `TagEdit.razor:191`: `db.Equipment.Where(e => driverIds.Contains(e.DriverInstanceId))` → guard `e.DriverInstanceId != null && driverIds.Contains(e.DriverInstanceId)` (behavior-preserving; SQL already excludes NULL).
- `EquipmentEdit.razor`: full driver-less support — `FormModel.DriverInstanceId``string?`; add a "(none / driver-less)" option to the driver `<select>`; remove the mandatory `if (string.IsNullOrEmpty(_form.DriverInstanceId)) { _error = "Pick a driver instance."; return; }` check (~line 209); on save (both the IsNew `Add` ~line 222 and the update ~line 245) write `string.IsNullOrWhiteSpace(_form.DriverInstanceId) ? null : _form.DriverInstanceId`.
**Context:** `DriverInstanceId` is currently `public required string DriverInstanceId { get; set; }`. The EF config `OtOpcUaConfigDbContext.ConfigureEquipment` has **no `.IsRequired()`** call and **no FK relationship** for it (it's a logical FK only) — so EF infers NOT NULL purely from the C# `required string` type. Changing the type to `string?` flips the column to nullable; no fluent-config change is needed. The index `IX_Equipment_Driver` is a plain non-unique index and stays valid on a nullable column.
**Step 1 — Change the entity property.** In `Equipment.cs`, change:
@@ -19,8 +19,11 @@ public sealed class Equipment
/// <summary>UUIDv4, IMMUTABLE across all generations of the same EquipmentId. Downstream-consumer join key.</summary>
public Guid EquipmentUuid { get; set; }
/// <summary>Logical FK to the driver providing data for this equipment.</summary>
public required string DriverInstanceId { get; set; }
/// <summary>
/// Optional logical FK to the driver providing data for this equipment.
/// <c>null</c> = VirtualTag-only / driver-less equipment (no field driver).
/// </summary>
public string? DriverInstanceId { get; set; }
/// <summary>Optional logical FK to a multi-device driver's device.</summary>
public string? DeviceId { get; set; }
@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <inheritdoc />
public partial class NullableEquipmentDriverInstanceId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "DriverInstanceId",
table: "Equipment",
type: "nvarchar(64)",
maxLength: 64,
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(64)",
oldMaxLength: 64);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "DriverInstanceId",
table: "Equipment",
type: "nvarchar(64)",
maxLength: 64,
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "nvarchar(64)",
oldMaxLength: 64,
oldNullable: true);
}
}
}
@@ -546,7 +546,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
.HasColumnType("nvarchar(512)");
b.Property<string>("DriverInstanceId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
@@ -72,7 +72,7 @@ else
<div class="col-md-6 mb-3">
<label class="form-label" for="driver">Driver instance</label>
<InputSelect id="driver" @bind-Value="_form.DriverInstanceId" class="form-select form-select-sm">
<option value="">— pick a driver</option>
<option value="">(none / driver-less)</option>
@foreach (var d in _drivers)
{
<option value="@d.DriverInstanceId">@d.DriverInstanceId &mdash; @d.Name (@d.DriverType)</option>
@@ -206,7 +206,6 @@ else
try
{
if (string.IsNullOrEmpty(_form.UnsLineId)) { _error = "Pick a UNS line."; return; }
if (string.IsNullOrEmpty(_form.DriverInstanceId)) { _error = "Pick a driver instance."; return; }
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
@@ -219,7 +218,7 @@ else
{
EquipmentId = equipmentId,
EquipmentUuid = uuid,
DriverInstanceId = _form.DriverInstanceId,
DriverInstanceId = string.IsNullOrWhiteSpace(_form.DriverInstanceId) ? null : _form.DriverInstanceId,
UnsLineId = _form.UnsLineId,
Name = _form.Name,
MachineCode = _form.MachineCode,
@@ -242,7 +241,7 @@ else
var entity = await db.Equipment.FirstOrDefaultAsync(e => e.EquipmentId == EquipmentId);
if (entity is null) { _error = "Row no longer exists."; return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
entity.DriverInstanceId = _form.DriverInstanceId;
entity.DriverInstanceId = string.IsNullOrWhiteSpace(_form.DriverInstanceId) ? null : _form.DriverInstanceId;
entity.UnsLineId = _form.UnsLineId;
entity.Name = _form.Name;
entity.MachineCode = _form.MachineCode;
@@ -292,7 +291,7 @@ else
public string Name { get; set; } = "";
[Required] public string MachineCode { get; set; } = "";
[Required] public string UnsLineId { get; set; } = "";
[Required] public string DriverInstanceId { get; set; } = "";
public string? DriverInstanceId { get; set; }
public string? ZTag { get; set; }
public string? SAPID { get; set; }
public string? Manufacturer { get; set; }
@@ -188,7 +188,7 @@ else
d => nsById.TryGetValue(d.NamespaceId, out var ns) ? ns : namespaces.First());
var driverIds = _drivers.Select(d => d.DriverInstanceId).ToHashSet();
_equipment = await db.Equipment.AsNoTracking()
.Where(e => driverIds.Contains(e.DriverInstanceId))
.Where(e => e.DriverInstanceId != null && driverIds.Contains(e.DriverInstanceId))
.OrderBy(e => e.MachineCode)
.ToListAsync();