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

@@ -359,7 +359,7 @@ public class TemplateServiceTests
Assert.Equal("myModule", result.Value.InstanceName);
Assert.NotNull(captured);
Assert.True(captured!.IsDerived);
Assert.Equal("Parent.myModule", captured.Name);
Assert.Equal("myModule", captured.Name); // contained (slot) name, not the qualified path
Assert.Equal(2, captured.ParentTemplateId);
Assert.Single(captured.Attributes);
Assert.True(captured.Attributes.First().IsInherited);
@@ -372,13 +372,13 @@ public class TemplateServiceTests
[Fact]
public async Task AddComposition_CascadesChildCompositions()
{
// $Probe (base) → $Sensor.Probe1 (derived) ← $Sensor composes "Probe1"
// Composing $Sensor into $Pump as "TempSensor" should produce:
// $Pump.TempSensor (derived from $Sensor)
// $Pump.TempSensor.Probe1 (derived from $Sensor.Probe1)
// plus a composition row on $Pump.TempSensor pointing at the new inner derived.
// $Probe (base) → $Sensor's "Probe1" derived ← $Sensor composes "Probe1"
// Composing $Sensor into $Pump as "TempSensor" should produce two derived
// templates whose stored names are their *contained* names (the slot
// names) — "TempSensor" and "Probe1" — plus a composition row on the
// TempSensor derived pointing at the new inner derived.
var probe = new Template("Probe") { Id = 10 };
var sensorProbe1 = new Template("Sensor.Probe1") { Id = 11, IsDerived = true, ParentTemplateId = 10, OwnerCompositionId = 1 };
var sensorProbe1 = new Template("Probe1") { Id = 11, IsDerived = true, ParentTemplateId = 10, OwnerCompositionId = 1 };
var sensor = new Template("Sensor") { Id = 2 };
sensor.Compositions.Add(new TemplateComposition("Probe1") { Id = 1, TemplateId = 2, ComposedTemplateId = 11 });
var pump = new Template("Pump") { Id = 1 };
@@ -403,9 +403,9 @@ public class TemplateServiceTests
Assert.True(result.IsSuccess);
Assert.Equal(2, captured.Count);
Assert.Equal("Pump.TempSensor", captured[0].Name);
Assert.Equal("TempSensor", captured[0].Name);
Assert.Equal(2, captured[0].ParentTemplateId);
Assert.Equal("Pump.TempSensor.Probe1", captured[1].Name);
Assert.Equal("Probe1", captured[1].Name);
Assert.Equal(11, captured[1].ParentTemplateId);
Assert.Equal(2, capturedCompositions.Count);
Assert.Equal("TempSensor", capturedCompositions[0].InstanceName);
@@ -428,9 +428,13 @@ public class TemplateServiceTests
}
[Fact]
public async Task AddComposition_DerivedNameCollision_Fails()
public async Task AddComposition_DerivedContainedName_NeedNotBeGloballyUnique()
{
var existing = new Template("Parent.myModule") { Id = 99 };
// A derived template's stored name is its contained (slot) name, which
// is only unique within its owner — it may freely coincide with an
// unrelated base template's name. Composing a module as the slot
// "myModule" succeeds even though a base template "myModule" exists.
var existing = new Template("myModule") { Id = 99 };
var moduleTemplate = new Template("Module") { Id = 2 };
var template = new Template("Parent") { Id = 1 };
@@ -439,92 +443,67 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template, moduleTemplate, existing });
var captured = new List<Template>();
_repoMock.Setup(r => r.AddTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()))
.Callback<Template, CancellationToken>((t, _) => captured.Add(t))
.Returns(Task.CompletedTask);
var result = await _service.AddCompositionAsync(1, 2, "myModule", "admin");
Assert.True(result.IsFailure);
Assert.Contains("already exists", result.Error);
Assert.True(result.IsSuccess);
Assert.Single(captured);
Assert.Equal("myModule", captured[0].Name);
}
[Fact]
public async Task RenameComposition_RenamesSlotAndDerivedTemplate()
{
// The derived template's stored name is the slot's contained name, so
// renaming the slot renames the derived template to the new short name.
var composition = new TemplateComposition("OldSlot") { Id = 50, TemplateId = 1, ComposedTemplateId = 77 };
var owner = new Template("Pump") { Id = 1 };
owner.Compositions.Add(composition);
var derived = new Template("Pump.OldSlot") { Id = 77, IsDerived = true, OwnerCompositionId = 50, ParentTemplateId = 2 };
var derived = new Template("OldSlot") { Id = 77, IsDerived = true, OwnerCompositionId = 50, ParentTemplateId = 2 };
_repoMock.Setup(r => r.GetTemplateCompositionByIdAsync(50, It.IsAny<CancellationToken>())).ReturnsAsync(composition);
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(owner);
_repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { owner, derived });
var result = await _service.RenameCompositionAsync(50, "NewSlot", "admin");
Assert.True(result.IsSuccess);
Assert.Equal("NewSlot", result.Value.InstanceName);
Assert.Equal("Pump.NewSlot", derived.Name);
Assert.Equal("NewSlot", derived.Name);
}
[Fact]
public async Task RenameComposition_CascadesRenameToNestedDerivedTemplates()
public async Task RenameComposition_RenamesOnlyTheSlotsOwnDerivedTemplate()
{
// Pump.TempSensor is the slot-owned derived; Pump.TempSensor.Probe1 is a
// cascaded inner derived under it. Renaming the TempSensor slot to
// MainSensor must rename BOTH derived templates so the dotted-path
// naming invariant holds: Pump.MainSensor and Pump.MainSensor.Probe1.
// With contained names, a nested derived template's name is its own
// slot name and is unaffected by renaming an ancestor slot. Renaming
// the TempSensor slot to MainSensor renames only that derived template;
// the inner "Probe1" derived is left untouched.
var innerComp = new TemplateComposition("Probe1") { Id = 51, TemplateId = 77, ComposedTemplateId = 78 };
var innerDerived = new Template("Pump.TempSensor.Probe1") { Id = 78, IsDerived = true, OwnerCompositionId = 51, ParentTemplateId = 11 };
var innerDerived = new Template("Probe1") { Id = 78, IsDerived = true, OwnerCompositionId = 51, ParentTemplateId = 11 };
var composition = new TemplateComposition("TempSensor") { Id = 50, TemplateId = 1, ComposedTemplateId = 77 };
var owner = new Template("Pump") { Id = 1 };
owner.Compositions.Add(composition);
var derived = new Template("Pump.TempSensor") { Id = 77, IsDerived = true, OwnerCompositionId = 50, ParentTemplateId = 2 };
var derived = new Template("TempSensor") { Id = 77, IsDerived = true, OwnerCompositionId = 50, ParentTemplateId = 2 };
derived.Compositions.Add(innerComp);
_repoMock.Setup(r => r.GetTemplateCompositionByIdAsync(50, It.IsAny<CancellationToken>())).ReturnsAsync(composition);
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(owner);
_repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);
_repoMock.Setup(r => r.GetTemplateByIdAsync(78, It.IsAny<CancellationToken>())).ReturnsAsync(innerDerived);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { owner, derived, innerDerived });
var result = await _service.RenameCompositionAsync(50, "MainSensor", "admin");
Assert.True(result.IsSuccess);
Assert.Equal("MainSensor", result.Value.InstanceName);
Assert.Equal("Pump.MainSensor", derived.Name);
Assert.Equal("Pump.MainSensor.Probe1", innerDerived.Name);
Assert.Equal("MainSensor", derived.Name);
Assert.Equal("Probe1", innerDerived.Name);
_repoMock.Verify(r => r.UpdateTemplateAsync(
It.Is<Template>(t => t.Id == 78), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task RenameComposition_NestedCascadeNameCollision_Fails()
{
// A pre-existing template occupies the name the nested cascade would
// produce (Pump.MainSensor.Probe1). The rename must abort before any
// row mutates, so the full cascade name set must be pre-checked.
var innerComp = new TemplateComposition("Probe1") { Id = 51, TemplateId = 77, ComposedTemplateId = 78 };
var innerDerived = new Template("Pump.TempSensor.Probe1") { Id = 78, IsDerived = true, OwnerCompositionId = 51, ParentTemplateId = 11 };
var composition = new TemplateComposition("TempSensor") { Id = 50, TemplateId = 1, ComposedTemplateId = 77 };
var owner = new Template("Pump") { Id = 1 };
owner.Compositions.Add(composition);
var derived = new Template("Pump.TempSensor") { Id = 77, IsDerived = true, OwnerCompositionId = 50, ParentTemplateId = 2 };
derived.Compositions.Add(innerComp);
var collider = new Template("Pump.MainSensor.Probe1") { Id = 99 };
_repoMock.Setup(r => r.GetTemplateCompositionByIdAsync(50, It.IsAny<CancellationToken>())).ReturnsAsync(composition);
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(owner);
_repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);
_repoMock.Setup(r => r.GetTemplateByIdAsync(78, It.IsAny<CancellationToken>())).ReturnsAsync(innerDerived);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { owner, derived, innerDerived, collider });
var result = await _service.RenameCompositionAsync(50, "MainSensor", "admin");
Assert.True(result.IsFailure);
Assert.Contains("already exists", result.Error);
_repoMock.Verify(r => r.UpdateTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()), Times.Never);
It.Is<Template>(t => t.Id == 78), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]