feat(templates): phase 3 — migrate existing compositions to derived

EF migration MigrateCompositionsToDerived. Aborts with a clear error if
any '<parent>.<slot>' derived name would collide with an existing
template. Otherwise it cursor-walks every TemplateComposition that still
points at a non-derived template:

  1. Insert a derived Template (name "<parent>.<slot>",
     ParentTemplateId=base, IsDerived=1, OwnerCompositionId=composition).
  2. Copy base attributes / scripts into the derived row with
     IsInherited=1, LockedInDerived=0.
  3. Repoint TemplateComposition.ComposedTemplateId at the new derived.

Idempotent: only touches compositions whose target is IsDerived=0, so
re-runs and freshly-created Phase 2 compositions are skipped.

Down() reverses by repointing compositions back to derived.ParentTemplateId
and dropping all derived templates (with cascade copy rows).
This commit is contained in:
Joseph Doherty
2026-05-12 08:30:17 -04:00
parent fa86750717
commit 03a8c4a632
2 changed files with 1417 additions and 0 deletions

View File

@@ -0,0 +1,117 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ScadaLink.ConfigurationDatabase.Migrations
{
/// <inheritdoc />
public partial class MigrateCompositionsToDerived : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Re-shape every pre-Phase-2 TemplateComposition so it points at a
// newly created derived template ("<parent>.<slot>") that inherits
// from the original base. Attribute and script rows are copied with
// IsInherited=1; the composition's ComposedTemplateId is repointed.
//
// Idempotent: only rows whose target is still IsDerived=0 are touched.
// Aborts the migration if any derived name would collide with an
// existing template, so the operator can resolve manually.
migrationBuilder.Sql(@"
SET NOCOUNT ON;
DECLARE @collisions NVARCHAR(MAX) = (
SELECT STRING_AGG(owner.Name + N'.' + c.InstanceName, N', ')
FROM TemplateCompositions c
INNER JOIN Templates base_t ON base_t.Id = c.ComposedTemplateId
INNER JOIN Templates owner ON owner.Id = c.TemplateId
INNER JOIN Templates existing ON existing.Name = owner.Name + N'.' + c.InstanceName
WHERE base_t.IsDerived = 0
);
IF @collisions IS NOT NULL
BEGIN
DECLARE @msg NVARCHAR(MAX) =
N'MigrateCompositionsToDerived: cannot create derived templates — these names already exist: '
+ @collisions
+ N'. Rename the conflicting templates and retry the migration.';
THROW 50000, @msg, 1;
END
DECLARE @CompId INT, @BaseId INT, @OwnerName NVARCHAR(200), @SlotName NVARCHAR(200);
DECLARE @NewId INT, @NewName NVARCHAR(200);
DECLARE map_cursor CURSOR FAST_FORWARD FOR
SELECT c.Id, c.ComposedTemplateId, owner.Name, c.InstanceName
FROM TemplateCompositions c
INNER JOIN Templates base_t ON base_t.Id = c.ComposedTemplateId
INNER JOIN Templates owner ON owner.Id = c.TemplateId
WHERE base_t.IsDerived = 0;
OPEN map_cursor;
FETCH NEXT FROM map_cursor INTO @CompId, @BaseId, @OwnerName, @SlotName;
WHILE @@FETCH_STATUS = 0
BEGIN
SET @NewName = @OwnerName + N'.' + @SlotName;
INSERT INTO Templates (Name, Description, ParentTemplateId, FolderId, IsDerived, OwnerCompositionId)
SELECT @NewName, b.Description, b.Id, NULL, 1, @CompId
FROM Templates b
WHERE b.Id = @BaseId;
SET @NewId = SCOPE_IDENTITY();
INSERT INTO TemplateAttributes
(TemplateId, Name, Value, DataType, IsLocked, Description, DataSourceReference, IsInherited, LockedInDerived)
SELECT @NewId, a.Name, a.Value, a.DataType, a.IsLocked, a.Description, a.DataSourceReference, 1, 0
FROM TemplateAttributes a
WHERE a.TemplateId = @BaseId;
INSERT INTO TemplateScripts
(TemplateId, Name, Code, IsLocked, TriggerType, TriggerConfiguration, ParameterDefinitions, ReturnDefinition, MinTimeBetweenRuns, IsInherited, LockedInDerived)
SELECT @NewId, s.Name, s.Code, s.IsLocked, s.TriggerType, s.TriggerConfiguration, s.ParameterDefinitions, s.ReturnDefinition, s.MinTimeBetweenRuns, 1, 0
FROM TemplateScripts s
WHERE s.TemplateId = @BaseId;
UPDATE TemplateCompositions
SET ComposedTemplateId = @NewId
WHERE Id = @CompId;
FETCH NEXT FROM map_cursor INTO @CompId, @BaseId, @OwnerName, @SlotName;
END
CLOSE map_cursor;
DEALLOCATE map_cursor;
");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// Reverse: repoint each composition back to the derived template's
// base, then drop the derived templates (with their copied rows).
migrationBuilder.Sql(@"
SET NOCOUNT ON;
UPDATE c
SET c.ComposedTemplateId = d.ParentTemplateId
FROM TemplateCompositions c
INNER JOIN Templates d ON d.Id = c.ComposedTemplateId
WHERE d.IsDerived = 1
AND d.OwnerCompositionId = c.Id
AND d.ParentTemplateId IS NOT NULL;
DELETE a FROM TemplateAttributes a
INNER JOIN Templates t ON t.Id = a.TemplateId
WHERE t.IsDerived = 1;
DELETE s FROM TemplateScripts s
INNER JOIN Templates t ON t.Id = s.TemplateId
WHERE t.IsDerived = 1;
DELETE FROM Templates WHERE IsDerived = 1;
");
}
}
}