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())!; } }