fix(db): full-history idempotent migration script runs clean from an empty DB (#127)

`dotnet ef migrations script --idempotent` from migration 0 stopped at
AddSiteIdToDataConnections (Msg 207 'SiteId') and, past it, hit Msg 156/178/137
on MigrateParametersToJsonSchema's CREATE FUNCTIONs and Msg 207 on the
ExecutionId / ParentExecutionId filtered indexes. Same root cause as #126: the
script compiles a whole migration as one batch behind IF ... BEGIN guards.
(The Msg 1911 'Category' / later @legacy errors in the issue were cascades of
those failures and disappear once they are fixed.)

- New MigrationSql.Deferred(sql): wraps a body in EXEC(N'...') with quotes
  doubled, so it compiles at execution time as its own batch. Migrate() runs
  the identical text.
- Deferred: AddSiteIdToDataConnections step 3 (data move + cursor, DECLAREs kept
  in the same body); MigrateParametersToJsonSchema's three CREATE FUNCTIONs and
  the UPDATE that calls them; AddAuditLogExecutionId / AddAuditLogParentExecutionId
  filtered partition-aligned indexes. No model/snapshot change.
- IdempotentMigrationScriptTests: new IdempotentScript_FromEmpty_RunsClean_AndMatchesMigrate
  runs the full script on an empty DB batch by batch, re-runs it as a no-op, and
  compares a schema snapshot (columns, defaults, indexes incl. filter + data space,
  FKs, checks, modules, principals, permissions, partitioning, per-table row
  count + checksum) against a Migrate()-built DB. Fails on the unfixed
  migrations with Msg 207 'SiteId'. Batch failures now name the migration.

Schema equivalence: Migrate() before vs after this change, and the fixed
from-empty script, all produce byte-identical snapshots (939 lines); the
57->head script still runs clean and lands on the same snapshot; the seeded
data path through AddSiteIdToDataConnections + MigrateParametersToJsonSchema
gives identical rows via the fixed script and the unfixed Migrate().

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-11-12 07:25:37 -05:00
parent 7bc350818c
commit 08ef94bb21
6 changed files with 272 additions and 25 deletions
@@ -23,7 +23,9 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
nullable: true); nullable: true);
// Step 3: Migrate data from SiteDataConnectionAssignments // Step 3: Migrate data from SiteDataConnectionAssignments
migrationBuilder.Sql(@" // Deferred (EXEC) because it references SiteId, added above in the same migration:
// the idempotent script compiles the whole migration as one batch (ScadaBridge#127).
migrationBuilder.Sql(MigrationSql.Deferred(@"
-- Phase A: Assign the first site to each existing DataConnection -- Phase A: Assign the first site to each existing DataConnection
UPDATE dc UPDATE dc
SET dc.SiteId = a.SiteId SET dc.SiteId = a.SiteId
@@ -79,7 +81,7 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
SET dc.SiteId = (SELECT TOP 1 Id FROM Sites ORDER BY Id) SET dc.SiteId = (SELECT TOP 1 Id FROM Sites ORDER BY Id)
FROM DataConnections dc FROM DataConnections dc
WHERE dc.SiteId IS NULL; WHERE dc.SiteId IS NULL;
"); "));
// Step 4: Make SiteId non-nullable // Step 4: Make SiteId non-nullable
migrationBuilder.AlterColumn<int>( migrationBuilder.AlterColumn<int>(
@@ -22,13 +22,18 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
// Idempotent: only rows whose value starts with '[' (parameters) or that // Idempotent: only rows whose value starts with '[' (parameters) or that
// contain the legacy 'List' sentinel (return) are touched. Already-converted // contain the legacy 'List' sentinel (return) are touched. Already-converted
// rows are skipped. // rows are skipped.
//
// The CREATE FUNCTION bodies and the UPDATE that calls them are deferred (EXEC):
// CREATE FUNCTION must start its own batch and cannot sit inside the idempotent
// script's IF ... BEGIN guards, and the whole migration compiles as one batch
// there (ScadaBridge#127). Migrate() runs the same text unchanged.
migrationBuilder.Sql(@" migrationBuilder.Sql(@"
IF OBJECT_ID('dbo.fn_LegacyTypeToJsonSchemaType', 'FN') IS NOT NULL IF OBJECT_ID('dbo.fn_LegacyTypeToJsonSchemaType', 'FN') IS NOT NULL
DROP FUNCTION dbo.fn_LegacyTypeToJsonSchemaType; DROP FUNCTION dbo.fn_LegacyTypeToJsonSchemaType;
"); ");
migrationBuilder.Sql(@" migrationBuilder.Sql(MigrationSql.Deferred(@"
CREATE FUNCTION dbo.fn_LegacyTypeToJsonSchemaType(@legacy NVARCHAR(50)) CREATE FUNCTION dbo.fn_LegacyTypeToJsonSchemaType(@legacy NVARCHAR(50))
RETURNS NVARCHAR(50) RETURNS NVARCHAR(50)
AS AS
@@ -53,14 +58,14 @@ BEGIN
ELSE 'string' ELSE 'string'
END; END;
END; END;
"); "));
migrationBuilder.Sql(@" migrationBuilder.Sql(@"
IF OBJECT_ID('dbo.fn_LegacyParametersToJsonSchema', 'FN') IS NOT NULL IF OBJECT_ID('dbo.fn_LegacyParametersToJsonSchema', 'FN') IS NOT NULL
DROP FUNCTION dbo.fn_LegacyParametersToJsonSchema; DROP FUNCTION dbo.fn_LegacyParametersToJsonSchema;
"); ");
migrationBuilder.Sql(@" migrationBuilder.Sql(MigrationSql.Deferred(@"
CREATE FUNCTION dbo.fn_LegacyParametersToJsonSchema(@legacy NVARCHAR(MAX)) CREATE FUNCTION dbo.fn_LegacyParametersToJsonSchema(@legacy NVARCHAR(MAX))
RETURNS NVARCHAR(MAX) RETURNS NVARCHAR(MAX)
AS AS
@@ -110,14 +115,14 @@ BEGIN
END END
+ '}'; + '}';
END; END;
"); "));
migrationBuilder.Sql(@" migrationBuilder.Sql(@"
IF OBJECT_ID('dbo.fn_LegacyReturnToJsonSchema', 'FN') IS NOT NULL IF OBJECT_ID('dbo.fn_LegacyReturnToJsonSchema', 'FN') IS NOT NULL
DROP FUNCTION dbo.fn_LegacyReturnToJsonSchema; DROP FUNCTION dbo.fn_LegacyReturnToJsonSchema;
"); ");
migrationBuilder.Sql(@" migrationBuilder.Sql(MigrationSql.Deferred(@"
CREATE FUNCTION dbo.fn_LegacyReturnToJsonSchema(@legacy NVARCHAR(MAX)) CREATE FUNCTION dbo.fn_LegacyReturnToJsonSchema(@legacy NVARCHAR(MAX))
RETURNS NVARCHAR(MAX) RETURNS NVARCHAR(MAX)
AS AS
@@ -142,9 +147,9 @@ BEGIN
RETURN CONCAT('{""type"":""', dbo.fn_LegacyTypeToJsonSchemaType(@legacyType), '""}'); RETURN CONCAT('{""type"":""', dbo.fn_LegacyTypeToJsonSchemaType(@legacyType), '""}');
END; END;
"); "));
migrationBuilder.Sql(@" migrationBuilder.Sql(MigrationSql.Deferred(@"
UPDATE TemplateScripts UPDATE TemplateScripts
SET ParameterDefinitions = dbo.fn_LegacyParametersToJsonSchema(ParameterDefinitions) SET ParameterDefinitions = dbo.fn_LegacyParametersToJsonSchema(ParameterDefinitions)
WHERE ParameterDefinitions IS NOT NULL WHERE ParameterDefinitions IS NOT NULL
@@ -174,7 +179,7 @@ UPDATE ApiMethods
SET ReturnDefinition = dbo.fn_LegacyReturnToJsonSchema(ReturnDefinition) SET ReturnDefinition = dbo.fn_LegacyReturnToJsonSchema(ReturnDefinition)
WHERE ReturnDefinition IS NOT NULL WHERE ReturnDefinition IS NOT NULL
AND LEFT(LTRIM(ReturnDefinition), 1) = '{'; AND LEFT(LTRIM(ReturnDefinition), 1) = '{';
"); "));
migrationBuilder.Sql(@" migrationBuilder.Sql(@"
DROP FUNCTION IF EXISTS dbo.fn_LegacyParametersToJsonSchema; DROP FUNCTION IF EXISTS dbo.fn_LegacyParametersToJsonSchema;
@@ -35,11 +35,13 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
// Raw SQL so the index is created on the partition scheme — EF's // Raw SQL so the index is created on the partition scheme — EF's
// CreateIndex cannot express the ON ps_AuditLog_Month(OccurredAtUtc) // CreateIndex cannot express the ON ps_AuditLog_Month(OccurredAtUtc)
// clause. Mirrors IX_AuditLog_CorrelationId (filtered, aligned). // clause. Mirrors IX_AuditLog_CorrelationId (filtered, aligned).
migrationBuilder.Sql(@" // Deferred (EXEC): the filter references ExecutionId, added above in the same
// migration, and the idempotent script compiles it as one batch (ScadaBridge#127).
migrationBuilder.Sql(MigrationSql.Deferred(@"
CREATE NONCLUSTERED INDEX IX_AuditLog_Execution CREATE NONCLUSTERED INDEX IX_AuditLog_Execution
ON dbo.AuditLog (ExecutionId) ON dbo.AuditLog (ExecutionId)
WHERE ExecutionId IS NOT NULL WHERE ExecutionId IS NOT NULL
ON ps_AuditLog_Month(OccurredAtUtc);"); ON ps_AuditLog_Month(OccurredAtUtc);"));
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -37,11 +37,13 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
// Raw SQL so the index is created on the partition scheme — EF's // Raw SQL so the index is created on the partition scheme — EF's
// CreateIndex cannot express the ON ps_AuditLog_Month(OccurredAtUtc) // CreateIndex cannot express the ON ps_AuditLog_Month(OccurredAtUtc)
// clause. Mirrors IX_AuditLog_Execution (filtered, aligned). // clause. Mirrors IX_AuditLog_Execution (filtered, aligned).
migrationBuilder.Sql(@" // Deferred (EXEC): the filter references ParentExecutionId, added above in the same
// migration, and the idempotent script compiles it as one batch (ScadaBridge#127).
migrationBuilder.Sql(MigrationSql.Deferred(@"
CREATE NONCLUSTERED INDEX IX_AuditLog_ParentExecution CREATE NONCLUSTERED INDEX IX_AuditLog_ParentExecution
ON dbo.AuditLog (ParentExecutionId) ON dbo.AuditLog (ParentExecutionId)
WHERE ParentExecutionId IS NOT NULL WHERE ParentExecutionId IS NOT NULL
ON ps_AuditLog_Month(OccurredAtUtc);"); ON ps_AuditLog_Month(OccurredAtUtc);"));
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -0,0 +1,34 @@
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations;
/// <summary>
/// Helpers for raw-SQL migration bodies that must survive the
/// <c>dotnet ef migrations script --idempotent</c> output (ScadaBridge#126, #127).
/// </summary>
/// <remarks>
/// <para>
/// The idempotent script puts a whole migration in ONE batch behind per-operation
/// <c>IF NOT EXISTS (… __EFMigrationsHistory …) BEGIN … END</c> guards, and SQL Server
/// compiles the whole batch before any guard runs. Three things therefore break there that
/// <c>database update</c> / <c>Migrate()</c> (one command at a time) never see:
/// </para>
/// <list type="bullet">
/// <item>a statement referencing a column added earlier in the same migration (Msg 207 / 1911);</item>
/// <item><c>CREATE FUNCTION</c> / <c>VIEW</c> / <c>PROCEDURE</c> / <c>TRIGGER</c>, which must be the
/// first statement of a batch and cannot sit inside <c>IF … BEGIN</c> (Msg 156 / 178);</item>
/// <item>a <c>DECLARE</c> whose variable is used from a later guarded block (Msg 137).</item>
/// </list>
/// <para>
/// <see cref="Deferred"/> wraps the body in <c>EXEC(N'…')</c> so it is compiled at execution
/// time, after the earlier operations of the migration have run, as its own batch. The wrapped
/// text is executed verbatim (single quotes are doubled for the literal and undone by the
/// parser), so the effect on <c>Migrate()</c> is unchanged. Keep each <c>DECLARE</c> and every
/// use of the variable in the SAME deferred body.
/// </para>
/// </remarks>
internal static class MigrationSql
{
/// <summary>Wraps <paramref name="sql"/> in <c>EXEC(N'…')</c> with single quotes escaped.</summary>
/// <param name="sql">The T-SQL body to execute as its own dynamic batch.</param>
/// <returns>The <c>EXEC(N'…');</c> statement.</returns>
public static string Deferred(string sql) => "EXEC(N'" + sql.Replace("'", "''") + "');";
}
@@ -16,8 +16,9 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
/// batch before any guard runs. A raw-SQL statement that references a column added earlier in /// 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 /// 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 /// 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 /// send one command at a time. The baseline test migrates the fixture database down and back
/// back up, so they own their fixture and leave the database fully migrated. /// 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.
/// </remarks> /// </remarks>
public sealed class IdempotentMigrationScriptTests : IClassFixture<MsSqlMigrationFixture> public sealed class IdempotentMigrationScriptTests : IClassFixture<MsSqlMigrationFixture>
{ {
@@ -27,6 +28,8 @@ public sealed class IdempotentMigrationScriptTests : IClassFixture<MsSqlMigratio
private static readonly Regex BatchSeparator = new(@"^\s*GO\s*$", RegexOptions.Multiline | RegexOptions.IgnoreCase); 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; private readonly MsSqlMigrationFixture _fixture;
/// <summary>Creates the test class with its own migrated SQL Server database.</summary> /// <summary>Creates the test class with its own migrated SQL Server database.</summary>
@@ -70,13 +73,199 @@ public sealed class IdempotentMigrationScriptTests : IClassFixture<MsSqlMigratio
} }
} }
private ScadaBridgeDbContext CreateContext() /// <summary>
=> new(new DbContextOptionsBuilder<ScadaBridgeDbContext>() /// From an EMPTY database (a fresh install done from the generated script) the full-history
.UseSqlServer(_fixture.ConnectionString, sql => sql.CommandTimeout(600)).Options); /// idempotent script runs batch by batch without an error, applies every migration, re-runs
/// as a no-op, and leaves exactly the schema <c>Migrate()</c> builds (ScadaBridge#127).
private async Task RunScriptAsync(string script) /// </summary>
/// <remarks>
/// Early migrations carry raw SQL that only <c>Migrate()</c> could run: references to columns
/// added earlier in the same migration, <c>CREATE FUNCTION</c> inside the script's
/// <c>IF … BEGIN</c> guards, and <c>DECLARE</c>d variables. They now go through
/// <c>MigrationSql.Deferred</c>. The schema comparison
/// guards the other half of that fix: the script path and the <c>Migrate()</c> path must
/// converge on the same tables, columns, defaults, indexes, keys, constraints, modules,
/// principals, permissions, partitioning and seed-row counts.
/// </remarks>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task IdempotentScript_FromEmpty_RunsClean_AndMatchesMigrate()
{ {
await using var conn = new SqlConnection(_fixture.ConnectionString); 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<IMigrator>().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<ScadaBridgeDbContext>()
.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).
}
}
/// <summary>
/// One sorted line per schema fact. System-generated constraint names (DF__…_hex) are
/// masked; everything else, including module bodies (hashed), is compared verbatim.
/// </summary>
private static async Task<List<string>> 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<List<string>> ReadLinesAsync(SqlConnection conn, string sql)
{
await using var cmd = conn.CreateCommand();
cmd.CommandTimeout = 120;
cmd.CommandText = sql;
var lines = new List<string>();
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 '<sys>' 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,'<heap>') + '|' + 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 '<sys>' 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(); await conn.OpenAsync();
foreach (var batch in BatchSeparator.Split(script)) foreach (var batch in BatchSeparator.Split(script))
{ {
@@ -88,13 +277,26 @@ public sealed class IdempotentMigrationScriptTests : IClassFixture<MsSqlMigratio
await using var cmd = conn.CreateCommand(); await using var cmd = conn.CreateCommand();
cmd.CommandTimeout = 600; cmd.CommandTimeout = 600;
cmd.CommandText = batch; cmd.CommandText = batch;
await cmd.ExecuteNonQueryAsync(); 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 async Task<List<string>> ReadHistoryAsync() private Task<List<string>> ReadHistoryAsync() => ReadHistoryAsync(_fixture.ConnectionString);
private static async Task<List<string>> ReadHistoryAsync(string connectionString)
{ {
await using var conn = new SqlConnection(_fixture.ConnectionString); await using var conn = new SqlConnection(connectionString);
await conn.OpenAsync(); await conn.OpenAsync();
await using var cmd = conn.CreateCommand(); await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT MigrationId FROM __EFMigrationsHistory ORDER BY MigrationId"; cmd.CommandText = "SELECT MigrationId FROM __EFMigrationsHistory ORDER BY MigrationId";