fix(templates): cascade child compositions when composing a composite

When the user composes a template that already has compositions of its
own (e.g. \$Sensor → Probe1 slot), only the outer derived was created
— the source's children weren't replicated. AddCompositionAsync now
walks the source's composition graph and creates a parallel derived for
every slot it encounters, each linked back through ParentTemplateId so
override chains stay intact (\$Probe → \$Sensor.Probe1 → \$Pump.TempSensor.Probe1).

The cascade pre-flights every name it would create — a deep collision
aborts before any rows mutate. Internal helper
CreateCascadedCompositionAsync skips the "base templates only" check
since it operates on the source side which may legitimately reference
derived rows.
This commit is contained in:
Joseph Doherty
2026-05-12 09:57:07 -04:00
parent 1f86945d46
commit 4f90f952d0
2 changed files with 103 additions and 9 deletions

View File

@@ -626,32 +626,83 @@ public class TemplateService
if (collisions.Count > 0)
return Result<TemplateComposition>.Failure(string.Join(" ", collisions));
// Derived template name uses dot-separated path: "<parent>.<slot>".
var derivedName = $"{template.Name}.{instanceName}";
if (allTemplates.Any(t => t.Name == derivedName))
// Derived template name uses dot-separated path: "<parent>.<slot>". The
// cascade may create additional derived templates one level per slot
// (composing $Sensor with a Probe1 slot into $Pump produces both
// $Pump.TempSensor and $Pump.TempSensor.Probe1). Pre-check every name
// the cascade is about to introduce so a deep collision aborts before
// any rows mutate.
var byId = allTemplates.ToDictionary(t => t.Id);
var cascadeNames = EnumerateCascadeNames(template.Name, instanceName, baseTemplate, byId).ToList();
var existingNames = allTemplates.Select(t => t.Name).ToHashSet(StringComparer.Ordinal);
var nameCollision = cascadeNames.FirstOrDefault(n => existingNames.Contains(n));
if (nameCollision != null)
return Result<TemplateComposition>.Failure(
$"Cannot create derived template '{derivedName}': a template with that name already exists.");
$"Cannot create derived template '{nameCollision}': a template with that name already exists.");
var derived = BuildDerivedTemplate(baseTemplate, derivedName);
var composition = await CreateCascadedCompositionAsync(template, baseTemplate, instanceName, user, cancellationToken);
await _auditService.LogAsync(user, "Create", "TemplateComposition", "0", instanceName, composition, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result<TemplateComposition>.Success(composition);
}
/// <summary>
/// Creates a derived template under <paramref name="outerTemplate"/> that
/// wraps <paramref name="source"/>, then recursively cascades the source's
/// own compositions so the slot graph is replicated under the new derived.
/// Used both for the user-initiated top-level compose and the recursive
/// children — neither path re-validates (caller pre-flights).
/// </summary>
private async Task<TemplateComposition> CreateCascadedCompositionAsync(
Template outerTemplate,
Template source,
string instanceName,
string user,
CancellationToken cancellationToken)
{
var derivedName = $"{outerTemplate.Name}.{instanceName}";
var derived = BuildDerivedTemplate(source, derivedName);
await _repository.AddTemplateAsync(derived, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
var composition = new TemplateComposition(instanceName)
{
TemplateId = templateId,
TemplateId = outerTemplate.Id,
ComposedTemplateId = derived.Id
};
await _repository.AddTemplateCompositionAsync(composition, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
derived.OwnerCompositionId = composition.Id;
await _repository.UpdateTemplateAsync(derived, cancellationToken);
await _auditService.LogAsync(user, "Create", "TemplateComposition", "0", instanceName, composition, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result<TemplateComposition>.Success(composition);
// Cascade — replicate each of the source's compositions onto the new
// derived. The child's ParentTemplateId points at the source-side
// child (so override chains stay intact across nesting).
foreach (var childComp in source.Compositions.ToList())
{
var childSource = await _repository.GetTemplateByIdAsync(childComp.ComposedTemplateId, cancellationToken);
if (childSource == null) continue;
await CreateCascadedCompositionAsync(derived, childSource, childComp.InstanceName, user, cancellationToken);
}
return composition;
}
private static IEnumerable<string> EnumerateCascadeNames(
string outerName, string instanceName, Template source, IReadOnlyDictionary<int, Template> byId)
{
var derivedName = $"{outerName}.{instanceName}";
yield return derivedName;
foreach (var comp in source.Compositions)
{
if (!byId.TryGetValue(comp.ComposedTemplateId, out var child)) continue;
foreach (var name in EnumerateCascadeNames(derivedName, comp.InstanceName, child, byId))
yield return name;
}
}
public async Task<Result<TemplateComposition>> RenameCompositionAsync(

View File

@@ -321,6 +321,49 @@ public class TemplateServiceTests
_repoMock.Verify(r => r.AddTemplateCompositionAsync(It.IsAny<TemplateComposition>(), It.IsAny<CancellationToken>()), Times.Once);
}
[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.
var probe = new Template("Probe") { Id = 10 };
var sensorProbe1 = new Template("Sensor.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 };
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(pump);
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(sensor);
_repoMock.Setup(r => r.GetTemplateByIdAsync(11, It.IsAny<CancellationToken>())).ReturnsAsync(sensorProbe1);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { pump, sensor, probe, sensorProbe1 });
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 capturedCompositions = new List<TemplateComposition>();
_repoMock.Setup(r => r.AddTemplateCompositionAsync(It.IsAny<TemplateComposition>(), It.IsAny<CancellationToken>()))
.Callback<TemplateComposition, CancellationToken>((c, _) => capturedCompositions.Add(c))
.Returns(Task.CompletedTask);
var result = await _service.AddCompositionAsync(1, 2, "TempSensor", "admin");
Assert.True(result.IsSuccess);
Assert.Equal(2, captured.Count);
Assert.Equal("Pump.TempSensor", captured[0].Name);
Assert.Equal(2, captured[0].ParentTemplateId);
Assert.Equal("Pump.TempSensor.Probe1", captured[1].Name);
Assert.Equal(11, captured[1].ParentTemplateId);
Assert.Equal(2, capturedCompositions.Count);
Assert.Equal("TempSensor", capturedCompositions[0].InstanceName);
Assert.Equal("Probe1", capturedCompositions[1].InstanceName);
}
[Fact]
public async Task AddComposition_BaseIsDerived_Fails()
{