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

@@ -0,0 +1,53 @@
using ScadaLink.Commons.Entities.Templates;
namespace ScadaLink.TemplateEngine;
/// <summary>
/// Resolves the hierarchical ("qualified") name of a composition-derived
/// template. A derived template stores only its <em>contained</em> name — the
/// owning composition slot's <c>InstanceName</c>, unique only within that owner.
/// The qualified path (<c>Owner.Slot.Slot…</c>) is computed on demand by
/// walking the <see cref="Template.OwnerCompositionId"/> chain up to the base
/// template.
/// </summary>
public static class TemplateNaming
{
/// <summary>
/// Returns the dotted hierarchical name of <paramref name="template"/>. For
/// a base (non-derived) template this is just its stored name. The walk is
/// null-safe: if any owner link is missing from the supplied lookups it
/// stops and falls back to the stored contained name, and a cycle (which
/// the composition graph should never contain) is broken defensively.
/// </summary>
public static string QualifiedName(
Template template,
IReadOnlyDictionary<int, Template> byId,
IReadOnlyDictionary<int, TemplateComposition> compById)
{
ArgumentNullException.ThrowIfNull(template);
ArgumentNullException.ThrowIfNull(byId);
ArgumentNullException.ThrowIfNull(compById);
return Resolve(template, byId, compById, new HashSet<int>());
}
private static string Resolve(
Template template,
IReadOnlyDictionary<int, Template> byId,
IReadOnlyDictionary<int, TemplateComposition> compById,
HashSet<int> visited)
{
// Base template, broken owner link, or a cycle → the stored name is the
// best (and contained) answer.
if (!template.IsDerived
|| template.OwnerCompositionId is not { } compId
|| !compById.TryGetValue(compId, out var composition)
|| !byId.TryGetValue(composition.TemplateId, out var owner)
|| !visited.Add(template.Id))
{
return template.Name;
}
return $"{Resolve(owner, byId, compById, visited)}.{composition.InstanceName}";
}
}

View File

@@ -598,20 +598,9 @@ public class TemplateService
if (collisions.Count > 0)
return Result<TemplateComposition>.Failure(string.Join(" ", collisions));
// 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 '{nameCollision}': a template with that name already exists.");
// No global name pre-check: derived templates store their contained
// (slot) name, which need only be unique within the owner — and that is
// already enforced above and by the (TemplateId, InstanceName) index.
var composition = await CreateCascadedCompositionAsync(template, baseTemplate, instanceName, user, cancellationToken);
await _auditService.LogAsync(user, "Create", "TemplateComposition", "0", instanceName, composition, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
@@ -633,8 +622,10 @@ public class TemplateService
string user,
CancellationToken cancellationToken)
{
var derivedName = $"{outerTemplate.Name}.{instanceName}";
var derived = BuildDerivedTemplate(source, derivedName);
// A derived template stores its contained name — the slot's instance
// name, unique within its owner. The qualified path is computed on read
// (see TemplateNaming.QualifiedName).
var derived = BuildDerivedTemplate(source, instanceName);
await _repository.AddTemplateAsync(derived, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
@@ -664,19 +655,6 @@ public class TemplateService
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(
int compositionId,
string newInstanceName,
@@ -700,33 +678,15 @@ public class TemplateService
return Result<TemplateComposition>.Failure(
$"Slot name '{newInstanceName}' already exists on '{owner.Name}'.");
// The derived template stores the slot's contained name, so renaming
// the slot renames just that one template. Nested derived templates
// keep their own contained names — an ancestor slot rename never
// touches them, and the qualified path is recomputed on read.
var derived = await _repository.GetTemplateByIdAsync(composition.ComposedTemplateId, cancellationToken);
if (derived != null && derived.IsDerived && derived.OwnerCompositionId == compositionId)
{
var newDerivedName = $"{owner.Name}.{newInstanceName}";
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
// The cascade of derived templates created by AddComposition follows a
// dotted path (Pump.TempSensor and the nested Pump.TempSensor.Probe1).
// Renaming the slot must rename every derived template in that cascade
// so the dotted-path naming invariant holds — pre-check every new name
// the cascade will introduce before any row mutates.
var renames = new List<(Template Template, string NewName)>();
await CollectCascadeRenamesAsync(derived, newDerivedName, renames, cancellationToken);
var renamedIds = renames.Select(r => r.Template.Id).ToHashSet();
foreach (var (_, newName) in renames)
{
if (allTemplates.Any(t => !renamedIds.Contains(t.Id) && t.Name == newName))
return Result<TemplateComposition>.Failure(
$"Cannot rename derived template to '{newName}': a template with that name already exists.");
}
foreach (var (template, newName) in renames)
{
template.Name = newName;
await _repository.UpdateTemplateAsync(template, cancellationToken);
}
derived.Name = newInstanceName;
await _repository.UpdateTemplateAsync(derived, cancellationToken);
}
composition.InstanceName = newInstanceName;
@@ -763,30 +723,6 @@ public class TemplateService
return Result<bool>.Success(true);
}
/// <summary>
/// Recursively collects the (template, new name) pairs for a renamed derived
/// template and every cascaded inner derived template beneath it. Each inner
/// derived's new name is re-derived from its renamed parent and the slot's
/// instance name (mirroring the cascade <see cref="CreateCascadedCompositionAsync"/>
/// builds and the recursion in <see cref="CascadeDeleteDerivedAsync"/>).
/// </summary>
private async Task CollectCascadeRenamesAsync(
Template derived,
string newName,
List<(Template Template, string NewName)> renames,
CancellationToken cancellationToken)
{
renames.Add((derived, newName));
foreach (var child in derived.Compositions.ToList())
{
var childDerived = await _repository.GetTemplateByIdAsync(child.ComposedTemplateId, cancellationToken);
if (childDerived != null && childDerived.IsDerived && childDerived.OwnerCompositionId == child.Id)
await CollectCascadeRenamesAsync(
childDerived, $"{newName}.{child.InstanceName}", renames, cancellationToken);
}
}
/// <summary>
/// Recursively deletes a derived template along with the cascade of inner
/// derived templates the compose flow created. Each composition row on the