From 2d4b287ab2e4b39013544dc0df5afa564322e8aa Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 18 May 2026 17:37:43 -0400 Subject: [PATCH] docs(plans): design for contained names on composition-derived templates --- ...6-05-18-contained-template-names-design.md | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 docs/plans/2026-05-18-contained-template-names-design.md diff --git a/docs/plans/2026-05-18-contained-template-names-design.md b/docs/plans/2026-05-18-contained-template-names-design.md new file mode 100644 index 0000000..ad9aa4c --- /dev/null +++ b/docs/plans/2026-05-18-contained-template-names-design.md @@ -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`