v3 batch1 WP1: greenfield DbContext + V3Initial squash + seeds + tests

DbContext (OtOpcUaConfigDbContext):
- Drop Namespace/EquipmentImportBatch/EquipmentImportRow DbSets + Configure methods.
- Add RawFolder/TagGroup/UnsTagReference DbSets + Configure methods with sibling-name
  filtered unique indexes over nullable parents (two-filtered pattern per level).
- Reshape DriverInstance (RawFolderId + UX_DriverInstance_Folder_Name/_ClusterRoot_Name),
  Tag (required DeviceId, nullable TagGroupId, UX_Tag_Group_Name/_Device_Name, IX_Tag_Device),
  Equipment (drop DriverInstanceId/DeviceId + IX_Equipment_Driver), Device (UX_Device_Driver_Name).

Migration squash -> V3Initial:
- Delete the whole Migrations/ folder; scaffold one V3Initial (applies clean to a fresh DB).
- Port the hand-written procs + AuthorizationGrants into V3Initial.StoredProcedures.cs. The v1
  generation-model procs (ConfigGeneration/GenerationId, dead since V2HostingAlignment) are
  rewritten as v3-schema-valid retirement stubs that preserve the two-role EXECUTE-grant surface
  + AuthorizationTests behaviors; sp_ComputeGenerationDiff now references the v3 tables;
  sp_ReleaseExternalIdReservation ported verbatim.

Compile-fixes for the build gate (Wave-C-owned, minimal + TODO(v3 WP4) markers):
- DraftSnapshot/DraftSnapshotFactory: drop the retired Namespaces list.
- DraftValidator: delete namespace-binding + Galaxy-FullName rules; collision rule now VirtualTag-only.

Seeds -> v3 schema:
- seed-clusters.sql summary SELECTs; all 5 scripts/smoke/seed-*.sql rewritten (DriverInstance
  RawFolderId, Device+DeviceConfig endpoint, Tag DeviceId, UnsTagReference; no ConfigGeneration/
  Namespace/sp_PublishGeneration). Verified: all seeds apply clean against a fresh V3Initial DB.

Tests (Configuration.Tests, 107 pass):
- SchemaComplianceTests rewritten to v3 tables + filtered indexes.
- New RawSchemaIntegrityTests: DB-enforced sibling-name uniqueness per raw level + UnsTagReference
  (Equipment,Tag) uniqueness.
- Delete DraftValidatorGalaxyFullNameCorpusTests (WP2/WP6 replaces); fix DraftValidatorTests +
  DraftSnapshotFactoryTests to v3 shapes; repoint scripting-migration existence test to V3Initial.
This commit is contained in:
Joseph Doherty
2026-07-15 19:19:21 -04:00
parent cb720bb8c3
commit 4d39b98564
54 changed files with 2523 additions and 30115 deletions
@@ -18,8 +18,12 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
public DbSet<ClusterNode> ClusterNodes => Set<ClusterNode>();
/// <summary>Gets the DbSet of cluster node credentials.</summary>
public DbSet<ClusterNodeCredential> ClusterNodeCredentials => Set<ClusterNodeCredential>();
/// <summary>Gets the DbSet of namespaces.</summary>
public DbSet<Namespace> Namespaces => Set<Namespace>();
/// <summary>Gets the DbSet of v3 Raw-tree driver-organizing folders.</summary>
public DbSet<RawFolder> RawFolders => Set<RawFolder>();
/// <summary>Gets the DbSet of v3 Raw-tree tag groups (nestable under a device).</summary>
public DbSet<TagGroup> TagGroups => Set<TagGroup>();
/// <summary>Gets the DbSet of v3 UNS tag references (equipment → raw tag projection).</summary>
public DbSet<UnsTagReference> UnsTagReferences => Set<UnsTagReference>();
/// <summary>Gets the DbSet of UNS areas.</summary>
public DbSet<UnsArea> UnsAreas => Set<UnsArea>();
/// <summary>Gets the DbSet of UNS lines.</summary>
@@ -46,10 +50,6 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
public DbSet<DriverInstanceResilienceStatus> DriverInstanceResilienceStatuses => Set<DriverInstanceResilienceStatus>();
/// <summary>Gets the DbSet of LDAP group role mappings.</summary>
public DbSet<LdapGroupRoleMapping> LdapGroupRoleMappings => Set<LdapGroupRoleMapping>();
/// <summary>Gets the DbSet of equipment import batches.</summary>
public DbSet<EquipmentImportBatch> EquipmentImportBatches => Set<EquipmentImportBatch>();
/// <summary>Gets the DbSet of equipment import rows.</summary>
public DbSet<EquipmentImportRow> EquipmentImportRows => Set<EquipmentImportRow>();
/// <summary>Gets the DbSet of scripts.</summary>
public DbSet<Script> Scripts => Set<Script>();
/// <summary>Gets the DbSet of virtual tags.</summary>
@@ -79,13 +79,15 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
ConfigureServerCluster(modelBuilder);
ConfigureClusterNode(modelBuilder);
ConfigureClusterNodeCredential(modelBuilder);
ConfigureNamespace(modelBuilder);
ConfigureRawFolder(modelBuilder);
ConfigureUnsArea(modelBuilder);
ConfigureUnsLine(modelBuilder);
ConfigureDriverInstance(modelBuilder);
ConfigureDevice(modelBuilder);
ConfigureTagGroup(modelBuilder);
ConfigureEquipment(modelBuilder);
ConfigureTag(modelBuilder);
ConfigureUnsTagReference(modelBuilder);
ConfigurePollGroup(modelBuilder);
ConfigureNodeAcl(modelBuilder);
ConfigureConfigAuditLog(modelBuilder);
@@ -93,7 +95,6 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
ConfigureDriverHostStatus(modelBuilder);
ConfigureDriverInstanceResilienceStatus(modelBuilder);
ConfigureLdapGroupRoleMapping(modelBuilder);
ConfigureEquipmentImportBatch(modelBuilder);
ConfigureScript(modelBuilder);
ConfigureVirtualTag(modelBuilder);
ConfigureScriptedAlarm(modelBuilder);
@@ -183,32 +184,39 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
});
}
private static void ConfigureNamespace(ModelBuilder modelBuilder)
private static void ConfigureRawFolder(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Namespace>(e =>
modelBuilder.Entity<RawFolder>(e =>
{
e.ToTable("Namespace");
e.HasKey(x => x.NamespaceRowId);
e.Property(x => x.NamespaceRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.NamespaceId).HasMaxLength(64);
e.ToTable("RawFolder");
e.HasKey(x => x.RawFolderRowId);
e.Property(x => x.RawFolderRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.RawFolderId).HasMaxLength(64);
e.Property(x => x.ClusterId).HasMaxLength(64);
e.Property(x => x.Kind).HasConversion<string>().HasMaxLength(32);
e.Property(x => x.NamespaceUri).HasMaxLength(256);
e.Property(x => x.Notes).HasMaxLength(1024);
e.Property(x => x.ParentRawFolderId).HasMaxLength(64);
e.Property(x => x.Name).HasMaxLength(128);
e.Property(x => x.RowVersion).IsRowVersion();
e.HasOne(x => x.Cluster).WithMany(c => c.Namespaces)
// Cluster-rooted. Restrict so a cluster with folders cannot be deleted out from under
// them (matches the OnDelete(Restrict) convention across the config schema). Child FKs
// (DriverInstance.RawFolderId, RawFolder.ParentRawFolderId) are logical-only, so
// non-empty-folder-delete is enforced at the service layer in later batches.
e.HasOne<ServerCluster>().WithMany(c => c.RawFolders)
.HasForeignKey(x => x.ClusterId)
.OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.ClusterId, x.Kind }).IsUnique()
.HasDatabaseName("UX_Namespace_Cluster_Kind");
e.HasIndex(x => x.NamespaceUri).IsUnique()
.HasDatabaseName("UX_Namespace_NamespaceUri");
e.HasIndex(x => x.NamespaceId).IsUnique()
.HasDatabaseName("UX_Namespace_LogicalId");
e.HasIndex(x => x.ClusterId)
.HasDatabaseName("IX_Namespace_Cluster");
e.HasIndex(x => x.RawFolderId).IsUnique().HasDatabaseName("UX_RawFolder_LogicalId");
e.HasIndex(x => x.ClusterId).HasDatabaseName("IX_RawFolder_Cluster");
// Sibling-name uniqueness. Two filtered indexes cover the nullable ParentRawFolderId:
// one for nested folders (Parent NOT NULL), one for cluster-root folders (Parent NULL),
// mirroring the Tag filtered-index convention.
e.HasIndex(x => new { x.ClusterId, x.ParentRawFolderId, x.Name }).IsUnique()
.HasFilter("[ParentRawFolderId] IS NOT NULL")
.HasDatabaseName("UX_RawFolder_Parent_Name");
e.HasIndex(x => new { x.ClusterId, x.Name }).IsUnique()
.HasFilter("[ParentRawFolderId] IS NULL")
.HasDatabaseName("UX_RawFolder_ClusterRoot_Name");
});
}
@@ -267,7 +275,7 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
e.Property(x => x.DriverInstanceRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.DriverInstanceId).HasMaxLength(64);
e.Property(x => x.ClusterId).HasMaxLength(64);
e.Property(x => x.NamespaceId).HasMaxLength(64);
e.Property(x => x.RawFolderId).HasMaxLength(64);
e.Property(x => x.Name).HasMaxLength(128);
e.Property(x => x.DriverType).HasMaxLength(32);
e.Property(x => x.DriverConfig).HasColumnType("nvarchar(max)");
@@ -277,8 +285,18 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
e.HasOne(x => x.Cluster).WithMany().HasForeignKey(x => x.ClusterId).OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => x.ClusterId).HasDatabaseName("IX_DriverInstance_Cluster");
e.HasIndex(x => x.NamespaceId).HasDatabaseName("IX_DriverInstance_Namespace");
e.HasIndex(x => x.RawFolderId).HasDatabaseName("IX_DriverInstance_RawFolder");
e.HasIndex(x => x.DriverInstanceId).IsUnique().HasDatabaseName("UX_DriverInstance_LogicalId");
// v3 sibling-name uniqueness (a RawPath segment). Two filtered indexes cover the nullable
// RawFolderId: nested drivers keyed by (ClusterId, RawFolderId, Name), cluster-root drivers
// by (ClusterId, Name).
e.HasIndex(x => new { x.ClusterId, x.RawFolderId, x.Name }).IsUnique()
.HasFilter("[RawFolderId] IS NOT NULL")
.HasDatabaseName("UX_DriverInstance_Folder_Name");
e.HasIndex(x => new { x.ClusterId, x.Name }).IsUnique()
.HasFilter("[RawFolderId] IS NULL")
.HasDatabaseName("UX_DriverInstance_ClusterRoot_Name");
});
}
@@ -300,6 +318,38 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
e.HasIndex(x => x.DriverInstanceId).HasDatabaseName("IX_Device_Driver");
e.HasIndex(x => x.DeviceId).IsUnique().HasDatabaseName("UX_Device_LogicalId");
// v3 sibling-name uniqueness within a driver (a RawPath segment).
e.HasIndex(x => new { x.DriverInstanceId, x.Name }).IsUnique()
.HasDatabaseName("UX_Device_Driver_Name");
});
}
private static void ConfigureTagGroup(ModelBuilder modelBuilder)
{
modelBuilder.Entity<TagGroup>(e =>
{
e.ToTable("TagGroup");
e.HasKey(x => x.TagGroupRowId);
e.Property(x => x.TagGroupRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.TagGroupId).HasMaxLength(64);
e.Property(x => x.DeviceId).HasMaxLength(64);
e.Property(x => x.ParentTagGroupId).HasMaxLength(64);
e.Property(x => x.Name).HasMaxLength(128);
e.Property(x => x.RowVersion).IsRowVersion();
e.HasIndex(x => x.TagGroupId).IsUnique().HasDatabaseName("UX_TagGroup_LogicalId");
e.HasIndex(x => x.DeviceId).HasDatabaseName("IX_TagGroup_Device");
// Sibling-name uniqueness. Two filtered indexes cover the nullable ParentTagGroupId:
// nested groups keyed by (DeviceId, ParentTagGroupId, Name), device-root groups by
// (DeviceId, Name). DeviceId/ParentTagGroupId are logical FKs (no navigation); non-empty
// group deletes are blocked at the service layer in later batches.
e.HasIndex(x => new { x.DeviceId, x.ParentTagGroupId, x.Name }).IsUnique()
.HasFilter("[ParentTagGroupId] IS NOT NULL")
.HasDatabaseName("UX_TagGroup_Parent_Name");
e.HasIndex(x => new { x.DeviceId, x.Name }).IsUnique()
.HasFilter("[ParentTagGroupId] IS NULL")
.HasDatabaseName("UX_TagGroup_DeviceRoot_Name");
});
}
@@ -311,8 +361,6 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
e.HasKey(x => x.EquipmentRowId);
e.Property(x => x.EquipmentRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.EquipmentId).HasMaxLength(64);
e.Property(x => x.DriverInstanceId).HasMaxLength(64);
e.Property(x => x.DeviceId).HasMaxLength(64);
e.Property(x => x.UnsLineId).HasMaxLength(64);
e.Property(x => x.Name).HasMaxLength(32);
e.Property(x => x.MachineCode).HasMaxLength(64);
@@ -329,7 +377,6 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
e.Property(x => x.EquipmentClassRef).HasMaxLength(128);
e.Property(x => x.RowVersion).IsRowVersion();
e.HasIndex(x => x.DriverInstanceId).HasDatabaseName("IX_Equipment_Driver");
e.HasIndex(x => x.UnsLineId).HasDatabaseName("IX_Equipment_Line");
e.HasIndex(x => x.EquipmentId).IsUnique().HasDatabaseName("UX_Equipment_LogicalId");
e.HasIndex(x => new { x.UnsLineId, x.Name }).IsUnique().HasDatabaseName("UX_Equipment_LinePath");
@@ -351,28 +398,29 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
e.HasKey(x => x.TagRowId);
e.Property(x => x.TagRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.TagId).HasMaxLength(64);
e.Property(x => x.DriverInstanceId).HasMaxLength(64);
e.Property(x => x.DeviceId).HasMaxLength(64);
e.Property(x => x.EquipmentId).HasMaxLength(64);
// v3: raw-only tag. Required DeviceId FK; nullable TagGroupId. EquipmentId/FolderPath/
// DriverInstanceId are retired — identity is the RawPath (device + optional group + name).
e.Property(x => x.DeviceId).HasMaxLength(64).IsRequired();
e.Property(x => x.TagGroupId).HasMaxLength(64);
e.Property(x => x.Name).HasMaxLength(128);
e.Property(x => x.FolderPath).HasMaxLength(512);
e.Property(x => x.DataType).HasMaxLength(32);
e.Property(x => x.AccessLevel).HasConversion<string>().HasMaxLength(16);
e.Property(x => x.PollGroupId).HasMaxLength(64);
e.Property(x => x.TagConfig).HasColumnType("nvarchar(max)");
e.Property(x => x.RowVersion).IsRowVersion();
e.HasIndex(x => new { x.DriverInstanceId, x.DeviceId }).HasDatabaseName("IX_Tag_Driver_Device");
e.HasIndex(x => x.EquipmentId)
.HasFilter("[EquipmentId] IS NOT NULL")
.HasDatabaseName("IX_Tag_Equipment");
e.HasIndex(x => new { x.DeviceId, x.TagGroupId }).HasDatabaseName("IX_Tag_Device");
e.HasIndex(x => x.TagId).IsUnique().HasDatabaseName("UX_Tag_LogicalId");
e.HasIndex(x => new { x.EquipmentId, x.Name }).IsUnique()
.HasFilter("[EquipmentId] IS NOT NULL")
.HasDatabaseName("UX_Tag_EquipmentPath");
e.HasIndex(x => new { x.DriverInstanceId, x.FolderPath, x.Name }).IsUnique()
.HasFilter("[EquipmentId] IS NULL")
.HasDatabaseName("UX_Tag_FolderPath");
// Sibling-name uniqueness (a RawPath segment). Two filtered indexes cover the nullable
// TagGroupId: grouped tags keyed by (DeviceId, TagGroupId, Name), device-root tags by
// (DeviceId, Name).
e.HasIndex(x => new { x.DeviceId, x.TagGroupId, x.Name }).IsUnique()
.HasFilter("[TagGroupId] IS NOT NULL")
.HasDatabaseName("UX_Tag_Group_Name");
e.HasIndex(x => new { x.DeviceId, x.Name }).IsUnique()
.HasFilter("[TagGroupId] IS NULL")
.HasDatabaseName("UX_Tag_Device_Name");
});
}
@@ -567,51 +615,28 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
});
}
private static void ConfigureEquipmentImportBatch(ModelBuilder modelBuilder)
private static void ConfigureUnsTagReference(ModelBuilder modelBuilder)
{
modelBuilder.Entity<EquipmentImportBatch>(e =>
modelBuilder.Entity<UnsTagReference>(e =>
{
e.ToTable("EquipmentImportBatch");
e.HasKey(x => x.Id);
e.Property(x => x.ClusterId).HasMaxLength(64);
e.Property(x => x.CreatedBy).HasMaxLength(128);
e.Property(x => x.CreatedAtUtc).HasColumnType("datetime2(3)");
e.Property(x => x.FinalisedAtUtc).HasColumnType("datetime2(3)");
// Admin preview modal filters by user; finalise / drop both hit this index.
e.HasIndex(x => new { x.CreatedBy, x.FinalisedAtUtc })
.HasDatabaseName("IX_EquipmentImportBatch_Creator_Finalised");
});
modelBuilder.Entity<EquipmentImportRow>(e =>
{
e.ToTable("EquipmentImportRow");
e.HasKey(x => x.Id);
e.Property(x => x.ZTag).HasMaxLength(128);
e.Property(x => x.MachineCode).HasMaxLength(128);
e.Property(x => x.SAPID).HasMaxLength(128);
e.ToTable("UnsTagReference");
e.HasKey(x => x.UnsTagReferenceRowId);
e.Property(x => x.UnsTagReferenceRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.UnsTagReferenceId).HasMaxLength(64);
e.Property(x => x.EquipmentId).HasMaxLength(64);
e.Property(x => x.EquipmentUuid).HasMaxLength(64);
e.Property(x => x.Name).HasMaxLength(128);
e.Property(x => x.UnsAreaName).HasMaxLength(64);
e.Property(x => x.UnsLineName).HasMaxLength(64);
e.Property(x => x.Manufacturer).HasMaxLength(256);
e.Property(x => x.Model).HasMaxLength(256);
e.Property(x => x.SerialNumber).HasMaxLength(256);
e.Property(x => x.HardwareRevision).HasMaxLength(64);
e.Property(x => x.SoftwareRevision).HasMaxLength(64);
e.Property(x => x.YearOfConstruction).HasMaxLength(8);
e.Property(x => x.AssetLocation).HasMaxLength(512);
e.Property(x => x.ManufacturerUri).HasMaxLength(512);
e.Property(x => x.DeviceManualUri).HasMaxLength(512);
e.Property(x => x.RejectReason).HasMaxLength(512);
e.Property(x => x.TagId).HasMaxLength(64);
e.Property(x => x.DisplayNameOverride).HasMaxLength(128);
e.Property(x => x.RowVersion).IsRowVersion();
e.HasOne(x => x.Batch)
.WithMany(b => b.Rows)
.HasForeignKey(x => x.BatchId)
.OnDelete(DeleteBehavior.Cascade);
e.HasIndex(x => x.UnsTagReferenceId).IsUnique().HasDatabaseName("UX_UnsTagReference_LogicalId");
e.HasIndex(x => x.EquipmentId).HasDatabaseName("IX_UnsTagReference_Equipment");
e.HasIndex(x => x.TagId).HasDatabaseName("IX_UnsTagReference_Tag");
e.HasIndex(x => x.BatchId).HasDatabaseName("IX_EquipmentImportRow_Batch");
// One reference per (equipment, raw tag). EquipmentId/TagId are logical FKs (no
// navigation); the "raw-tag delete blocked while referenced" rule is enforced at the
// service layer in later batches.
e.HasIndex(x => new { x.EquipmentId, x.TagId }).IsUnique()
.HasDatabaseName("UX_UnsTagReference_Equip_Tag");
});
}