diff --git a/docs/plans/sql/AddAuditLogHashChain.sql b/docs/plans/sql/AddAuditLogHashChain.sql index 7818ca7c..47dbe686 100644 --- a/docs/plans/sql/AddAuditLogHashChain.sql +++ b/docs/plans/sql/AddAuditLogHashChain.sql @@ -2,7 +2,9 @@ -- ChainSeq/RowHash on dbo.AuditLog, AuditLogChainHead + AuditLogChainTombstone, two partition-aligned -- filtered indexes, the scadabridge_audit_chainer role. Apply to the central configuration database -- with both central nodes stopped (old binaries cannot switch out a partition once the columns exist). -BEGIN TRANSACTION; +-- The two filtered indexes reference ChainSeq, which this same batch adds; SQL Server compiles a batch +-- before running it, so they are wrapped in EXEC to defer compilation (else Msg 207, ScadaBridge#126). +BEGIN TRANSACTION; ALTER TABLE [AuditLog] ADD [ChainSeq] bigint NULL; ALTER TABLE [AuditLog] ADD [RowHash] binary(32) NULL; @@ -27,16 +29,16 @@ CREATE TABLE [AuditLogChainTombstone] ( ); -CREATE NONCLUSTERED INDEX IX_AuditLog_Unchained +EXEC(N'CREATE NONCLUSTERED INDEX IX_AuditLog_Unchained ON dbo.AuditLog (OccurredAtUtc, EventId) WHERE ChainSeq IS NULL -ON ps_AuditLog_Month(OccurredAtUtc); +ON ps_AuditLog_Month(OccurredAtUtc);'); -CREATE NONCLUSTERED INDEX IX_AuditLog_ChainSeq +EXEC(N'CREATE NONCLUSTERED INDEX IX_AuditLog_ChainSeq ON dbo.AuditLog (ChainSeq) WHERE ChainSeq IS NOT NULL -ON ps_AuditLog_Month(OccurredAtUtc); +ON ps_AuditLog_Month(OccurredAtUtc);'); IF DATABASE_PRINCIPAL_ID('scadabridge_audit_chainer') IS NULL diff --git a/docs/plans/sql/AddConfigGraphRowVersions.sql b/docs/plans/sql/AddConfigGraphRowVersions.sql index 287edd7d..735d5bb8 100644 --- a/docs/plans/sql/AddConfigGraphRowVersions.sql +++ b/docs/plans/sql/AddConfigGraphRowVersions.sql @@ -3,7 +3,7 @@ -- MAX(RowVersion)+COUNT_BIG(*) over them is unchanged. Additive: SQL Server fills every existing row as the -- column is added; old binaries ignore the column. Apply after AddAuditLogHashChain in the coordinated -- central-pair migration window (ScadaBridge#107 item 4). -BEGIN TRANSACTION; +BEGIN TRANSACTION; ALTER TABLE [TemplateScripts] ADD [RowVersion] rowversion NOT NULL; ALTER TABLE [Templates] ADD [RowVersion] rowversion NOT NULL; diff --git a/docs/plans/sql/PrefixSearchIndexes.sql b/docs/plans/sql/PrefixSearchIndexes.sql index ebd06890..03bf6e02 100644 --- a/docs/plans/sql/PrefixSearchIndexes.sql +++ b/docs/plans/sql/PrefixSearchIndexes.sql @@ -1,6 +1,6 @@ -- PrefixSearchIndexes (2026-11-09, ScadaBridge#50). Indexes backing the prefix search on the -- Deployments page (DeployedBy, RevisionHash) and the config-audit EntityName filter. -BEGIN TRANSACTION; +BEGIN TRANSACTION; ALTER TABLE [TemplateNativeAlarmSources] ADD [IsMesReportable] bit NOT NULL DEFAULT CAST(0 AS bit); ALTER TABLE [InstanceNativeAlarmSourceOverrides] ADD [IsMesReportableOverride] bit NULL; diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20261109211317_AddAuditLogHashChain.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20261109211317_AddAuditLogHashChain.cs index 3828f011..a0d830a2 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20261109211317_AddAuditLogHashChain.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20261109211317_AddAuditLogHashChain.cs @@ -77,17 +77,24 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations // AuditLog is monthly-partitioned with a partition-switch purge, so both indexes // MUST be partition-aligned (same pattern as ReviewRemediationIndexes). + // + // Both statements reference ChainSeq, which this same migration adds. The + // `dotnet ef migrations script --idempotent` output puts a whole migration in ONE + // batch, and SQL Server compiles the batch before any IF guard runs — so a direct + // reference to the not-yet-existing column fails with Msg 207 (ScadaBridge#126). + // EXEC defers compilation to execution time, after the ADD COLUMN has run. The + // resulting indexes are identical; `database update` / Migrate() are unaffected. migrationBuilder.Sql(@" -CREATE NONCLUSTERED INDEX IX_AuditLog_Unchained +EXEC(N'CREATE NONCLUSTERED INDEX IX_AuditLog_Unchained ON dbo.AuditLog (OccurredAtUtc, EventId) WHERE ChainSeq IS NULL -ON ps_AuditLog_Month(OccurredAtUtc);"); +ON ps_AuditLog_Month(OccurredAtUtc);');"); migrationBuilder.Sql(@" -CREATE NONCLUSTERED INDEX IX_AuditLog_ChainSeq +EXEC(N'CREATE NONCLUSTERED INDEX IX_AuditLog_ChainSeq ON dbo.AuditLog (ChainSeq) WHERE ChainSeq IS NOT NULL -ON ps_AuditLog_Month(OccurredAtUtc);"); +ON ps_AuditLog_Month(OccurredAtUtc);');"); migrationBuilder.Sql(@" IF DATABASE_PRINCIPAL_ID('scadabridge_audit_chainer') IS NULL diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Migrations/IdempotentMigrationScriptTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Migrations/IdempotentMigrationScriptTests.cs new file mode 100644 index 00000000..074d1a0c --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Migrations/IdempotentMigrationScriptTests.cs @@ -0,0 +1,123 @@ +using System.Text.RegularExpressions; +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations; + +/// +/// SQL Server tests for the deploy artifact itself: the dotnet ef migrations script +/// --idempotent output that upgrades a central configuration database (ScadaBridge#126). +/// +/// +/// EF puts each migration of an idempotent script in ONE batch behind per-statement +/// IF NOT EXISTS (… __EFMigrationsHistory …) guards, and SQL Server compiles the whole +/// batch before any guard runs. A raw-SQL statement that references a column added earlier in +/// the same migration therefore fails with Msg 207 even though the guard would have let the +/// ADD COLUMN run first — database update / Migrate() never see it because they +/// send one command at a time. The tests in this class migrate the fixture database down and +/// back up, so they own their fixture and leave the database fully migrated. +/// +public sealed class IdempotentMigrationScriptTests : IClassFixture +{ + // The last migration deployed everywhere before the 2026-11 central-pair migration window + // (migration 57). The idempotent script from here to head is what DEV/QA/prod run. + private const string BaselineMigration = "20260914173147_AddSiteArtifactRevision"; + + private static readonly Regex BatchSeparator = new(@"^\s*GO\s*$", RegexOptions.Multiline | RegexOptions.IgnoreCase); + + private readonly MsSqlMigrationFixture _fixture; + + /// Creates the test class with its own migrated SQL Server database. + /// The per-class migration fixture. + public IdempotentMigrationScriptTests(MsSqlMigrationFixture fixture) => _fixture = fixture; + + /// + /// From the migration-57 baseline the generated idempotent script runs batch by batch + /// without an error, applies every pending migration, and a second run is a no-op. + /// + /// A task that represents the asynchronous operation. + [SkippableFact] + public async Task IdempotentScript_FromBaseline_RunsClean_AndReRunsAsANoOp() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + await using var ctx = CreateContext(); + var migrator = ctx.GetService(); + var all = ctx.Database.GetMigrations().ToList(); + var script = migrator.GenerateScript( + fromMigration: BaselineMigration, + options: MigrationsSqlGenerationOptions.Idempotent); + + try + { + await migrator.MigrateAsync(BaselineMigration); + Assert.Equal(all.IndexOf(BaselineMigration) + 1, (await ReadHistoryAsync()).Count); + + await RunScriptAsync(script); + Assert.Equal(all, await ReadHistoryAsync()); + Assert.Equal(2, await CountChainIndexesAsync()); + + // Second run against the fully-migrated database: every guard is false. + await RunScriptAsync(script); + Assert.Equal(all, await ReadHistoryAsync()); + } + finally + { + // Leave the fixture database fully migrated whatever happened above. + await migrator.MigrateAsync(); + } + } + + private ScadaBridgeDbContext CreateContext() + => new(new DbContextOptionsBuilder() + .UseSqlServer(_fixture.ConnectionString, sql => sql.CommandTimeout(600)).Options); + + private async Task RunScriptAsync(string script) + { + await using var conn = new SqlConnection(_fixture.ConnectionString); + await conn.OpenAsync(); + foreach (var batch in BatchSeparator.Split(script)) + { + if (string.IsNullOrWhiteSpace(batch)) + { + continue; + } + + await using var cmd = conn.CreateCommand(); + cmd.CommandTimeout = 600; + cmd.CommandText = batch; + await cmd.ExecuteNonQueryAsync(); + } + } + + private async Task> ReadHistoryAsync() + { + await using var conn = new SqlConnection(_fixture.ConnectionString); + await conn.OpenAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT MigrationId FROM __EFMigrationsHistory ORDER BY MigrationId"; + var ids = new List(); + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + ids.Add(reader.GetString(0)); + } + + return ids; + } + + private async Task CountChainIndexesAsync() + { + await using var conn = new SqlConnection(_fixture.ConnectionString); + await conn.OpenAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT COUNT(*) FROM sys.indexes + WHERE object_id = OBJECT_ID('dbo.AuditLog') + AND name IN ('IX_AuditLog_Unchained', 'IX_AuditLog_ChainSeq') + """; + return (int)(await cmd.ExecuteScalarAsync())!; + } +}