feat(templates): phase 2 — derive-on-compose for new compositions
AddCompositionAsync creates a derived Template ("<parent>.<slot>") that
inherits from the base via ParentTemplateId. Base attributes and scripts
are copied with IsInherited=true so the derived template carries its own
override-able rows. The composition row points at the derived template,
and the derived's OwnerCompositionId back-refs the composition for cascade
delete.
DeleteCompositionAsync cascade-deletes the owned derived template.
DeleteTemplateAsync blocks direct deletion of derived templates and
distinguishes derivatives from regular children, listing slot owners
("'Pump' (as 'TempSensor')") in the error.
Composing a derived template is rejected — only bases can be composed.
Existing compositions still resolve until phase 3 migrates them.
This commit is contained in:
@@ -30,6 +30,11 @@ public class TemplateDeletionService
|
||||
if (template == null)
|
||||
return Result<bool>.Failure($"Template with ID {templateId} not found.");
|
||||
|
||||
if (template.IsDerived)
|
||||
return Result<bool>.Failure(
|
||||
$"Cannot delete template '{template.Name}': it is a derived template. " +
|
||||
"Remove the owning composition on its parent template instead.");
|
||||
|
||||
var errors = new List<string>();
|
||||
|
||||
// Check 1: Instances reference this template
|
||||
@@ -40,16 +45,33 @@ public class TemplateDeletionService
|
||||
errors.Add($"Cannot delete template '{template.Name}': {instances.Count} instance(s) reference it ({names}{(instances.Count > 10 ? "..." : "")}).");
|
||||
}
|
||||
|
||||
// Check 2: Child templates reference it as parent
|
||||
// Check 2: Child templates reference it as parent. Split derived vs. regular.
|
||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
||||
var childTemplates = allTemplates.Where(t => t.ParentTemplateId == templateId).ToList();
|
||||
if (childTemplates.Count > 0)
|
||||
var inheritors = allTemplates.Where(t => t.ParentTemplateId == templateId).ToList();
|
||||
var regularChildren = inheritors.Where(t => !t.IsDerived).ToList();
|
||||
var derivatives = inheritors.Where(t => t.IsDerived).ToList();
|
||||
|
||||
if (regularChildren.Count > 0)
|
||||
{
|
||||
var names = string.Join(", ", childTemplates.Select(t => t.Name).Take(10));
|
||||
errors.Add($"Cannot delete template '{template.Name}': {childTemplates.Count} child template(s) inherit from it ({names}{(childTemplates.Count > 10 ? "..." : "")}).");
|
||||
var names = string.Join(", ", regularChildren.Select(t => t.Name).Take(10));
|
||||
errors.Add($"Cannot delete template '{template.Name}': {regularChildren.Count} child template(s) inherit from it ({names}{(regularChildren.Count > 10 ? "..." : "")}).");
|
||||
}
|
||||
|
||||
// Check 3: Other templates compose it
|
||||
if (derivatives.Count > 0)
|
||||
{
|
||||
var compIds = derivatives.Select(d => d.OwnerCompositionId).Where(id => id.HasValue).Select(id => id!.Value).ToHashSet();
|
||||
var ownerLookup = allTemplates
|
||||
.SelectMany(t => t.Compositions.Select(c => new { Owner = t, Composition = c }))
|
||||
.Where(x => compIds.Contains(x.Composition.Id))
|
||||
.ToDictionary(x => x.Composition.Id, x => $"'{x.Owner.Name}' (as '{x.Composition.InstanceName}')");
|
||||
var details = string.Join(", ", derivatives.Take(10).Select(d =>
|
||||
d.OwnerCompositionId.HasValue && ownerLookup.TryGetValue(d.OwnerCompositionId.Value, out var label)
|
||||
? label
|
||||
: $"'{d.Name}'"));
|
||||
errors.Add($"Cannot delete template '{template.Name}': it is the base of {derivatives.Count} derived template(s) used in {details}{(derivatives.Count > 10 ? "..." : "")}. Remove those compositions first.");
|
||||
}
|
||||
|
||||
// Check 3: Other templates compose it directly (e.g., pre-Phase-3 data).
|
||||
var composingTemplates = new List<(string TemplateName, string InstanceName)>();
|
||||
foreach (var t in allTemplates)
|
||||
{
|
||||
|
||||
@@ -115,19 +115,48 @@ public class TemplateService
|
||||
if (template == null)
|
||||
return Result<bool>.Failure($"Template with ID {templateId} not found.");
|
||||
|
||||
// Derived templates are owned by their composition row and must be removed
|
||||
// by deleting the composition (which cascades) — block direct deletion.
|
||||
if (template.IsDerived)
|
||||
return Result<bool>.Failure(
|
||||
$"Cannot delete template '{template.Name}': it is a derived template. " +
|
||||
"Remove the owning composition on its parent template instead.");
|
||||
|
||||
// Check for instances referencing this template
|
||||
var instances = await _repository.GetInstancesByTemplateIdAsync(templateId, cancellationToken);
|
||||
if (instances.Count > 0)
|
||||
return Result<bool>.Failure(
|
||||
$"Cannot delete template '{template.Name}': it is referenced by {instances.Count} instance(s).");
|
||||
|
||||
// Check for child templates inheriting from this template
|
||||
// Check for child templates inheriting from this template.
|
||||
// Split derived vs. regular children — the message and remediation differ.
|
||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
||||
var children = allTemplates.Where(t => t.ParentTemplateId == templateId).ToList();
|
||||
if (children.Count > 0)
|
||||
var inheritors = allTemplates.Where(t => t.ParentTemplateId == templateId).ToList();
|
||||
var derivatives = inheritors.Where(t => t.IsDerived).ToList();
|
||||
var regularChildren = inheritors.Where(t => !t.IsDerived).ToList();
|
||||
|
||||
if (regularChildren.Count > 0)
|
||||
return Result<bool>.Failure(
|
||||
$"Cannot delete template '{template.Name}': it is inherited by {children.Count} child template(s): " +
|
||||
string.Join(", ", children.Select(c => $"'{c.Name}'")));
|
||||
$"Cannot delete template '{template.Name}': it is inherited by {regularChildren.Count} child template(s): " +
|
||||
string.Join(", ", regularChildren.Select(c => $"'{c.Name}'")));
|
||||
|
||||
if (derivatives.Count > 0)
|
||||
{
|
||||
// Name each derivative by its owning parent template + composition slot.
|
||||
var ownerCompIds = derivatives.Select(d => d.OwnerCompositionId).Where(id => id.HasValue).Select(id => id!.Value).ToHashSet();
|
||||
var ownerLookup = allTemplates
|
||||
.SelectMany(t => t.Compositions.Select(c => new { Owner = t, Composition = c }))
|
||||
.Where(x => ownerCompIds.Contains(x.Composition.Id))
|
||||
.ToDictionary(x => x.Composition.Id, x => $"'{x.Owner.Name}' (as '{x.Composition.InstanceName}')");
|
||||
|
||||
var details = derivatives
|
||||
.Select(d => d.OwnerCompositionId.HasValue && ownerLookup.TryGetValue(d.OwnerCompositionId.Value, out var label)
|
||||
? label
|
||||
: $"'{d.Name}'");
|
||||
return Result<bool>.Failure(
|
||||
$"Cannot delete template '{template.Name}': it is the base of {derivatives.Count} derived template(s) used in: " +
|
||||
string.Join(", ", details) + ". Remove those compositions first.");
|
||||
}
|
||||
|
||||
// Check for templates composing this template
|
||||
var composedBy = allTemplates
|
||||
@@ -533,39 +562,64 @@ public class TemplateService
|
||||
if (template == null)
|
||||
return Result<TemplateComposition>.Failure($"Template with ID {templateId} not found.");
|
||||
|
||||
var composedTemplate = await _repository.GetTemplateByIdAsync(composedTemplateId, cancellationToken);
|
||||
if (composedTemplate == null)
|
||||
var baseTemplate = await _repository.GetTemplateByIdAsync(composedTemplateId, cancellationToken);
|
||||
if (baseTemplate == null)
|
||||
return Result<TemplateComposition>.Failure($"Composed template with ID {composedTemplateId} not found.");
|
||||
|
||||
// Only base templates can be composed; derived templates are slot-owned.
|
||||
if (baseTemplate.IsDerived)
|
||||
return Result<TemplateComposition>.Failure(
|
||||
$"Cannot compose template '{baseTemplate.Name}': it is a derived template. Compose its base instead.");
|
||||
|
||||
// Check for duplicate instance name
|
||||
if (template.Compositions.Any(c => c.InstanceName == instanceName))
|
||||
return Result<TemplateComposition>.Failure(
|
||||
$"Composition instance name '{instanceName}' already exists on template '{template.Name}'.");
|
||||
|
||||
// Check composition acyclicity
|
||||
// Acyclicity is checked against the base, not the to-be-created derived template —
|
||||
// the derived inherits from the base, so a base→base cycle is the meaningful check.
|
||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
||||
var cycleError = CycleDetector.DetectCompositionCycle(templateId, composedTemplateId, allTemplates);
|
||||
if (cycleError != null)
|
||||
return Result<TemplateComposition>.Failure(cycleError);
|
||||
|
||||
// Check cross-graph cycle
|
||||
var crossCycleError = CycleDetector.DetectCrossGraphCycle(templateId, null, composedTemplateId, allTemplates);
|
||||
if (crossCycleError != null)
|
||||
return Result<TemplateComposition>.Failure(crossCycleError);
|
||||
|
||||
var composition = new TemplateComposition(instanceName)
|
||||
var probeComposition = new TemplateComposition(instanceName)
|
||||
{
|
||||
TemplateId = templateId,
|
||||
ComposedTemplateId = composedTemplateId
|
||||
};
|
||||
|
||||
// Check for naming collisions with the new composition
|
||||
var testTemplate = CloneTemplateWithNewComposition(template, composition);
|
||||
var testTemplate = CloneTemplateWithNewComposition(template, probeComposition);
|
||||
var collisions = CollisionDetector.DetectCollisions(testTemplate, allTemplates);
|
||||
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))
|
||||
return Result<TemplateComposition>.Failure(
|
||||
$"Cannot create derived template '{derivedName}': a template with that name already exists.");
|
||||
|
||||
var derived = BuildDerivedTemplate(baseTemplate, derivedName);
|
||||
|
||||
await _repository.AddTemplateAsync(derived, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var composition = new TemplateComposition(instanceName)
|
||||
{
|
||||
TemplateId = templateId,
|
||||
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);
|
||||
|
||||
@@ -581,13 +635,65 @@ public class TemplateService
|
||||
if (composition == null)
|
||||
return Result<bool>.Failure($"Composition with ID {compositionId} not found.");
|
||||
|
||||
// Identify the slot-owned derived template (post Phase-3 migration this is the
|
||||
// typical case; pre-migration the composition may still point at a base).
|
||||
var composedTemplate = await _repository.GetTemplateByIdAsync(composition.ComposedTemplateId, cancellationToken);
|
||||
|
||||
await _repository.DeleteTemplateCompositionAsync(compositionId, cancellationToken);
|
||||
await _auditService.LogAsync(user, "Delete", "TemplateComposition", compositionId.ToString(), composition.InstanceName, null, cancellationToken);
|
||||
|
||||
if (composedTemplate != null && composedTemplate.IsDerived && composedTemplate.OwnerCompositionId == compositionId)
|
||||
{
|
||||
await _repository.DeleteTemplateAsync(composedTemplate.Id, cancellationToken);
|
||||
await _auditService.LogAsync(user, "Delete", "Template", composedTemplate.Id.ToString(), composedTemplate.Name, null, cancellationToken);
|
||||
}
|
||||
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
|
||||
private static Template BuildDerivedTemplate(Template baseTemplate, string derivedName)
|
||||
{
|
||||
var derived = new Template(derivedName)
|
||||
{
|
||||
Description = baseTemplate.Description,
|
||||
ParentTemplateId = baseTemplate.Id,
|
||||
IsDerived = true,
|
||||
};
|
||||
|
||||
foreach (var attr in baseTemplate.Attributes)
|
||||
{
|
||||
derived.Attributes.Add(new TemplateAttribute(attr.Name)
|
||||
{
|
||||
Value = attr.Value,
|
||||
DataType = attr.DataType,
|
||||
IsLocked = attr.IsLocked,
|
||||
Description = attr.Description,
|
||||
DataSourceReference = attr.DataSourceReference,
|
||||
IsInherited = true,
|
||||
LockedInDerived = false,
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var script in baseTemplate.Scripts)
|
||||
{
|
||||
derived.Scripts.Add(new TemplateScript(script.Name, script.Code)
|
||||
{
|
||||
IsLocked = script.IsLocked,
|
||||
TriggerType = script.TriggerType,
|
||||
TriggerConfiguration = script.TriggerConfiguration,
|
||||
ParameterDefinitions = script.ParameterDefinitions,
|
||||
ReturnDefinition = script.ReturnDefinition,
|
||||
MinTimeBetweenRuns = script.MinTimeBetweenRuns,
|
||||
IsInherited = true,
|
||||
LockedInDerived = false,
|
||||
});
|
||||
}
|
||||
|
||||
return derived;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// WP-7: Path-Qualified Canonical Naming (via TemplateResolver)
|
||||
// ========================================================================
|
||||
|
||||
Reference in New Issue
Block a user