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);
// 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
UPDATE dc
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)
FROM DataConnections dc
WHERE dc.SiteId IS NULL;
");
"));
// Step 4: Make SiteId non-nullable
migrationBuilder.AlterColumn<int>(
@@ -22,13 +22,18 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
// Idempotent: only rows whose value starts with '[' (parameters) or that
// contain the legacy 'List' sentinel (return) are touched. Already-converted
// 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(@"
IF OBJECT_ID('dbo.fn_LegacyTypeToJsonSchemaType', 'FN') IS NOT NULL
DROP FUNCTION dbo.fn_LegacyTypeToJsonSchemaType;
");
migrationBuilder.Sql(@"
migrationBuilder.Sql(MigrationSql.Deferred(@"
CREATE FUNCTION dbo.fn_LegacyTypeToJsonSchemaType(@legacy NVARCHAR(50))
RETURNS NVARCHAR(50)
AS
@@ -53,14 +58,14 @@ BEGIN
ELSE 'string'
END;
END;
");
"));
migrationBuilder.Sql(@"
IF OBJECT_ID('dbo.fn_LegacyParametersToJsonSchema', 'FN') IS NOT NULL
DROP FUNCTION dbo.fn_LegacyParametersToJsonSchema;
");
migrationBuilder.Sql(@"
migrationBuilder.Sql(MigrationSql.Deferred(@"
CREATE FUNCTION dbo.fn_LegacyParametersToJsonSchema(@legacy NVARCHAR(MAX))
RETURNS NVARCHAR(MAX)
AS
@@ -110,14 +115,14 @@ BEGIN
END
+ '}';
END;
");
"));
migrationBuilder.Sql(@"
IF OBJECT_ID('dbo.fn_LegacyReturnToJsonSchema', 'FN') IS NOT NULL
DROP FUNCTION dbo.fn_LegacyReturnToJsonSchema;
");
migrationBuilder.Sql(@"
migrationBuilder.Sql(MigrationSql.Deferred(@"
CREATE FUNCTION dbo.fn_LegacyReturnToJsonSchema(@legacy NVARCHAR(MAX))
RETURNS NVARCHAR(MAX)
AS
@@ -142,9 +147,9 @@ BEGIN
RETURN CONCAT('{""type"":""', dbo.fn_LegacyTypeToJsonSchemaType(@legacyType), '""}');
END;
");
"));
migrationBuilder.Sql(@"
migrationBuilder.Sql(MigrationSql.Deferred(@"
UPDATE TemplateScripts
SET ParameterDefinitions = dbo.fn_LegacyParametersToJsonSchema(ParameterDefinitions)
WHERE ParameterDefinitions IS NOT NULL
@@ -174,7 +179,7 @@ UPDATE ApiMethods
SET ReturnDefinition = dbo.fn_LegacyReturnToJsonSchema(ReturnDefinition)
WHERE ReturnDefinition IS NOT NULL
AND LEFT(LTRIM(ReturnDefinition), 1) = '{';
");
"));
migrationBuilder.Sql(@"
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
// CreateIndex cannot express the ON ps_AuditLog_Month(OccurredAtUtc)
// 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
ON dbo.AuditLog (ExecutionId)
WHERE ExecutionId IS NOT NULL
ON ps_AuditLog_Month(OccurredAtUtc);");
ON ps_AuditLog_Month(OccurredAtUtc);"));
}
/// <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
// CreateIndex cannot express the ON ps_AuditLog_Month(OccurredAtUtc)
// 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
ON dbo.AuditLog (ParentExecutionId)
WHERE ParentExecutionId IS NOT NULL
ON ps_AuditLog_Month(OccurredAtUtc);");
ON ps_AuditLog_Month(OccurredAtUtc);"));
}
/// <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("'", "''") + "');";
}