Compare commits
3 Commits
0139c9ca83
...
5615f3d0c7
| Author | SHA1 | Date | |
|---|---|---|---|
| 5615f3d0c7 | |||
| a968cefbc2 | |||
| 68548432b3 |
@@ -0,0 +1,272 @@
|
||||
# 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.
|
||||
@@ -12,6 +12,21 @@ public class Template
|
||||
public ICollection<TemplateScript> Scripts { get; set; } = new List<TemplateScript>();
|
||||
public ICollection<TemplateComposition> Compositions { get; set; } = new List<TemplateComposition>();
|
||||
|
||||
/// <summary>
|
||||
/// True when this template was auto-derived to back a TemplateComposition
|
||||
/// slot. Derived templates inherit from a base (see <see cref="ParentTemplateId"/>),
|
||||
/// are owned by their composition row (see <see cref="OwnerCompositionId"/>),
|
||||
/// and are hidden from the main template tree by default.
|
||||
/// </summary>
|
||||
public bool IsDerived { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Back-reference to the <see cref="TemplateComposition"/> that owns this
|
||||
/// derived template. Non-null only when <see cref="IsDerived"/>; cascade-
|
||||
/// delete when the composition is removed. Always null on base templates.
|
||||
/// </summary>
|
||||
public int? OwnerCompositionId { get; set; }
|
||||
|
||||
public Template(string name)
|
||||
{
|
||||
Name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
|
||||
@@ -13,6 +13,21 @@ public class TemplateAttribute
|
||||
public string? Description { get; set; }
|
||||
public string? DataSourceReference { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when this row was copied from the base template and has not been
|
||||
/// overridden on the derived template. Changes to the base flow downward
|
||||
/// for inherited rows; an explicit override flips this to false.
|
||||
/// Always false on base (non-derived) templates.
|
||||
/// </summary>
|
||||
public bool IsInherited { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Set on a base attribute. When true, derived templates may not override
|
||||
/// the value — the row is rendered readonly with a 🔒 in the derived UI,
|
||||
/// and any attempt to update it through the API is rejected.
|
||||
/// </summary>
|
||||
public bool LockedInDerived { get; set; }
|
||||
|
||||
public TemplateAttribute(string name)
|
||||
{
|
||||
Name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
|
||||
@@ -13,6 +13,21 @@ public class TemplateScript
|
||||
public string? ReturnDefinition { get; set; }
|
||||
public TimeSpan? MinTimeBetweenRuns { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when this row was copied from the base template and has not been
|
||||
/// overridden on the derived template. Changes to the base flow downward
|
||||
/// for inherited rows; an explicit override flips this to false.
|
||||
/// Always false on base (non-derived) templates.
|
||||
/// </summary>
|
||||
public bool IsInherited { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Set on a base script. When true, derived templates may not override
|
||||
/// the script body — the row is rendered readonly with a 🔒 in the derived
|
||||
/// UI, and any attempt to update it through the API is rejected.
|
||||
/// </summary>
|
||||
public bool LockedInDerived { get; set; }
|
||||
|
||||
public TemplateScript(string name, string code)
|
||||
{
|
||||
Name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
|
||||
Generated
+1300
File diff suppressed because it is too large
Load Diff
+83
@@ -0,0 +1,83 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddDerivedTemplateFields : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsInherited",
|
||||
table: "TemplateScripts",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "LockedInDerived",
|
||||
table: "TemplateScripts",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsDerived",
|
||||
table: "Templates",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "OwnerCompositionId",
|
||||
table: "Templates",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsInherited",
|
||||
table: "TemplateAttributes",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "LockedInDerived",
|
||||
table: "TemplateAttributes",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsInherited",
|
||||
table: "TemplateScripts");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LockedInDerived",
|
||||
table: "TemplateScripts");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsDerived",
|
||||
table: "Templates");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "OwnerCompositionId",
|
||||
table: "Templates");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsInherited",
|
||||
table: "TemplateAttributes");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LockedInDerived",
|
||||
table: "TemplateAttributes");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -848,11 +848,17 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
|
||||
b.Property<int?>("FolderId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<bool>("IsDerived")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<int?>("OwnerCompositionId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int?>("ParentTemplateId")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -935,9 +941,15 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("nvarchar(2000)");
|
||||
|
||||
b.Property<bool>("IsInherited")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsLocked")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("LockedInDerived")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
@@ -1027,9 +1039,15 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("IsInherited")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsLocked")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("LockedInDerived")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<TimeSpan?>("MinTimeBetweenRuns")
|
||||
.HasColumnType("time");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user