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 baseline test migrates the fixture database down and back
/// up, so this class owns its fixture and leaves the database fully migrated; the from-empty
/// test (ScadaBridge#127) builds and drops two databases of its own next to the fixture's.
///
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 static readonly Regex MigrationIdInBatch = new(@"\[MigrationId\] = N'([^']+)'");
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();
}
}
///
/// From an EMPTY database (a fresh install done from the generated script) the full-history
/// idempotent script runs batch by batch without an error, applies every migration, re-runs
/// as a no-op, and leaves exactly the schema Migrate() builds (ScadaBridge#127).
///
///
/// Early migrations carry raw SQL that only Migrate() could run: references to columns
/// added earlier in the same migration, CREATE FUNCTION inside the script's
/// IF … BEGIN guards, and DECLAREd variables. They now go through
/// MigrationSql.Deferred. The schema comparison
/// guards the other half of that fix: the script path and the Migrate() path must
/// converge on the same tables, columns, defaults, indexes, keys, constraints, modules,
/// principals, permissions, partitioning and seed-row counts.
///
/// A task that represents the asynchronous operation.
[SkippableFact]
public async Task IdempotentScript_FromEmpty_RunsClean_AndMatchesMigrate()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var scriptDb = _fixture.DatabaseName + "_script";
var migrateDb = _fixture.DatabaseName + "_migrate";
var scriptConn = ForDatabase(scriptDb);
var migrateConn = ForDatabase(migrateDb);
await using var ctx = CreateContext();
var all = ctx.Database.GetMigrations().ToList();
var script = ctx.GetService().GenerateScript(
options: MigrationsSqlGenerationOptions.Idempotent);
try
{
await CreateEmptyDatabaseAsync(scriptDb);
await CreateEmptyDatabaseAsync(migrateDb);
await RunScriptAsync(scriptConn, script);
Assert.Equal(all, await ReadHistoryAsync(scriptConn));
// Second run against the fully-migrated database: every guard is false.
await RunScriptAsync(scriptConn, script);
Assert.Equal(all, await ReadHistoryAsync(scriptConn));
await using (var migrateCtx = CreateContext(migrateConn))
{
await migrateCtx.Database.MigrateAsync();
}
var expected = await ReadSchemaSnapshotAsync(migrateConn);
var actual = await ReadSchemaSnapshotAsync(scriptConn);
Assert.True(expected.Count > 500, $"schema snapshot looks empty ({expected.Count} lines)");
var onlyMigrate = expected.Except(actual, StringComparer.Ordinal).ToList();
var onlyScript = actual.Except(expected, StringComparer.Ordinal).ToList();
Assert.True(
onlyMigrate.Count == 0 && onlyScript.Count == 0,
"Schema differs between Migrate() and the idempotent script." +
$"\nOnly via Migrate():\n {string.Join("\n ", onlyMigrate)}" +
$"\nOnly via script:\n {string.Join("\n ", onlyScript)}");
}
finally
{
await DropDatabaseAsync(scriptDb);
await DropDatabaseAsync(migrateDb);
}
}
private ScadaBridgeDbContext CreateContext() => CreateContext(_fixture.ConnectionString);
private static ScadaBridgeDbContext CreateContext(string connectionString)
=> new(new DbContextOptionsBuilder()
.UseSqlServer(connectionString, sql => sql.CommandTimeout(600)).Options);
private string ForDatabase(string database)
=> new SqlConnectionStringBuilder(_fixture.ConnectionString) { InitialCatalog = database }.ConnectionString;
private async Task CreateEmptyDatabaseAsync(string database)
{
await using var conn = new SqlConnection(ForDatabase("master"));
await conn.OpenAsync();
await using var cmd = conn.CreateCommand();
cmd.CommandTimeout = 120;
// Name is the fixture's guid-suffixed name plus a fixed suffix: no injection surface.
cmd.CommandText = $"CREATE DATABASE [{database}];";
await cmd.ExecuteNonQueryAsync();
}
private async Task DropDatabaseAsync(string database)
{
try
{
SqlConnection.ClearAllPools();
await using var conn = new SqlConnection(ForDatabase("master"));
await conn.OpenAsync();
await using var cmd = conn.CreateCommand();
cmd.CommandTimeout = 120;
cmd.CommandText =
$"IF DB_ID(N'{database}') IS NOT NULL BEGIN " +
$"ALTER DATABASE [{database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{database}]; END";
await cmd.ExecuteNonQueryAsync();
}
catch (SqlException)
{
// Best-effort: the fixture's orphan sweep clears anything left behind (same prefix).
}
}
///
/// One sorted line per schema fact. System-generated constraint names (DF__…_hex) are
/// masked; everything else, including module bodies (hashed), is compared verbatim.
///
private static async Task> ReadSchemaSnapshotAsync(string connectionString)
{
await using var conn = new SqlConnection(connectionString);
await conn.OpenAsync();
var lines = await ReadLinesAsync(conn, SchemaSnapshotSql);
lines.AddRange(await ReadLinesAsync(conn, RowCountSnapshotSql));
lines.Sort(StringComparer.Ordinal);
return lines;
}
private static async Task> ReadLinesAsync(SqlConnection conn, string sql)
{
await using var cmd = conn.CreateCommand();
cmd.CommandTimeout = 120;
cmd.CommandText = sql;
var lines = new List();
await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
lines.Add(reader.GetString(0));
}
return lines;
}
private const string SchemaSnapshotSql = """
SET NOCOUNT ON;
CREATE TABLE #t(line nvarchar(max) COLLATE Latin1_General_BIN);
INSERT #t(line)
SELECT 'COL|' + s.name + '.' + t.name + '|' + CAST(c.column_id AS varchar) + '|' + c.name + '|' + ty.name + '|' + CAST(c.max_length AS varchar) + '|' + CAST(c.precision AS varchar) + '|' + CAST(c.scale AS varchar) + '|' + CAST(c.is_nullable AS varchar) + '|' + CAST(c.is_identity AS varchar) + '|' + ISNULL(c.collation_name,'') + '|' + ISNULL(cc.definition,'') + '|' + ISNULL(CASE WHEN dc.is_system_named=1 THEN '' ELSE dc.name END,'') + '|' + ISNULL(dc.definition,'')
FROM sys.tables t JOIN sys.schemas s ON s.schema_id=t.schema_id JOIN sys.columns c ON c.object_id=t.object_id
JOIN sys.types ty ON ty.user_type_id=c.user_type_id
LEFT JOIN sys.computed_columns cc ON cc.object_id=c.object_id AND cc.column_id=c.column_id
LEFT JOIN sys.default_constraints dc ON dc.object_id=c.default_object_id;
INSERT #t(line)
SELECT 'IDX|' + s.name + '.' + o.name + '|' + ISNULL(i.name,'') + '|' + i.type_desc + '|' + CAST(i.is_unique AS varchar) + '|' + CAST(i.is_primary_key AS varchar) + '|' + CAST(i.is_unique_constraint AS varchar) + '|' + ISNULL(i.filter_definition,'') + '|' + ISNULL((SELECT name FROM sys.data_spaces ds WHERE ds.data_space_id=i.data_space_id),'') + '|' +
ISNULL(STUFF((SELECT ',' + c.name + CASE WHEN ic.is_descending_key=1 THEN ' DESC' ELSE '' END + CASE WHEN ic.is_included_column=1 THEN ' INCL' ELSE '' END
FROM sys.index_columns ic JOIN sys.columns c ON c.object_id=ic.object_id AND c.column_id=ic.column_id
WHERE ic.object_id=i.object_id AND ic.index_id=i.index_id ORDER BY ic.is_included_column, ic.key_ordinal, c.name FOR XML PATH('')),1,1,''),'')
FROM sys.indexes i JOIN sys.objects o ON o.object_id=i.object_id JOIN sys.schemas s ON s.schema_id=o.schema_id WHERE o.is_ms_shipped=0;
INSERT #t(line)
SELECT 'FK|' + fk.name + '|' + OBJECT_SCHEMA_NAME(fk.parent_object_id) + '.' + OBJECT_NAME(fk.parent_object_id) + '->' + OBJECT_SCHEMA_NAME(fk.referenced_object_id) + '.' + OBJECT_NAME(fk.referenced_object_id) + '|' + fk.delete_referential_action_desc + '|' + fk.update_referential_action_desc + '|' +
STUFF((SELECT ',' + COL_NAME(fkc.parent_object_id,fkc.parent_column_id) + '=' + COL_NAME(fkc.referenced_object_id,fkc.referenced_column_id) FROM sys.foreign_key_columns fkc WHERE fkc.constraint_object_id=fk.object_id ORDER BY fkc.constraint_column_id FOR XML PATH('')),1,1,'')
FROM sys.foreign_keys fk;
INSERT #t(line)
SELECT 'CK|' + OBJECT_NAME(parent_object_id) + '|' + CASE WHEN is_system_named=1 THEN '' ELSE name END + '|' + definition FROM sys.check_constraints;
INSERT #t(line)
SELECT 'OBJ|' + o.type_desc + '|' + s.name + '.' + o.name + '|' + ISNULL(OBJECT_NAME(o.parent_object_id),'') FROM sys.objects o JOIN sys.schemas s ON s.schema_id=o.schema_id WHERE o.is_ms_shipped=0 AND o.type NOT IN ('D','C');
INSERT #t(line)
SELECT 'MOD|' + OBJECT_SCHEMA_NAME(m.object_id) + '.' + OBJECT_NAME(m.object_id) + '|' + CONVERT(varchar(64), HASHBYTES('SHA2_256', m.definition), 2) + '|' + CAST(m.uses_ansi_nulls AS varchar) + CAST(m.uses_quoted_identifier AS varchar) + '|' + ISNULL(CAST(m.is_schema_bound AS varchar),'') + '|' + ISNULL(USER_NAME(m.execute_as_principal_id),'') FROM sys.sql_modules m;
INSERT #t(line)
SELECT 'SCH|' + s.name + '|' + USER_NAME(s.principal_id) FROM sys.schemas s WHERE s.schema_id < 16384;
INSERT #t(line)
SELECT 'PRN|' + p.name + '|' + p.type_desc + '|' + ISNULL(p.default_schema_name,'') FROM sys.database_principals p WHERE p.principal_id BETWEEN 5 AND 16383;
INSERT #t(line)
SELECT 'ROLEMEM|' + USER_NAME(role_principal_id) + '|' + USER_NAME(member_principal_id) FROM sys.database_role_members;
INSERT #t(line)
SELECT 'PERM|' + USER_NAME(dp.grantee_principal_id) + '|' + dp.class_desc + '|' + ISNULL(CASE dp.class WHEN 1 THEN OBJECT_SCHEMA_NAME(dp.major_id) + '.' + OBJECT_NAME(dp.major_id) WHEN 3 THEN SCHEMA_NAME(dp.major_id) END,'') + '|' + ISNULL(COL_NAME(dp.major_id, NULLIF(dp.minor_id,0)),'') + '|' + dp.permission_name + '|' + dp.state_desc FROM sys.database_permissions dp WHERE dp.grantee_principal_id > 4 OR dp.class <> 0;
INSERT #t(line)
SELECT 'SEQ|' + name + '|' + CAST(start_value AS varchar) FROM sys.sequences;
INSERT #t(line)
SELECT 'TT|' + name FROM sys.types WHERE is_user_defined=1;
INSERT #t(line)
SELECT 'PF|' + pf.name + '|' + pf.type_desc + '|' + CAST(pf.boundary_value_on_right AS varchar) + '|' + ISNULL(STUFF((SELECT ',' + CONVERT(nvarchar(100), prv.value, 126) FROM sys.partition_range_values prv WHERE prv.function_id=pf.function_id ORDER BY prv.boundary_id FOR XML PATH('')),1,1,''),'') FROM sys.partition_functions pf;
INSERT #t(line)
SELECT 'PS|' + ps.name + '|' + pf.name FROM sys.partition_schemes ps JOIN sys.partition_functions pf ON pf.function_id=ps.function_id;
SELECT line FROM #t ORDER BY line;
""";
// Row count + content checksum per table (seed rows such as LdapGroupMappings).
private const string RowCountSnapshotSql = """
SET NOCOUNT ON;
DECLARE @sql nvarchar(max) = N'';
SELECT @sql = @sql + N'SELECT ''DATA|' + s.name + '.' + t.name + '|'' + CAST(COUNT(*) AS varchar) + ''|'' + ISNULL(CAST(CHECKSUM_AGG(BINARY_CHECKSUM(*)) AS varchar),'''') FROM ' + QUOTENAME(s.name) + '.' + QUOTENAME(t.name) + N' UNION ALL '
FROM sys.tables t JOIN sys.schemas s ON s.schema_id=t.schema_id ORDER BY s.name, t.name;
SET @sql = LEFT(@sql, LEN(@sql) - 10) + N' ORDER BY 1';
EXEC(@sql);
""";
private Task RunScriptAsync(string script) => RunScriptAsync(_fixture.ConnectionString, script);
private static async Task RunScriptAsync(string connectionString, string script)
{
await using var conn = new SqlConnection(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;
try
{
await cmd.ExecuteNonQueryAsync();
}
catch (SqlException ex)
{
// Name the migration so a failure points straight at the file to fix.
var migration = MigrationIdInBatch.Match(batch);
throw new InvalidOperationException(
$"Idempotent script batch for migration '{(migration.Success ? migration.Groups[1].Value : "?")}' " +
$"failed: Msg {ex.Number}: {ex.Message}", ex);
}
}
}
private Task> ReadHistoryAsync() => ReadHistoryAsync(_fixture.ConnectionString);
private static async Task> ReadHistoryAsync(string connectionString)
{
await using var conn = new SqlConnection(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())!;
}
}