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