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:
+4
-2
@@ -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>(
|
||||
|
||||
+13
-8
@@ -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;
|
||||
|
||||
+4
-2
@@ -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 />
|
||||
|
||||
+4
-2
@@ -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("'", "''") + "');";
|
||||
}
|
||||
Reference in New Issue
Block a user