docs(plans): design for contained names on composition-derived templates
This commit is contained in:
130
docs/plans/2026-05-18-contained-template-names-design.md
Normal file
130
docs/plans/2026-05-18-contained-template-names-design.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# Contained Names for Composition-Derived Templates — Design
|
||||
|
||||
**Date:** 2026-05-18
|
||||
**Status:** Approved (brainstorming) — implementation to follow
|
||||
|
||||
## 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 `Name` is its
|
||||
contained name (the composition's `InstanceName`); the qualified path is
|
||||
computed, not stored.
|
||||
- **Keep derived templates materialized.** The `MigrateCompositionsToDerived`
|
||||
design is not revisited — the contained-name fix works with derived
|
||||
templates as-is.
|
||||
- **`QualifiedName` is 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` — never `Template.Name`.
|
||||
- The flattened/deployed artifact carries no template name; deployment keys on
|
||||
ID + revision hash. The dotted name never reaches sites.
|
||||
- `TemplateComposition` already has a DB `UNIQUE (TemplateId, InstanceName)`
|
||||
index — the contained-name uniqueness backstop already exists.
|
||||
- Every other `Template.Name` use is either dotted-name construction
|
||||
(`TemplateService` + the old migration), a dotted-name uniqueness pre-check,
|
||||
or reading `.Name` for display after an ID lookup.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Schema
|
||||
|
||||
- The `UNIQUE` index on `Template.Name` becomes 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 on `TemplateComposition` — a slot
|
||||
name is unique within its owner.
|
||||
- A new EF Core migration:
|
||||
- Rewrites every derived row: `Name = OwnerComposition.InstanceName`.
|
||||
- Replaces the `Template.Name` index with the filtered version.
|
||||
- `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` — derived `Name = instanceName`.
|
||||
- Nested cascade (`EnumerateCascadeNames`, cascade-rename) — each level stores
|
||||
its own `InstanceName`, not the accumulated path.
|
||||
- The dotted-name global collision pre-check (`allTemplates.Any(t => t.Name ==
|
||||
newName)`) is removed — uniqueness is the in-owner `InstanceName` check that
|
||||
already runs (and the DB index behind it).
|
||||
|
||||
### 4. UI
|
||||
|
||||
- `TemplateEdit`: the page title shows the contained `Name`; 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
|
||||
`QualifiedName` when the referenced template is derived, so a bare `TempSensor`
|
||||
is 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
|
||||
|
||||
- **`TemplateNaming` unit tests** — qualified name for a base template, a
|
||||
one-level derived template, and a nested derived template; null-safe
|
||||
fallback.
|
||||
- **`TemplateService` tests** — 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.cs`
|
||||
- `src/ScadaLink.ConfigurationDatabase/Migrations/*` (new migration)
|
||||
- `src/ScadaLink.TemplateEngine/TemplateService.cs`
|
||||
- `src/ScadaLink.TemplateEngine/TemplateNaming.cs` (new)
|
||||
- `src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor`
|
||||
- Docs: `docs/requirements/Component-TemplateEngine.md`
|
||||
Reference in New Issue
Block a user