feat(template-engine): contained names for composition-derived templates

A composition-derived template now stores its contained name — the
composition slot's InstanceName (e.g. "Pump"), unique only within its
owner — instead of the dotted global path ("Motor Controller.Pump").
The qualified hierarchical name is computed on read.

- TemplateNaming.QualifiedName: walks the OwnerCompositionId chain to
  build the dotted path; null-safe, cycle-guarded.
- TemplateConfiguration: the unique index on Template.Name becomes
  filtered (WHERE IsDerived = 0) — base templates stay globally unique;
  derived templates' uniqueness is the existing (TemplateId,
  InstanceName) index on TemplateComposition.
- Migration ContainedDerivedTemplateNames: rewrites derived rows to the
  contained name; Down rebuilds the dotted names via a recursive CTE
  before restoring the global index.
- TemplateService: composition create/rename store the contained name;
  the dotted-name collision pre-checks and cascade-rename are removed
  (a slot rename no longer touches nested derived templates).
- TemplateEdit: title shows the contained name; the qualified path is a
  breadcrumb subtitle; "composed inside" uses the owner's qualified name.

TDD: 4 TemplateNaming tests + updated composition tests. TemplateEngine
293, ConfigurationDatabase 114, CentralUI 316 green. Migration applied to
the dev cluster and verified in the browser (Motor Controller.Pump now
titled "Pump"; nested Motor Controller.Pump.TempSensor resolves).

Design: docs/plans/2026-05-18-contained-template-names-design.md
This commit is contained in:
Joseph Doherty
2026-05-18 17:50:30 -04:00
parent 2d4b287ab2
commit 06462a0100
11 changed files with 1662 additions and 139 deletions

View File

@@ -17,7 +17,12 @@ public class TemplateConfiguration : IEntityTypeConfiguration<Template>
builder.Property(t => t.Description)
.HasMaxLength(2000);
builder.HasIndex(t => t.Name).IsUnique();
// Only base (user-authored) templates are globally unique by name.
// Derived templates store their *contained* name (the composition slot's
// InstanceName), unique only within the owner — enforced by the
// (TemplateId, InstanceName) index on TemplateComposition — so they are
// excluded from this index via a filter.
builder.HasIndex(t => t.Name).IsUnique().HasFilter("[IsDerived] = 0");
// Self-referencing parent template (inheritance)
builder.HasOne<Template>()

View File

@@ -0,0 +1,81 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ScadaLink.ConfigurationDatabase.Migrations
{
/// <summary>
/// Moves composition-derived templates to AVEVA-style contained names: a
/// derived template stores only its slot name (e.g. <c>Pump</c>), not the
/// dotted qualified path (<c>Motor Controller.Pump</c>). The qualified name
/// is computed on read by walking the OwnerComposition chain. The unique
/// index on Template.Name becomes filtered to base templates only —
/// derived templates' uniqueness is the (TemplateId, InstanceName) index on
/// TemplateComposition.
/// </summary>
public partial class ContainedDerivedTemplateNames : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Drop the global unique index first: derived rows are about to be
// renamed to contained names that may duplicate one another or a
// base template.
migrationBuilder.DropIndex(
name: "IX_Templates_Name",
table: "Templates");
// Collapse every derived template's dotted name to its contained
// name — the owning composition slot's InstanceName.
migrationBuilder.Sql(@"
UPDATE t
SET t.Name = c.InstanceName
FROM Templates t
INNER JOIN TemplateCompositions c ON c.Id = t.OwnerCompositionId
WHERE t.IsDerived = 1;");
// Recreate the uniqueness guarantee for base templates only.
migrationBuilder.CreateIndex(
name: "IX_Templates_Name",
table: "Templates",
column: "Name",
unique: true,
filter: "[IsDerived] = 0");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Templates_Name",
table: "Templates");
// Rebuild the dotted qualified names so the global unique index can
// be restored — derived templates' contained names are not globally
// unique. The recursive CTE walks the OwnerComposition chain down
// from each base template.
migrationBuilder.Sql(@"
WITH q AS (
SELECT t.Id, CAST(t.Name AS NVARCHAR(MAX)) AS Qualified
FROM Templates t
WHERE t.IsDerived = 0
UNION ALL
SELECT t.Id, CAST(q.Qualified + N'.' + c.InstanceName AS NVARCHAR(MAX))
FROM Templates t
INNER JOIN TemplateCompositions c ON c.Id = t.OwnerCompositionId
INNER JOIN q ON q.Id = c.TemplateId
)
UPDATE t
SET t.Name = q.Qualified
FROM Templates t
INNER JOIN q ON q.Id = t.Id
WHERE t.IsDerived = 1;");
migrationBuilder.CreateIndex(
name: "IX_Templates_Name",
table: "Templates",
column: "Name",
unique: true);
}
}
}

View File

@@ -900,7 +900,8 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
b.HasIndex("FolderId");
b.HasIndex("Name")
.IsUnique();
.IsUnique()
.HasFilter("[IsDerived] = 0");
b.HasIndex("ParentTemplateId");