c7dfc21b4c
`dotnet ef migrations script --idempotent` puts each migration in ONE batch behind IF NOT EXISTS guards, and SQL Server compiles the whole batch before any guard runs. AddAuditLogHashChain adds ChainSeq and then creates two filtered indexes whose WHERE clause references it in the same batch, so the script failed with Msg 207 "Invalid column name 'ChainSeq'" on any DB at migration <= 60 (the 2026-11-11 vd03 deploy hand-inserted GO separators instead). - AddAuditLogHashChain: the two CREATE INDEX statements go through EXEC(N'...') so they compile at execution time. Same indexes, same snapshot; Migrate() / database update unaffected; DBs that already ran the migration never run it again. The column-level GRANT compiles fine (checked at execution). - Audit of 58-64: no other migration hits the pattern (the idempotent script from 57 now runs clean end to end; AddConfigGraphRowVersions' plain CREATE INDEX on a new column is not compile-checked). - docs/plans/sql/AddAuditLogHashChain.sql: same EXEC wrap. It, and PrefixSearchIndexes.sql / AddConfigGraphRowVersions.sql, also carried a stray UTF-8 BOM after the prepended header comment that made sqlcmd fail with Msg 102 on line 1 of the batch; removed. - New IdempotentMigrationScriptTests: migrates the fixture DB to 57, runs the generated idempotent script batch by batch, asserts every migration applied, then re-runs it as a no-op. Fails with Msg 207 without the migration fix. Co-Authored-By: Claude <noreply@anthropic.com>
124 lines
5.1 KiB
C#
124 lines
5.1 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// SQL Server tests for the deploy artifact itself: the <c>dotnet ef migrations script
|
|
/// --idempotent</c> output that upgrades a central configuration database (ScadaBridge#126).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// EF puts each migration of an idempotent script in ONE batch behind per-statement
|
|
/// <c>IF NOT EXISTS (… __EFMigrationsHistory …)</c> 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 — <c>database update</c> / <c>Migrate()</c> 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.
|
|
/// </remarks>
|
|
public sealed class IdempotentMigrationScriptTests : IClassFixture<MsSqlMigrationFixture>
|
|
{
|
|
// 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;
|
|
|
|
/// <summary>Creates the test class with its own migrated SQL Server database.</summary>
|
|
/// <param name="fixture">The per-class migration fixture.</param>
|
|
public IdempotentMigrationScriptTests(MsSqlMigrationFixture fixture) => _fixture = fixture;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[SkippableFact]
|
|
public async Task IdempotentScript_FromBaseline_RunsClean_AndReRunsAsANoOp()
|
|
{
|
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
|
|
|
await using var ctx = CreateContext();
|
|
var migrator = ctx.GetService<IMigrator>();
|
|
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<ScadaBridgeDbContext>()
|
|
.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<List<string>> 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<string>();
|
|
await using var reader = await cmd.ExecuteReaderAsync();
|
|
while (await reader.ReadAsync())
|
|
{
|
|
ids.Add(reader.GetString(0));
|
|
}
|
|
|
|
return ids;
|
|
}
|
|
|
|
private async Task<int> 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())!;
|
|
}
|
|
}
|