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
5.8 KiB
Contained Names for Composition-Derived Templates — Design
Date: 2026-05-18 Status: Implemented
Context
When a template composes another template, the MigrateCompositionsToDerived
migration materializes the composition slot as its own derived Template
row. Each derived template was named OwnerTemplate.Name + "." + Composition. InstanceName — e.g. the Pump slot inside Motor Controller became a
template literally named Motor Controller.Pump.
The dotted name exists only to satisfy the global UNIQUE index on
Template.Name: there is already a standalone Pump template, so the derived
one could not also be called Pump.
This is the opposite of how AVEVA System Platform models containment — there,
an object has a contained name (Pump, unique only within its container)
and a hierarchical name (MotorController.Pump) that is derived, not
stored. This design moves ScadaLink to that model.
Decisions taken during brainstorming
- Adopt the contained-name model. A derived template's stored
Nameis its contained name (the composition'sInstanceName); the qualified path is computed, not stored. - Keep derived templates materialized. The
MigrateCompositionsToDeriveddesign is not revisited — the contained-name fix works with derived templates as-is. QualifiedNameis computed, not a denormalized column — a stored column would need cascade maintenance on every rename up the chain. Composition chains are 1–3 deep, so walking them on read is cheap.
Audit findings (read-only, completed before this design)
- No code resolves a template by name: there is no
GetTemplateByNameAsync; the Management API and CLI are entirely ID-based. - The template tree already hides derived templates (
Where(t => !t.IsDerived)). - Flattening, member canonical names, and collision detection use
composition.InstanceName— neverTemplate.Name. - The flattened/deployed artifact carries no template name; deployment keys on ID + revision hash. The dotted name never reaches sites.
TemplateCompositionalready has a DBUNIQUE (TemplateId, InstanceName)index — the contained-name uniqueness backstop already exists.- Every other
Template.Nameuse is either dotted-name construction (TemplateService+ the old migration), a dotted-name uniqueness pre-check, or reading.Namefor display after an ID lookup.
Design
1. Schema
- The
UNIQUEindex onTemplate.Namebecomes a filtered unique index:UNIQUE ... WHERE [IsDerived] = 0. User-authored (base) templates stay globally unique; derived templates do not need to be. - Derived templates' uniqueness is enforced entirely by the existing
UNIQUE (TemplateId, InstanceName)index onTemplateComposition— a slot name is unique within its owner. - A new EF Core migration:
- Rewrites every derived row:
Name = OwnerComposition.InstanceName. - Replaces the
Template.Nameindex with the filtered version.
- Rewrites every derived row:
TemplateConfiguration:HasIndex(t => t.Name).IsUnique()gains.HasFilter("[IsDerived] = 0").
2. Qualified name
A new pure helper resolves the hierarchical name on demand:
QualifiedName(template, byId, compById):
if not template.IsDerived: return template.Name
comp = compById[template.OwnerCompositionId]
owner = byId[comp.TemplateId]
return QualifiedName(owner, …) + "." + comp.InstanceName
It lives in a small static class in ScadaLink.TemplateEngine (e.g.
TemplateNaming), takes lookups the callers already have, and is null-safe
(a missing link falls back to the stored Name).
3. TemplateService
The composition create / rename / cascade paths currently build
$"{owner.Name}.{instanceName}" for the derived Template.Name. They change
to store the contained name (instanceName) directly:
CreateCascadedCompositionAsync— derivedName = instanceName.- Nested cascade (
EnumerateCascadeNames, cascade-rename) — each level stores its ownInstanceName, not the accumulated path. - The dotted-name global collision pre-check (
allTemplates.Any(t => t.Name == newName)) is removed — uniqueness is the in-ownerInstanceNamecheck that already runs (and the DB index behind it).
4. UI
TemplateEdit: the page title shows the containedName; the qualified path is shown as a breadcrumb / subtitle (the existing "— composed inside X as Y" line already carries this information and becomes the canonical presentation).- "inherits X", cycle-detection messages, and deletion-blocked messages use
QualifiedNamewhen the referenced template is derived, so a bareTempSensoris never ambiguous in a message.
5. Out of scope
- Un-materializing derived templates (treating a composition purely as a slot).
- Any change to member/attribute canonical names — they already use
InstanceName.
Testing & verification
TemplateNamingunit tests — qualified name for a base template, a one-level derived template, and a nested derived template; null-safe fallback.TemplateServicetests — composition create and rename store the contained name; two derived templates with the same contained name under different owners are allowed; a duplicate slot name on one owner is rejected.- Migration — applied to the dev cluster; existing derived rows
(
Motor Controller.Pump,Tank Monitor.DrivePump.TempSensor, …) collapse to contained names; verified in the browser.
Affected files
src/ScadaLink.ConfigurationDatabase/Configurations/TemplateConfiguration.cssrc/ScadaLink.ConfigurationDatabase/Migrations/*(new migration)src/ScadaLink.TemplateEngine/TemplateService.cssrc/ScadaLink.TemplateEngine/TemplateNaming.cs(new)src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor- Docs:
docs/requirements/Component-TemplateEngine.md