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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user