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:
@@ -25,8 +25,9 @@ public sealed class DraftSnapshot
|
||||
/// </summary>
|
||||
public string? Site { get; init; }
|
||||
|
||||
/// <summary>Gets the list of OPC UA namespaces.</summary>
|
||||
public IReadOnlyList<Namespace> Namespaces { get; init; } = [];
|
||||
// v3: the Namespace entity is retired (the two OPC UA namespaces are implicit). The old
|
||||
// Namespaces list + namespace-binding validation are gone. WP4 (Wave C) owns the new v3
|
||||
// rules (raw-name charset, historized-tagname length, UNS effective-leaf uniqueness).
|
||||
/// <summary>Gets the list of driver instances.</summary>
|
||||
public IReadOnlyList<DriverInstance> DriverInstances { get; init; } = [];
|
||||
/// <summary>Gets the list of devices.</summary>
|
||||
|
||||
@@ -36,7 +36,7 @@ public static class DraftSnapshotFactory
|
||||
// EquipmentUuid checks run in separate validator passes — no rule here reads these fields.
|
||||
GenerationId = 0, // generation model dropped; placeholder (no rule reads it)
|
||||
ClusterId = string.Empty, // global snapshot; rules compare entity ClusterId fields, not this
|
||||
Namespaces = await db.Namespaces.AsNoTracking().ToListAsync(ct),
|
||||
// v3: Namespace entity retired — no namespace list to materialize.
|
||||
DriverInstances = await db.DriverInstances.AsNoTracking().ToListAsync(ct),
|
||||
Devices = await db.Devices.AsNoTracking().ToListAsync(ct),
|
||||
UnsAreas = await db.UnsAreas.AsNoTracking().ToListAsync(ct),
|
||||
|
||||
@@ -30,56 +30,34 @@ public static class DraftValidator
|
||||
ValidateUnsSegments(draft, errors);
|
||||
ValidatePathLength(draft, errors);
|
||||
ValidateEquipmentUuidImmutability(draft, errors);
|
||||
ValidateSameClusterNamespaceBinding(draft, errors);
|
||||
ValidateReservationPreflight(draft, errors);
|
||||
ValidateEquipmentIdDerivation(draft, errors);
|
||||
ValidateNoEquipmentSignalNameCollision(draft, errors);
|
||||
ValidateGalaxyTagFullName(draft, errors);
|
||||
|
||||
// v3 WP1 note: the retired rules ValidateSameClusterNamespaceBinding (Namespace entity gone)
|
||||
// and ValidateGalaxyTagFullName (blob-as-identity gone; Tags are raw-only, not equipment-bound)
|
||||
// are deleted here. TODO(v3 WP4): add the new v3 rules — raw-name charset (no '/', no
|
||||
// lead/trail whitespace) at every level, historized effective-tagname ≤ 255 chars, and UNS
|
||||
// effective-leaf uniqueness across UnsTagReference/VirtualTag/ScriptedAlarm.
|
||||
return errors;
|
||||
}
|
||||
|
||||
private static void ValidateGalaxyTagFullName(DraftSnapshot draft, List<ValidationError> errors)
|
||||
{
|
||||
var typeByDriver = draft.DriverInstances
|
||||
.ToDictionary(d => d.DriverInstanceId, d => d.DriverType, StringComparer.Ordinal);
|
||||
foreach (var t in draft.Tags)
|
||||
{
|
||||
if (t.EquipmentId is null) continue;
|
||||
if (!typeByDriver.TryGetValue(t.DriverInstanceId, out var dtype) || dtype != "GalaxyMxGateway")
|
||||
continue;
|
||||
// The Galaxy rule wants the EXPLICIT reference, not the raw-blob fallback — so it reads
|
||||
// TagConfigIntent.ExplicitFullName (the "FullName" property only when present-and-string,
|
||||
// else null), preserving the historical null-on-absent semantics by construction (R2-11).
|
||||
if (string.IsNullOrWhiteSpace(TagConfigIntent.Parse(t.TagConfig).ExplicitFullName))
|
||||
errors.Add(new("GalaxyTagMissingReference",
|
||||
$"Galaxy tag '{t.TagId}' on equipment '{t.EquipmentId}' is missing a Galaxy reference (TagConfig.FullName)",
|
||||
t.TagId));
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateNoEquipmentSignalNameCollision(DraftSnapshot draft, List<ValidationError> errors)
|
||||
{
|
||||
// Materialiser NodeId key: "{EquipmentId}[/{FolderPath}]/{Name}". Tag (EquipmentId != null) and
|
||||
// VirtualTag share this space with no DB cross-table uniqueness, so the same key from both collides.
|
||||
static string Key(string eq, string? folder, string name) =>
|
||||
string.IsNullOrWhiteSpace(folder) ? $"{eq}/{name}" : $"{eq}/{folder}/{name}";
|
||||
// v3: Tags are raw-only (no EquipmentId), so the old Tag-vs-VirtualTag NodeId collision does
|
||||
// not apply here. Until WP4 wires the full UNS effective-leaf rule (across UnsTagReference,
|
||||
// VirtualTag, and ScriptedAlarm), enforce the still-valid invariant that a VirtualTag Name is
|
||||
// unique within its owning equipment — the OPC UA NodeId key is "{EquipmentId}/{Name}".
|
||||
var signals = draft.VirtualTags.Select(v => (Eq: v.EquipmentId, v.Name));
|
||||
|
||||
var signals = draft.Tags
|
||||
.Where(t => t.EquipmentId is not null)
|
||||
.Select(t => (Key: Key(t.EquipmentId!, t.FolderPath, t.Name), Eq: t.EquipmentId!, t.Name))
|
||||
// VirtualTag has no FolderPath column today — null is correct here; update if it ever gains one.
|
||||
.Concat(draft.VirtualTags
|
||||
.Select(v => (Key: Key(v.EquipmentId, null, v.Name), Eq: v.EquipmentId, v.Name)));
|
||||
|
||||
foreach (var g in signals.GroupBy(s => s.Key, StringComparer.Ordinal))
|
||||
foreach (var g in signals.GroupBy(s => $"{s.Eq}/{s.Name}", StringComparer.Ordinal))
|
||||
{
|
||||
var items = g.ToList();
|
||||
if (items.Count <= 1) continue;
|
||||
var f = items[0];
|
||||
errors.Add(new("EquipmentSignalNameCollision",
|
||||
$"{items.Count} signals collide on OPC UA NodeId '{g.Key}' (equipment '{f.Eq}', name '{f.Name}'); " +
|
||||
"a Name must be unique across Tag and VirtualTag within an equipment+folder",
|
||||
"a VirtualTag Name must be unique within an equipment",
|
||||
f.Eq));
|
||||
}
|
||||
}
|
||||
@@ -149,27 +127,6 @@ public static class DraftValidator
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateSameClusterNamespaceBinding(DraftSnapshot draft, List<ValidationError> errors)
|
||||
{
|
||||
var nsById = draft.Namespaces.ToDictionary(n => n.NamespaceId);
|
||||
|
||||
foreach (var di in draft.DriverInstances)
|
||||
{
|
||||
if (!nsById.TryGetValue(di.NamespaceId, out var ns))
|
||||
{
|
||||
errors.Add(new("NamespaceUnresolved",
|
||||
$"DriverInstance '{di.DriverInstanceId}' references unknown NamespaceId '{di.NamespaceId}'",
|
||||
di.DriverInstanceId));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ns.ClusterId != di.ClusterId)
|
||||
errors.Add(new("BadCrossClusterNamespaceBinding",
|
||||
$"DriverInstance '{di.DriverInstanceId}' is in cluster '{di.ClusterId}' but references namespace in cluster '{ns.ClusterId}'",
|
||||
di.DriverInstanceId));
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateReservationPreflight(DraftSnapshot draft, List<ValidationError> errors)
|
||||
{
|
||||
var activeByKindValue = draft.ActiveReservations
|
||||
|
||||
Reference in New Issue
Block a user