Files
scadalink-design/docs/plans/2026-05-18-contained-template-names-design.md

5.8 KiB
Raw Blame History

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 13 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