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:
@@ -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>()
|
||||
|
||||
1351
src/ScadaLink.ConfigurationDatabase/Migrations/20260518214444_ContainedDerivedTemplateNames.Designer.cs
generated
Normal file
1351
src/ScadaLink.ConfigurationDatabase/Migrations/20260518214444_ContainedDerivedTemplateNames.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -900,7 +900,8 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
|
||||
b.HasIndex("FolderId");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
.IsUnique()
|
||||
.HasFilter("[IsDerived] = 0");
|
||||
|
||||
b.HasIndex("ParentTemplateId");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user