Files
lmxopcua/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260417215224_StoredProcedures.cs
Joseph Doherty 01fd90c178 Phase 1 Streams B–E scaffold + Phase 2 Streams A–C scaffold — 8 new projects with ~70 new tests, all green alongside the 494 v1 IntegrationTests baseline (parity preserved: no v1 tests broken; legacy OtOpcUa.Host untouched). Phase 1 finish: Configuration project (16 entities + 10 enums + DbContext + DesignTimeDbContextFactory + InitialSchema/StoredProcedures/AuthorizationGrants migrations — 8 procs including sp_PublishGeneration with MERGE on ExternalIdReservation per decision #124, sp_RollbackToGeneration cloning rows into a new published generation, sp_ValidateDraft with cross-cluster-namespace + EquipmentUuid-immutability + ZTag/SAPID reservation pre-flight, sp_ComputeGenerationDiff with CHECKSUM-based row signature — plus OtOpcUaNode/OtOpcUaAdmin SQL roles with EXECUTE grants scoped to per-principal-class proc sets and DENY UPDATE/DELETE/INSERT/SELECT on dbo schema); managed DraftValidator covering UNS segment regex, path length, EquipmentUuid immutability across generations, same-cluster namespace binding (decision #122), reservation pre-flight, EquipmentId derivation (decision #125), driver↔namespace compatibility — returning every failing rule in one pass; LiteDB local cache with round-trip + ring pruning + corruption-fast-fail; GenerationApplier with per-entity Added/Removed/Modified diff and dependency-ordered callbacks (namespace → driver → device → equipment → poll-group → tag, Removed before Added); Core project with GenericDriverNodeManager (scaffold for the Phase 2 Galaxy port) and DriverHost lifecycle registry; Server project using Microsoft.Extensions.Hosting BackgroundService replacing TopShelf, with NodeBootstrap that falls back to LiteDB cache when the central DB is unreachable (decision #79); Admin project scaffolded as Blazor Server with Bootstrap 5 sidebar layout, cookie auth, three admin roles (ConfigViewer/ConfigEditor/FleetAdmin), Cluster + Generation services fronting the stored procs. Phase 2 scaffold: Driver.Galaxy.Shared (netstandard2.0) with full MessagePack IPC contract surface — Hello version negotiation, Open/CloseSession, Heartbeat, DiscoverHierarchy + GalaxyObjectInfo/GalaxyAttributeInfo, Read/WriteValues, Subscribe/Unsubscribe/OnDataChange, AlarmSubscribe/Event/Ack, HistoryRead, HostConnectivityStatus, Recycle — plus length-prefixed framing (decision #28) with a 16 MiB cap and thread-safe FrameWriter/FrameReader; Driver.Galaxy.Host (net48) implementing the Tier C cross-cutting protections from driver-stability.md — strict PipeAcl (allow configured server SID only, explicit deny on LocalSystem + Administrators), PipeServer with caller-SID verification via pipe.RunAsClient + WindowsIdentity.GetCurrent and per-process shared-secret Hello, Galaxy-specific MemoryWatchdog (warn at max(1.5×baseline, +200 MB), soft-recycle at max(2×baseline, +200 MB), hard ceiling 1.5 GB, slope ≥5 MB/min over 30-min rolling window), RecyclePolicy (1 soft recycle per hour cap + 03:00 local daily scheduled), PostMortemMmf (1000-entry ring buffer in %ProgramData%\OtOpcUa\driver-postmortem\galaxy.mmf, survives hard crash, readable cross-process), MxAccessHandle : SafeHandle (ReleaseHandle loops Marshal.ReleaseComObject until refcount=0 then calls optional unregister callback), StaPump with responsiveness probe (BlockingCollection dispatcher for Phase 1 — real Win32 GetMessage/DispatchMessage pump slots in with the same semantics when the Galaxy code lift happens), IsExternalInit shim for init setters on .NET 4.8; Driver.Galaxy.Proxy (net10) implementing IDriver + ITagDiscovery forwarding over the IPC channel with MX data-type and security-classification mapping, plus Supervisor pieces — Backoff (5s → 15s → 60s capped, reset-on-stable-run), CircuitBreaker (3 crashes per 5 min opens; 1h → 4h → manual cooldown escalation; sticky alert doesn't auto-clear), HeartbeatMonitor (2s cadence, 3 consecutive misses = host dead per driver-stability.md). Infrastructure: docker SQL Server remapped to host port 14330 to coexist with the native MSSQL14 Galaxy ZB DB instance on 1433; NuGetAuditSuppress applied per-project for two System.Security.Cryptography.Xml advisories that only reach via EF Core Design with PrivateAssets=all (fix ships in 11.0.0-preview); .slnx gains 14 project registrations. Deferred with explicit TODOs in docs/v2/implementation/phase-2-partial-exit-evidence.md: Phase 1 Stream E Admin UI pages (Generations listing + draft-diff-publish, Equipment CRUD with OPC 40010 fields, UNS Areas/Lines tabs, ACLs + permission simulator, Generic JSON config editor, SignalR real-time, Release-Reservation + Merge-Equipment workflows, LDAP login page, AppServer smoke test per decision #142), Phase 2 Stream D (Galaxy MXAccess code lift out of legacy OtOpcUa.Host, dual-service installer, appsettings → DriverConfig migration script, legacy Host deletion — blocked by parity), Phase 2 Stream E (v1 IntegrationTests against v2 topology, Client.CLI walkthrough diff, four 2026-04-13 stability findings regression tests, adversarial review — requires live MXAccess runtime).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 21:35:25 -04:00

474 lines
20 KiB
C#

using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations;
/// <summary>
/// Stored procedures per <c>config-db-schema.md §"Stored Procedures"</c>. All node + admin DB
/// access funnels through these — direct table writes are revoked in the AuthorizationGrants
/// migration that follows. CREATE OR ALTER style so procs version with the schema.
/// </summary>
public partial class StoredProcedures : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(Procs.GetCurrentGenerationForCluster);
migrationBuilder.Sql(Procs.GetGenerationContent);
migrationBuilder.Sql(Procs.RegisterNodeGenerationApplied);
migrationBuilder.Sql(Procs.ValidateDraft);
migrationBuilder.Sql(Procs.PublishGeneration);
migrationBuilder.Sql(Procs.RollbackToGeneration);
migrationBuilder.Sql(Procs.ComputeGenerationDiff);
migrationBuilder.Sql(Procs.ReleaseExternalIdReservation);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
foreach (var name in new[]
{
"sp_ReleaseExternalIdReservation", "sp_ComputeGenerationDiff", "sp_RollbackToGeneration",
"sp_PublishGeneration", "sp_ValidateDraft", "sp_RegisterNodeGenerationApplied",
"sp_GetGenerationContent", "sp_GetCurrentGenerationForCluster",
})
{
migrationBuilder.Sql($"IF OBJECT_ID(N'dbo.{name}', N'P') IS NOT NULL DROP PROCEDURE dbo.{name};");
}
}
private static class Procs
{
public const string GetCurrentGenerationForCluster = @"
CREATE OR ALTER PROCEDURE dbo.sp_GetCurrentGenerationForCluster
@NodeId nvarchar(64),
@ClusterId nvarchar(64)
AS
BEGIN
SET NOCOUNT ON;
DECLARE @Caller nvarchar(128) = SUSER_SNAME();
IF NOT EXISTS (
SELECT 1 FROM dbo.ClusterNodeCredential
WHERE NodeId = @NodeId AND Value = @Caller AND Enabled = 1)
BEGIN
RAISERROR('Unauthorized: caller %s is not bound to NodeId %s', 16, 1, @Caller, @NodeId);
RETURN;
END
IF NOT EXISTS (
SELECT 1 FROM dbo.ClusterNode
WHERE NodeId = @NodeId AND ClusterId = @ClusterId AND Enabled = 1)
BEGIN
RAISERROR('Forbidden: NodeId %s does not belong to ClusterId %s', 16, 1, @NodeId, @ClusterId);
RETURN;
END
SELECT TOP 1 GenerationId, ClusterId, Status, PublishedAt, PublishedBy, Notes
FROM dbo.ConfigGeneration
WHERE ClusterId = @ClusterId AND Status = 'Published'
ORDER BY GenerationId DESC;
END
";
public const string GetGenerationContent = @"
CREATE OR ALTER PROCEDURE dbo.sp_GetGenerationContent
@NodeId nvarchar(64),
@GenerationId bigint
AS
BEGIN
SET NOCOUNT ON;
DECLARE @Caller nvarchar(128) = SUSER_SNAME();
DECLARE @ClusterId nvarchar(64);
SELECT @ClusterId = ClusterId FROM dbo.ConfigGeneration WHERE GenerationId = @GenerationId;
IF @ClusterId IS NULL
BEGIN
RAISERROR('GenerationId %I64d not found', 16, 1, @GenerationId);
RETURN;
END
IF NOT EXISTS (
SELECT 1
FROM dbo.ClusterNodeCredential c
JOIN dbo.ClusterNode n ON n.NodeId = c.NodeId
WHERE c.NodeId = @NodeId AND c.Value = @Caller AND c.Enabled = 1
AND n.ClusterId = @ClusterId AND n.Enabled = 1)
BEGIN
RAISERROR('Forbidden: caller %s not bound to a node in ClusterId %s', 16, 1, @Caller, @ClusterId);
RETURN;
END
SELECT * FROM dbo.Namespace WHERE GenerationId = @GenerationId;
SELECT * FROM dbo.UnsArea WHERE GenerationId = @GenerationId;
SELECT * FROM dbo.UnsLine WHERE GenerationId = @GenerationId;
SELECT * FROM dbo.DriverInstance WHERE GenerationId = @GenerationId;
SELECT * FROM dbo.Device WHERE GenerationId = @GenerationId;
SELECT * FROM dbo.Equipment WHERE GenerationId = @GenerationId;
SELECT * FROM dbo.PollGroup WHERE GenerationId = @GenerationId;
SELECT * FROM dbo.Tag WHERE GenerationId = @GenerationId;
SELECT * FROM dbo.NodeAcl WHERE GenerationId = @GenerationId;
END
";
public const string RegisterNodeGenerationApplied = @"
CREATE OR ALTER PROCEDURE dbo.sp_RegisterNodeGenerationApplied
@NodeId nvarchar(64),
@GenerationId bigint,
@Status nvarchar(16),
@Error nvarchar(max) = NULL
AS
BEGIN
SET NOCOUNT ON;
DECLARE @Caller nvarchar(128) = SUSER_SNAME();
IF NOT EXISTS (
SELECT 1 FROM dbo.ClusterNodeCredential
WHERE NodeId = @NodeId AND Value = @Caller AND Enabled = 1)
BEGIN
RAISERROR('Unauthorized: caller %s is not bound to NodeId %s', 16, 1, @Caller, @NodeId);
RETURN;
END
MERGE dbo.ClusterNodeGenerationState AS tgt
USING (SELECT @NodeId AS NodeId) AS src ON tgt.NodeId = src.NodeId
WHEN MATCHED THEN UPDATE SET
CurrentGenerationId = @GenerationId,
LastAppliedAt = SYSUTCDATETIME(),
LastAppliedStatus = @Status,
LastAppliedError = @Error,
LastSeenAt = SYSUTCDATETIME()
WHEN NOT MATCHED THEN INSERT
(NodeId, CurrentGenerationId, LastAppliedAt, LastAppliedStatus, LastAppliedError, LastSeenAt)
VALUES (@NodeId, @GenerationId, SYSUTCDATETIME(), @Status, @Error, SYSUTCDATETIME());
INSERT dbo.ConfigAuditLog (Principal, EventType, NodeId, GenerationId, DetailsJson)
VALUES (@Caller, 'NodeApplied', @NodeId, @GenerationId,
CONCAT('{""status"":""', @Status, '""}'));
END
";
public const string ValidateDraft = @"
CREATE OR ALTER PROCEDURE dbo.sp_ValidateDraft
@DraftGenerationId bigint
AS
BEGIN
SET NOCOUNT ON;
DECLARE @ClusterId nvarchar(64);
DECLARE @Status nvarchar(16);
SELECT @ClusterId = ClusterId, @Status = Status
FROM dbo.ConfigGeneration WHERE GenerationId = @DraftGenerationId;
IF @ClusterId IS NULL
BEGIN
RAISERROR('GenerationId %I64d not found', 16, 1, @DraftGenerationId);
RETURN;
END
IF @Status <> 'Draft'
BEGIN
RAISERROR('GenerationId %I64d is not in Draft status (current=%s)', 16, 1, @DraftGenerationId, @Status);
RETURN;
END
IF EXISTS (
SELECT 1 FROM dbo.Tag t
LEFT JOIN dbo.DriverInstance d ON d.GenerationId = t.GenerationId AND d.DriverInstanceId = t.DriverInstanceId
WHERE t.GenerationId = @DraftGenerationId AND d.DriverInstanceId IS NULL)
BEGIN
RAISERROR('Draft has tags with unresolved DriverInstanceId', 16, 1);
RETURN;
END
IF EXISTS (
SELECT 1 FROM dbo.Tag t
LEFT JOIN dbo.Device dv ON dv.GenerationId = t.GenerationId AND dv.DeviceId = t.DeviceId
WHERE t.GenerationId = @DraftGenerationId AND t.DeviceId IS NOT NULL AND dv.DeviceId IS NULL)
BEGIN
RAISERROR('Draft has tags with unresolved DeviceId', 16, 1);
RETURN;
END
IF EXISTS (
SELECT 1 FROM dbo.Tag t
LEFT JOIN dbo.PollGroup pg ON pg.GenerationId = t.GenerationId AND pg.PollGroupId = t.PollGroupId
WHERE t.GenerationId = @DraftGenerationId AND t.PollGroupId IS NOT NULL AND pg.PollGroupId IS NULL)
BEGIN
RAISERROR('Draft has tags with unresolved PollGroupId', 16, 1);
RETURN;
END
IF EXISTS (
SELECT 1
FROM dbo.DriverInstance di
JOIN dbo.Namespace ns ON ns.GenerationId = di.GenerationId AND ns.NamespaceId = di.NamespaceId
WHERE di.GenerationId = @DraftGenerationId
AND ns.ClusterId <> di.ClusterId)
BEGIN
INSERT dbo.ConfigAuditLog (Principal, EventType, ClusterId, GenerationId)
VALUES (SUSER_SNAME(), 'CrossClusterNamespaceAttempt', @ClusterId, @DraftGenerationId);
RAISERROR('BadCrossClusterNamespaceBinding: namespace and driver must belong to the same cluster', 16, 1);
RETURN;
END
IF EXISTS (
SELECT 1
FROM dbo.Equipment draft
JOIN dbo.Equipment prior
ON prior.EquipmentId = draft.EquipmentId
AND prior.EquipmentUuid <> draft.EquipmentUuid
AND prior.GenerationId <> draft.GenerationId
JOIN dbo.ConfigGeneration pg ON pg.GenerationId = prior.GenerationId
WHERE draft.GenerationId = @DraftGenerationId
AND pg.ClusterId = @ClusterId)
BEGIN
RAISERROR('EquipmentUuid immutability violated for an EquipmentId that existed in a prior generation', 16, 1);
RETURN;
END
IF EXISTS (
SELECT 1
FROM dbo.Equipment draft
JOIN dbo.ExternalIdReservation r
ON r.Kind = 'ZTag' AND r.Value = draft.ZTag AND r.ReleasedAt IS NULL
AND r.EquipmentUuid <> draft.EquipmentUuid
WHERE draft.GenerationId = @DraftGenerationId AND draft.ZTag IS NOT NULL)
BEGIN
RAISERROR('BadDuplicateExternalIdentifier: a ZTag in the draft is reserved by a different EquipmentUuid', 16, 1);
RETURN;
END
IF EXISTS (
SELECT 1
FROM dbo.Equipment draft
JOIN dbo.ExternalIdReservation r
ON r.Kind = 'SAPID' AND r.Value = draft.SAPID AND r.ReleasedAt IS NULL
AND r.EquipmentUuid <> draft.EquipmentUuid
WHERE draft.GenerationId = @DraftGenerationId AND draft.SAPID IS NOT NULL)
BEGIN
RAISERROR('BadDuplicateExternalIdentifier: a SAPID in the draft is reserved by a different EquipmentUuid', 16, 1);
RETURN;
END
END
";
public const string PublishGeneration = @"
CREATE OR ALTER PROCEDURE dbo.sp_PublishGeneration
@ClusterId nvarchar(64),
@DraftGenerationId bigint,
@Notes nvarchar(1024) = NULL
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
BEGIN TRANSACTION;
DECLARE @Lock nvarchar(255) = N'OtOpcUa_Publish_' + @ClusterId;
DECLARE @LockResult int;
EXEC @LockResult = sp_getapplock @Resource = @Lock, @LockMode = 'Exclusive', @LockTimeout = 0;
IF @LockResult < 0
BEGIN
RAISERROR('PublishConflict: another publish is in progress for cluster %s', 16, 1, @ClusterId);
ROLLBACK;
RETURN;
END
EXEC dbo.sp_ValidateDraft @DraftGenerationId = @DraftGenerationId;
MERGE dbo.ExternalIdReservation AS tgt
USING (
SELECT 'ZTag' AS Kind, ZTag AS Value, EquipmentUuid
FROM dbo.Equipment
WHERE GenerationId = @DraftGenerationId AND ZTag IS NOT NULL
UNION ALL
SELECT 'SAPID', SAPID, EquipmentUuid
FROM dbo.Equipment
WHERE GenerationId = @DraftGenerationId AND SAPID IS NOT NULL
) AS src
ON tgt.Kind = src.Kind AND tgt.Value = src.Value AND tgt.EquipmentUuid = src.EquipmentUuid
WHEN MATCHED THEN UPDATE SET LastPublishedAt = SYSUTCDATETIME()
WHEN NOT MATCHED BY TARGET THEN
INSERT (Kind, Value, EquipmentUuid, ClusterId, FirstPublishedBy, LastPublishedAt)
VALUES (src.Kind, src.Value, src.EquipmentUuid, @ClusterId, SUSER_SNAME(), SYSUTCDATETIME());
UPDATE dbo.ConfigGeneration
SET Status = 'Superseded'
WHERE ClusterId = @ClusterId AND Status = 'Published';
UPDATE dbo.ConfigGeneration
SET Status = 'Published',
PublishedAt = SYSUTCDATETIME(),
PublishedBy = SUSER_SNAME(),
Notes = ISNULL(@Notes, Notes)
WHERE GenerationId = @DraftGenerationId AND ClusterId = @ClusterId AND Status = 'Draft';
IF @@ROWCOUNT = 0
BEGIN
RAISERROR('Draft %I64d for cluster %s not found (was it already published?)', 16, 1, @DraftGenerationId, @ClusterId);
ROLLBACK;
RETURN;
END
INSERT dbo.ConfigAuditLog (Principal, EventType, ClusterId, GenerationId)
VALUES (SUSER_SNAME(), 'Published', @ClusterId, @DraftGenerationId);
COMMIT;
END
";
public const string RollbackToGeneration = @"
CREATE OR ALTER PROCEDURE dbo.sp_RollbackToGeneration
@ClusterId nvarchar(64),
@TargetGenerationId bigint,
@Notes nvarchar(1024) = NULL
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
BEGIN TRANSACTION;
IF NOT EXISTS (
SELECT 1 FROM dbo.ConfigGeneration
WHERE GenerationId = @TargetGenerationId AND ClusterId = @ClusterId
AND Status IN ('Published', 'Superseded'))
BEGIN
RAISERROR('Target generation %I64d not found or not rollback-eligible', 16, 1, @TargetGenerationId);
ROLLBACK; RETURN;
END
DECLARE @NewGenId bigint;
INSERT dbo.ConfigGeneration (ClusterId, Status, CreatedAt, CreatedBy, PublishedAt, PublishedBy, Notes)
VALUES (@ClusterId, 'Draft', SYSUTCDATETIME(), SUSER_SNAME(), NULL, NULL,
ISNULL(@Notes, CONCAT('Rollback clone of generation ', @TargetGenerationId)));
SET @NewGenId = SCOPE_IDENTITY();
INSERT dbo.Namespace (GenerationId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled, Notes)
SELECT @NewGenId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled, Notes FROM dbo.Namespace WHERE GenerationId = @TargetGenerationId;
INSERT dbo.UnsArea (GenerationId, UnsAreaId, ClusterId, Name, Notes)
SELECT @NewGenId, UnsAreaId, ClusterId, Name, Notes FROM dbo.UnsArea WHERE GenerationId = @TargetGenerationId;
INSERT dbo.UnsLine (GenerationId, UnsLineId, UnsAreaId, Name, Notes)
SELECT @NewGenId, UnsLineId, UnsAreaId, Name, Notes FROM dbo.UnsLine WHERE GenerationId = @TargetGenerationId;
INSERT dbo.DriverInstance (GenerationId, DriverInstanceId, ClusterId, NamespaceId, Name, DriverType, Enabled, DriverConfig)
SELECT @NewGenId, DriverInstanceId, ClusterId, NamespaceId, Name, DriverType, Enabled, DriverConfig FROM dbo.DriverInstance WHERE GenerationId = @TargetGenerationId;
INSERT dbo.Device (GenerationId, DeviceId, DriverInstanceId, Name, Enabled, DeviceConfig)
SELECT @NewGenId, DeviceId, DriverInstanceId, Name, Enabled, DeviceConfig FROM dbo.Device WHERE GenerationId = @TargetGenerationId;
INSERT dbo.Equipment (GenerationId, EquipmentId, EquipmentUuid, DriverInstanceId, DeviceId, UnsLineId, Name, MachineCode, ZTag, SAPID, Manufacturer, Model, SerialNumber, HardwareRevision, SoftwareRevision, YearOfConstruction, AssetLocation, ManufacturerUri, DeviceManualUri, EquipmentClassRef, Enabled)
SELECT @NewGenId, EquipmentId, EquipmentUuid, DriverInstanceId, DeviceId, UnsLineId, Name, MachineCode, ZTag, SAPID, Manufacturer, Model, SerialNumber, HardwareRevision, SoftwareRevision, YearOfConstruction, AssetLocation, ManufacturerUri, DeviceManualUri, EquipmentClassRef, Enabled FROM dbo.Equipment WHERE GenerationId = @TargetGenerationId;
INSERT dbo.PollGroup (GenerationId, PollGroupId, DriverInstanceId, Name, IntervalMs)
SELECT @NewGenId, PollGroupId, DriverInstanceId, Name, IntervalMs FROM dbo.PollGroup WHERE GenerationId = @TargetGenerationId;
INSERT dbo.Tag (GenerationId, TagId, DriverInstanceId, DeviceId, EquipmentId, Name, FolderPath, DataType, AccessLevel, WriteIdempotent, PollGroupId, TagConfig)
SELECT @NewGenId, TagId, DriverInstanceId, DeviceId, EquipmentId, Name, FolderPath, DataType, AccessLevel, WriteIdempotent, PollGroupId, TagConfig FROM dbo.Tag WHERE GenerationId = @TargetGenerationId;
INSERT dbo.NodeAcl (GenerationId, NodeAclId, ClusterId, LdapGroup, ScopeKind, ScopeId, PermissionFlags, Notes)
SELECT @NewGenId, NodeAclId, ClusterId, LdapGroup, ScopeKind, ScopeId, PermissionFlags, Notes FROM dbo.NodeAcl WHERE GenerationId = @TargetGenerationId;
EXEC dbo.sp_PublishGeneration @ClusterId = @ClusterId, @DraftGenerationId = @NewGenId, @Notes = @Notes;
INSERT dbo.ConfigAuditLog (Principal, EventType, ClusterId, GenerationId, DetailsJson)
VALUES (SUSER_SNAME(), 'RolledBack', @ClusterId, @NewGenId,
CONCAT('{""rolledBackTo"":', @TargetGenerationId, '}'));
COMMIT;
END
";
public const string ComputeGenerationDiff = @"
CREATE OR ALTER PROCEDURE dbo.sp_ComputeGenerationDiff
@FromGenerationId bigint,
@ToGenerationId bigint
AS
BEGIN
SET NOCOUNT ON;
CREATE TABLE #diff (TableName nvarchar(32), LogicalId nvarchar(64), ChangeKind nvarchar(16));
WITH f AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @FromGenerationId),
t AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Namespace', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @FromGenerationId),
t AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'DriverInstance', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @FromGenerationId),
t AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Equipment', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @FromGenerationId),
t AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Tag', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
SELECT TableName, LogicalId, ChangeKind FROM #diff;
DROP TABLE #diff;
END
";
public const string ReleaseExternalIdReservation = @"
CREATE OR ALTER PROCEDURE dbo.sp_ReleaseExternalIdReservation
@Kind nvarchar(16),
@Value nvarchar(64),
@ReleaseReason nvarchar(512)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
IF @ReleaseReason IS NULL OR LEN(@ReleaseReason) = 0
BEGIN
RAISERROR('ReleaseReason is required', 16, 1);
RETURN;
END
UPDATE dbo.ExternalIdReservation
SET ReleasedAt = SYSUTCDATETIME(),
ReleasedBy = SUSER_SNAME(),
ReleaseReason = @ReleaseReason
WHERE Kind = @Kind AND Value = @Value AND ReleasedAt IS NULL;
IF @@ROWCOUNT = 0
BEGIN
RAISERROR('No active reservation found for (%s, %s)', 16, 1, @Kind, @Value);
RETURN;
END
INSERT dbo.ConfigAuditLog (Principal, EventType, DetailsJson)
VALUES (SUSER_SNAME(), 'ExternalIdReleased',
CONCAT('{""kind"":""', @Kind, '"",""value"":""', @Value, '""}'));
END
";
}
}