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

@@ -236,7 +236,7 @@
<a href="/design/templates/@_baseTemplate.Id"><code>@_baseTemplate.Name</code></a>
@if (_ownerTemplate != null && _ownerComposition != null)
{
<span class="ms-1">— composed inside <a href="/design/templates/@_ownerTemplate.Id"><code>@_ownerTemplate.Name</code></a> as <code>@_ownerComposition.InstanceName</code>.</span>
<span class="ms-1">— composed inside <a href="/design/templates/@_ownerTemplate.Id"><code>@QualifiedTemplateName(_ownerTemplate)</code></a> as <code>@_ownerComposition.InstanceName</code>.</span>
}
</div>
</div>
@@ -248,6 +248,12 @@
{
<span class="text-muted ms-2">inherits @(_templates.FirstOrDefault(t => t.Id == _selectedTemplate.ParentTemplateId)?.Name)</span>
}
@if (_selectedTemplate.IsDerived)
{
@* Derived templates store a contained name; show the full
qualified path as a breadcrumb subtitle. *@
<div class="text-muted small font-monospace">@QualifiedTemplateName(_selectedTemplate)</div>
}
</div>
<div>
<button class="btn btn-outline-info btn-sm me-1" @onclick="RunValidation" disabled="@_validating">
@@ -1701,6 +1707,18 @@
else { _toast.ShowError(result.Error); }
}
/// <summary>
/// Computes a template's qualified (hierarchical) name from the loaded
/// template set — the stored name for a base template, the dotted
/// owner-chain path for a composition-derived one.
/// </summary>
private string QualifiedTemplateName(Template template)
{
var byId = _templates.ToDictionary(t => t.Id);
var compById = _templates.SelectMany(t => t.Compositions).ToDictionary(c => c.Id);
return TemplateNaming.QualifiedName(template, byId, compById);
}
// ---- Editor metadata builders ----
private async Task<IReadOnlyList<ScadaLink.CentralUI.ScriptAnalysis.CompositionContext>> BuildChildContextsAsync(