273 lines
11 KiB
Markdown
273 lines
11 KiB
Markdown
# Derive-on-compose template specialization
|
|
|
|
## Goal
|
|
|
|
Match Aveva System Platform's composition model: composing template
|
|
`$Sensor` into template `$Pump` no longer references `$Sensor` directly. Instead
|
|
the system creates a derived template that **inherits** from `$Sensor`, then the
|
|
composition references the derived template. The derived template lives under
|
|
the owning parent and can:
|
|
|
|
- override attribute default values
|
|
- override script bodies
|
|
- add new attributes / scripts the base doesn't have
|
|
- be prevented from overriding fields the base marks as locked
|
|
|
|
This is the user-selected approach (Option C "Always-derive") from the
|
|
brainstorming session, with all four customization scopes enabled.
|
|
|
|
## Why
|
|
|
|
- Per-composition customization is a real SCADA use case (Pump's TempSensor
|
|
needs different alarm thresholds from Motor's TempSensor).
|
|
- Single parent always at design time: removes the multi-parent picker we just
|
|
added.
|
|
- Industry-standard mental model for users coming from Aveva / Wonderware.
|
|
|
|
## Non-goals
|
|
|
|
- Replacing the existing `ParentTemplateId` inheritance chain — we reuse it.
|
|
- Versioning of base templates separately from derived (out of scope; can layer
|
|
later).
|
|
- Cross-template attribute references (already covered by Children/Parent).
|
|
|
|
## Data model changes
|
|
|
|
`Template` gains:
|
|
|
|
```csharp
|
|
public bool IsDerived { get; set; } // hides from main tree
|
|
public int? OwnerCompositionId { get; set; } // back-ref to composition
|
|
```
|
|
|
|
`TemplateAttribute` gains:
|
|
|
|
```csharp
|
|
public bool IsInherited { get; set; } // value came from base
|
|
public bool LockedInDerived { get; set; } // base marks "no override"
|
|
```
|
|
|
|
`TemplateScript` gains the same `IsInherited` / `LockedInDerived` pair.
|
|
|
|
`TemplateComposition` is unchanged in shape — `ComposedTemplateId` now points
|
|
at the **derived** template, not the base. The base is reachable via
|
|
`derived.ParentTemplateId`.
|
|
|
|
**Why a separate `IsDerived` flag rather than just "has a parent and is composed
|
|
once":** explicit marker keeps the tree-view filtering trivial and signals
|
|
intent independent of current composition state.
|
|
|
|
**Why `OwnerCompositionId` instead of inferring from `TemplateComposition`
|
|
back-pointers:** O(1) lookup for cascade-delete and forbid-direct-edit paths.
|
|
|
|
## Lifecycle
|
|
|
|
```
|
|
Compose "$Sensor" into "$Pump" as instance "TempSensor":
|
|
1. Create new template { Name: "Pump.TempSensor", ParentTemplateId: $Sensor.Id,
|
|
IsDerived: true, Description: from $Sensor }
|
|
2. Copy $Sensor.Attributes into the new template marked IsInherited=true
|
|
3. Copy $Sensor.Scripts into the new template marked IsInherited=true
|
|
4. Create TemplateComposition { TemplateId: $Pump.Id,
|
|
ComposedTemplateId: newTemplate.Id,
|
|
InstanceName: "TempSensor" }
|
|
5. Set newTemplate.OwnerCompositionId = the new composition's Id
|
|
```
|
|
|
|
Delete composition or owning parent → cascade-delete the derived template.
|
|
|
|
Rename composition InstanceName → rename the derived template (`Pump.NewName`).
|
|
|
|
Edit base attribute that is `IsInherited=true` on derivatives → the derivatives
|
|
pick up the change *if* they haven't overridden that field. Override sets
|
|
`IsInherited=false`.
|
|
|
|
## Lock semantics
|
|
|
|
Existing `IsLocked` on `TemplateAttribute` already exists with the meaning
|
|
"this attribute on this template is locked for editing." Add a second flag
|
|
`LockedInDerived` meaning "derived templates may not override the value
|
|
inherited from this attribute." These compose:
|
|
|
|
| State on base | What derived can do |
|
|
|---|---|
|
|
| neither flag set | Override value freely |
|
|
| `LockedInDerived` only | Cannot override; inherited value is final |
|
|
| `IsLocked` only | Base itself can't be edited; derived can still override |
|
|
| both | Locked everywhere |
|
|
|
|
## Flattening implications
|
|
|
|
`FlatteningService.ResolveInheritedScripts` already walks a template chain via
|
|
`ParentTemplateId`. That logic already handles "child overrides parent;
|
|
parent's `IsLocked` blocks override." We extend the same with
|
|
`LockedInDerived` for both attributes and scripts.
|
|
|
|
`ResolveComposedScripts` walks compositions → composed templates. Today the
|
|
prefix is the `InstanceName`. With derived templates the prefix is still the
|
|
`InstanceName` (the derived template's name `Pump.TempSensor` doesn't show up
|
|
in canonical paths — paths use the slot name, not the template name).
|
|
|
|
The `ResolvedScript.Scope` we landed for Phase 2 of the previous design still
|
|
applies: `SelfPath = "TempSensor"`, `ParentPath = ""`. No change.
|
|
|
|
## UI changes
|
|
|
|
### Template tree
|
|
|
|
Hide `IsDerived` templates from the main list. They're reachable via:
|
|
- the Compositions tab on the parent template (click the row → opens the
|
|
derived template's edit page)
|
|
- a "Show derived templates" toggle on the tree page (off by default)
|
|
|
|
### TemplateEdit for a derived template
|
|
|
|
Top banner: *"Derived from `$Sensor` — composed inside `$Pump` as `TempSensor`."*
|
|
|
|
Attributes table renders three columns of state:
|
|
- **Override / Inherited** badge per row
|
|
- Locked-from-base attributes render readonly with a 🔒 icon and tooltip
|
|
*"Locked by base — cannot override."*
|
|
|
|
Scripts table same treatment.
|
|
|
|
Adding a new attribute or script on the derived template is allowed (creates
|
|
a row with `IsInherited = false`).
|
|
|
|
Removing an inherited row reverts it to the base value (the row goes back to
|
|
inherited state). Removing an own-added row deletes it.
|
|
|
|
### TemplateEdit for a base template
|
|
|
|
Two extra columns on attribute / script tables:
|
|
- 🔒 toggle for `LockedInDerived` — "Lock this against per-slot override"
|
|
|
|
### Compositions tab
|
|
|
|
Today: lists composition rows with InstanceName + ComposedTemplate name.
|
|
After: each row links to *its derived template* (not the base). InstanceName
|
|
becomes the visible label.
|
|
|
|
Renaming a composition renames the derived template too.
|
|
|
|
### Composition picker (when adding a composition)
|
|
|
|
Today: pick a template + provide an instance name.
|
|
After: pick a **base** template + provide an instance name. The system creates
|
|
the derived template behind the scenes.
|
|
|
|
The picker filters out `IsDerived` templates — you can only compose bases.
|
|
|
|
## Editor metadata implications
|
|
|
|
The multi-parent picker becomes mostly irrelevant:
|
|
|
|
- **Derived template**: always single parent (the composition it's owned by).
|
|
`Parent.*` resolves to that one. No picker.
|
|
- **Base template**: still has no direct parent (it's a library entry).
|
|
`Parent.*` autocompletion is suppressed. Scripts on bases that use
|
|
`Parent.*` get a warning *"Parent access on a base template is ambiguous —
|
|
override this script in the derived template instead."*
|
|
|
|
`TemplateEdit.BuildParentContextsAsync` simplifies to: "if derived, return the
|
|
single owning parent; else return null."
|
|
|
|
`GetTemplatesComposingAsync` repository method still useful (e.g., for "find
|
|
all uses of this base"), but the editor metadata path doesn't need it.
|
|
|
|
## Migration
|
|
|
|
One-shot for existing data:
|
|
|
|
```sql
|
|
-- pseudo-SQL describing intent
|
|
FOREACH composition IN TemplateComposition:
|
|
derived := INSERT INTO Templates (
|
|
Name = parent.Name + "." + composition.InstanceName,
|
|
ParentTemplateId = composition.ComposedTemplateId,
|
|
IsDerived = true,
|
|
OwnerCompositionId = composition.Id
|
|
)
|
|
-- Copy attributes from base, mark IsInherited=true
|
|
INSERT INTO TemplateAttributes
|
|
SELECT @derived.Id, Name, Value, DataType, true, ... FROM base.Attributes
|
|
-- Same for scripts
|
|
UPDATE TemplateComposition SET ComposedTemplateId = derived.Id WHERE Id = composition.Id
|
|
```
|
|
|
|
EF Core migration in `ScadaLink.ConfigurationDatabase/Migrations/`.
|
|
|
|
Rollback strategy: the migration is one-way for new derivations, but old
|
|
composition data can be reconstructed from `IsDerived` templates' `ParentTemplateId`.
|
|
|
|
## Phased rollout
|
|
|
|
Each phase is independently shippable and reviewable.
|
|
|
|
1. **Schema + entities.** Add the new fields. Empty migration. EF mappings.
|
|
No behavior changes. Existing data unaffected.
|
|
|
|
2. **Composition flow change.** Modify `TemplateService.AddCompositionAsync`
|
|
to derive on compose for *new* compositions. Existing data still has direct
|
|
compositions and continues to work. Two modes coexist during the cutover.
|
|
|
|
3. **Migration.** EF Core migration script that walks existing compositions
|
|
and creates the derived templates retroactively. After this all
|
|
compositions are derived.
|
|
|
|
4. **Inherit/override resolution.** Update `FlatteningService` to merge
|
|
inherited and overridden fields. Tests for the override semantics.
|
|
|
|
5. **Lock semantics.** Wire `LockedInDerived` through `TemplateService`
|
|
update paths. Tests.
|
|
|
|
6. **Template tree UI.** Hide derived templates from the main listing;
|
|
surface them through the parent's Compositions tab.
|
|
|
|
7. **Derived TemplateEdit UI.** Banner, inherited/override badges,
|
|
readonly-when-locked, override/revert actions.
|
|
|
|
8. **Base TemplateEdit UI.** Add the LockedInDerived toggle column.
|
|
|
|
9. **Editor metadata simplification.** Replace the multi-parent picker with
|
|
the single-parent resolver. Base templates suppress `Parent.*` assistance
|
|
and warn on use.
|
|
|
|
## Out of scope (for now)
|
|
|
|
- Versioning of base templates with explicit "update derived templates to
|
|
base v2" workflow.
|
|
- Reverse-flow: editing a derived value and asking "promote to base."
|
|
- Multiple inheritance levels for derivation (e.g., `$Sensor → $Sensor.Pump →
|
|
$Sensor.Pump.HighTemp`) — the data model supports it via
|
|
`ParentTemplateId`, but the UX hasn't been designed.
|
|
- Cross-tenant template libraries.
|
|
|
|
## Decisions
|
|
|
|
- **Naming**: dot-separated (`Pump.TempSensor`). Matches the canonical-path
|
|
format used in flattening. Visible in audit logs / error messages.
|
|
- **Delete base with derivatives**: block the delete and list the derivatives.
|
|
User must remove or repoint them first.
|
|
- **Migration of existing data**: EF Core migration on next startup
|
|
auto-derives every existing composition. After deploy all compositions are
|
|
derived; no mixed-mode code paths.
|
|
- **Tree UX**: derived templates hidden by default. "Show derived templates"
|
|
toggle on the tree page reveals them indented under their base. Always
|
|
reachable from the parent's Compositions tab.
|
|
|
|
## Confirmed semantics
|
|
|
|
- **Re-composing the same base on the same parent in two slots** (e.g. Pump
|
|
composes Sensor twice as `IntakeSensor` and `OutletSensor`) produces two
|
|
derived templates: `Pump.IntakeSensor` and `Pump.OutletSensor`, both
|
|
inheriting from `Sensor`.
|
|
|
|
- **Inheritance updates flow downward**: if a base attribute changes value
|
|
later and the derivative has `IsInherited = true` for that attribute, the
|
|
derived value updates. Once overridden (`IsInherited = false`), changes to
|
|
the base no longer affect that field.
|
|
|
|
- **Subsequent `LockedInDerived` after overrides exist**: surface as a
|
|
validation error at deploy time; do not force-revert silently.
|