Files
scadalink-design/src/ScadaLink.TemplateEngine/TemplateNaming.cs
Joseph Doherty 06462a0100 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
2026-05-18 17:50:30 -04:00

54 lines
2.1 KiB
C#

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}";
}
}