Compare commits
2 Commits
36c6036060
...
06462a0100
| Author | SHA1 | Date | |
|---|---|---|---|
| 06462a0100 | |||
| 2d4b287ab2 |
@@ -0,0 +1,130 @@
|
||||
# Contained Names for Composition-Derived Templates — Design
|
||||
|
||||
**Date:** 2026-05-18
|
||||
**Status:** Implemented
|
||||
|
||||
## 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`
|
||||
@@ -81,6 +81,18 @@ When a template composes a feature module, members from that module are addresse
|
||||
- The composing template's own members (not from a module) have no prefix — they are top-level names.
|
||||
- Naming collision detection operates on canonical names, so two modules can define the same member name as long as their module instance names differ.
|
||||
|
||||
### Derived template naming
|
||||
|
||||
A composition slot is materialized as its own *derived* template. A derived
|
||||
template stores a **contained name** — the composition slot's instance name
|
||||
(e.g. `Pump`), unique only within its owner. The **qualified name**
|
||||
(`Motor Controller.Pump`, or `Motor Controller.Pump.TempSensor` when nested) is
|
||||
*computed* on read by walking the owner-composition chain — it is not stored.
|
||||
Only base (user-authored) templates are globally unique by name; a derived
|
||||
template's uniqueness is the slot-name uniqueness within its owner. The Central
|
||||
UI shows the contained name as the template title and the qualified path as a
|
||||
breadcrumb.
|
||||
|
||||
## Override Granularity
|
||||
|
||||
Override and lock rules apply per entity type at the following granularity:
|
||||
|
||||
@@ -236,7 +236,7 @@
|
||||
<a href="/design/templates/@_baseTemplate.Id"><code>@_baseTemplate.Name</code></a>
|
||||
@if (_ownerTemplate != null && _ownerComposition != null)
|
||||
{
|
||||
<span class="ms-1">— composed inside <a href="/design/templates/@_ownerTemplate.Id"><code>@_ownerTemplate.Name</code></a> as <code>@_ownerComposition.InstanceName</code>.</span>
|
||||
<span class="ms-1">— composed inside <a href="/design/templates/@_ownerTemplate.Id"><code>@QualifiedTemplateName(_ownerTemplate)</code></a> as <code>@_ownerComposition.InstanceName</code>.</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -248,6 +248,12 @@
|
||||
{
|
||||
<span class="text-muted ms-2">inherits @(_templates.FirstOrDefault(t => t.Id == _selectedTemplate.ParentTemplateId)?.Name)</span>
|
||||
}
|
||||
@if (_selectedTemplate.IsDerived)
|
||||
{
|
||||
@* Derived templates store a contained name; show the full
|
||||
qualified path as a breadcrumb subtitle. *@
|
||||
<div class="text-muted small font-monospace">@QualifiedTemplateName(_selectedTemplate)</div>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-outline-info btn-sm me-1" @onclick="RunValidation" disabled="@_validating">
|
||||
@@ -1701,6 +1707,18 @@
|
||||
else { _toast.ShowError(result.Error); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a template's qualified (hierarchical) name from the loaded
|
||||
/// template set — the stored name for a base template, the dotted
|
||||
/// owner-chain path for a composition-derived one.
|
||||
/// </summary>
|
||||
private string QualifiedTemplateName(Template template)
|
||||
{
|
||||
var byId = _templates.ToDictionary(t => t.Id);
|
||||
var compById = _templates.SelectMany(t => t.Compositions).ToDictionary(c => c.Id);
|
||||
return TemplateNaming.QualifiedName(template, byId, compById);
|
||||
}
|
||||
|
||||
// ---- Editor metadata builders ----
|
||||
|
||||
private async Task<IReadOnlyList<ScadaLink.CentralUI.ScriptAnalysis.CompositionContext>> BuildChildContextsAsync(
|
||||
|
||||
@@ -17,7 +17,12 @@ public class TemplateConfiguration : IEntityTypeConfiguration<Template>
|
||||
builder.Property(t => t.Description)
|
||||
.HasMaxLength(2000);
|
||||
|
||||
builder.HasIndex(t => t.Name).IsUnique();
|
||||
// Only base (user-authored) templates are globally unique by name.
|
||||
// Derived templates store their *contained* name (the composition slot's
|
||||
// InstanceName), unique only within the owner — enforced by the
|
||||
// (TemplateId, InstanceName) index on TemplateComposition — so they are
|
||||
// excluded from this index via a filter.
|
||||
builder.HasIndex(t => t.Name).IsUnique().HasFilter("[IsDerived] = 0");
|
||||
|
||||
// Self-referencing parent template (inheritance)
|
||||
builder.HasOne<Template>()
|
||||
|
||||
+1351
File diff suppressed because it is too large
Load Diff
+81
@@ -0,0 +1,81 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// Moves composition-derived templates to AVEVA-style contained names: a
|
||||
/// derived template stores only its slot name (e.g. <c>Pump</c>), not the
|
||||
/// dotted qualified path (<c>Motor Controller.Pump</c>). The qualified name
|
||||
/// is computed on read by walking the OwnerComposition chain. The unique
|
||||
/// index on Template.Name becomes filtered to base templates only —
|
||||
/// derived templates' uniqueness is the (TemplateId, InstanceName) index on
|
||||
/// TemplateComposition.
|
||||
/// </summary>
|
||||
public partial class ContainedDerivedTemplateNames : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Drop the global unique index first: derived rows are about to be
|
||||
// renamed to contained names that may duplicate one another or a
|
||||
// base template.
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Templates_Name",
|
||||
table: "Templates");
|
||||
|
||||
// Collapse every derived template's dotted name to its contained
|
||||
// name — the owning composition slot's InstanceName.
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE t
|
||||
SET t.Name = c.InstanceName
|
||||
FROM Templates t
|
||||
INNER JOIN TemplateCompositions c ON c.Id = t.OwnerCompositionId
|
||||
WHERE t.IsDerived = 1;");
|
||||
|
||||
// Recreate the uniqueness guarantee for base templates only.
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Templates_Name",
|
||||
table: "Templates",
|
||||
column: "Name",
|
||||
unique: true,
|
||||
filter: "[IsDerived] = 0");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Templates_Name",
|
||||
table: "Templates");
|
||||
|
||||
// Rebuild the dotted qualified names so the global unique index can
|
||||
// be restored — derived templates' contained names are not globally
|
||||
// unique. The recursive CTE walks the OwnerComposition chain down
|
||||
// from each base template.
|
||||
migrationBuilder.Sql(@"
|
||||
WITH q AS (
|
||||
SELECT t.Id, CAST(t.Name AS NVARCHAR(MAX)) AS Qualified
|
||||
FROM Templates t
|
||||
WHERE t.IsDerived = 0
|
||||
UNION ALL
|
||||
SELECT t.Id, CAST(q.Qualified + N'.' + c.InstanceName AS NVARCHAR(MAX))
|
||||
FROM Templates t
|
||||
INNER JOIN TemplateCompositions c ON c.Id = t.OwnerCompositionId
|
||||
INNER JOIN q ON q.Id = c.TemplateId
|
||||
)
|
||||
UPDATE t
|
||||
SET t.Name = q.Qualified
|
||||
FROM Templates t
|
||||
INNER JOIN q ON q.Id = t.Id
|
||||
WHERE t.IsDerived = 1;");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Templates_Name",
|
||||
table: "Templates",
|
||||
column: "Name",
|
||||
unique: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -900,7 +900,8 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
|
||||
b.HasIndex("FolderId");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
.IsUnique()
|
||||
.HasFilter("[IsDerived] = 0");
|
||||
|
||||
b.HasIndex("ParentTemplateId");
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
|
||||
namespace ScadaLink.TemplateEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the hierarchical ("qualified") name of a composition-derived
|
||||
/// template. A derived template stores only its <em>contained</em> name — the
|
||||
/// owning composition slot's <c>InstanceName</c>, unique only within that owner.
|
||||
/// The qualified path (<c>Owner.Slot.Slot…</c>) is computed on demand by
|
||||
/// walking the <see cref="Template.OwnerCompositionId"/> chain up to the base
|
||||
/// template.
|
||||
/// </summary>
|
||||
public static class TemplateNaming
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the dotted hierarchical name of <paramref name="template"/>. For
|
||||
/// a base (non-derived) template this is just its stored name. The walk is
|
||||
/// null-safe: if any owner link is missing from the supplied lookups it
|
||||
/// stops and falls back to the stored contained name, and a cycle (which
|
||||
/// the composition graph should never contain) is broken defensively.
|
||||
/// </summary>
|
||||
public static string QualifiedName(
|
||||
Template template,
|
||||
IReadOnlyDictionary<int, Template> byId,
|
||||
IReadOnlyDictionary<int, TemplateComposition> compById)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(template);
|
||||
ArgumentNullException.ThrowIfNull(byId);
|
||||
ArgumentNullException.ThrowIfNull(compById);
|
||||
|
||||
return Resolve(template, byId, compById, new HashSet<int>());
|
||||
}
|
||||
|
||||
private static string Resolve(
|
||||
Template template,
|
||||
IReadOnlyDictionary<int, Template> byId,
|
||||
IReadOnlyDictionary<int, TemplateComposition> compById,
|
||||
HashSet<int> visited)
|
||||
{
|
||||
// Base template, broken owner link, or a cycle → the stored name is the
|
||||
// best (and contained) answer.
|
||||
if (!template.IsDerived
|
||||
|| template.OwnerCompositionId is not { } compId
|
||||
|| !compById.TryGetValue(compId, out var composition)
|
||||
|| !byId.TryGetValue(composition.TemplateId, out var owner)
|
||||
|| !visited.Add(template.Id))
|
||||
{
|
||||
return template.Name;
|
||||
}
|
||||
|
||||
return $"{Resolve(owner, byId, compById, visited)}.{composition.InstanceName}";
|
||||
}
|
||||
}
|
||||
@@ -598,20 +598,9 @@ public class TemplateService
|
||||
if (collisions.Count > 0)
|
||||
return Result<TemplateComposition>.Failure(string.Join(" ", collisions));
|
||||
|
||||
// Derived template name uses dot-separated path: "<parent>.<slot>". The
|
||||
// cascade may create additional derived templates one level per slot
|
||||
// (composing $Sensor with a Probe1 slot into $Pump produces both
|
||||
// $Pump.TempSensor and $Pump.TempSensor.Probe1). Pre-check every name
|
||||
// the cascade is about to introduce so a deep collision aborts before
|
||||
// any rows mutate.
|
||||
var byId = allTemplates.ToDictionary(t => t.Id);
|
||||
var cascadeNames = EnumerateCascadeNames(template.Name, instanceName, baseTemplate, byId).ToList();
|
||||
var existingNames = allTemplates.Select(t => t.Name).ToHashSet(StringComparer.Ordinal);
|
||||
var nameCollision = cascadeNames.FirstOrDefault(n => existingNames.Contains(n));
|
||||
if (nameCollision != null)
|
||||
return Result<TemplateComposition>.Failure(
|
||||
$"Cannot create derived template '{nameCollision}': a template with that name already exists.");
|
||||
|
||||
// No global name pre-check: derived templates store their contained
|
||||
// (slot) name, which need only be unique within the owner — and that is
|
||||
// already enforced above and by the (TemplateId, InstanceName) index.
|
||||
var composition = await CreateCascadedCompositionAsync(template, baseTemplate, instanceName, user, cancellationToken);
|
||||
await _auditService.LogAsync(user, "Create", "TemplateComposition", "0", instanceName, composition, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
@@ -633,8 +622,10 @@ public class TemplateService
|
||||
string user,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var derivedName = $"{outerTemplate.Name}.{instanceName}";
|
||||
var derived = BuildDerivedTemplate(source, derivedName);
|
||||
// A derived template stores its contained name — the slot's instance
|
||||
// name, unique within its owner. The qualified path is computed on read
|
||||
// (see TemplateNaming.QualifiedName).
|
||||
var derived = BuildDerivedTemplate(source, instanceName);
|
||||
|
||||
await _repository.AddTemplateAsync(derived, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
@@ -664,19 +655,6 @@ public class TemplateService
|
||||
return composition;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateCascadeNames(
|
||||
string outerName, string instanceName, Template source, IReadOnlyDictionary<int, Template> byId)
|
||||
{
|
||||
var derivedName = $"{outerName}.{instanceName}";
|
||||
yield return derivedName;
|
||||
foreach (var comp in source.Compositions)
|
||||
{
|
||||
if (!byId.TryGetValue(comp.ComposedTemplateId, out var child)) continue;
|
||||
foreach (var name in EnumerateCascadeNames(derivedName, comp.InstanceName, child, byId))
|
||||
yield return name;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result<TemplateComposition>> RenameCompositionAsync(
|
||||
int compositionId,
|
||||
string newInstanceName,
|
||||
@@ -700,33 +678,15 @@ public class TemplateService
|
||||
return Result<TemplateComposition>.Failure(
|
||||
$"Slot name '{newInstanceName}' already exists on '{owner.Name}'.");
|
||||
|
||||
// The derived template stores the slot's contained name, so renaming
|
||||
// the slot renames just that one template. Nested derived templates
|
||||
// keep their own contained names — an ancestor slot rename never
|
||||
// touches them, and the qualified path is recomputed on read.
|
||||
var derived = await _repository.GetTemplateByIdAsync(composition.ComposedTemplateId, cancellationToken);
|
||||
if (derived != null && derived.IsDerived && derived.OwnerCompositionId == compositionId)
|
||||
{
|
||||
var newDerivedName = $"{owner.Name}.{newInstanceName}";
|
||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
||||
|
||||
// The cascade of derived templates created by AddComposition follows a
|
||||
// dotted path (Pump.TempSensor and the nested Pump.TempSensor.Probe1).
|
||||
// Renaming the slot must rename every derived template in that cascade
|
||||
// so the dotted-path naming invariant holds — pre-check every new name
|
||||
// the cascade will introduce before any row mutates.
|
||||
var renames = new List<(Template Template, string NewName)>();
|
||||
await CollectCascadeRenamesAsync(derived, newDerivedName, renames, cancellationToken);
|
||||
|
||||
var renamedIds = renames.Select(r => r.Template.Id).ToHashSet();
|
||||
foreach (var (_, newName) in renames)
|
||||
{
|
||||
if (allTemplates.Any(t => !renamedIds.Contains(t.Id) && t.Name == newName))
|
||||
return Result<TemplateComposition>.Failure(
|
||||
$"Cannot rename derived template to '{newName}': a template with that name already exists.");
|
||||
}
|
||||
|
||||
foreach (var (template, newName) in renames)
|
||||
{
|
||||
template.Name = newName;
|
||||
await _repository.UpdateTemplateAsync(template, cancellationToken);
|
||||
}
|
||||
derived.Name = newInstanceName;
|
||||
await _repository.UpdateTemplateAsync(derived, cancellationToken);
|
||||
}
|
||||
|
||||
composition.InstanceName = newInstanceName;
|
||||
@@ -763,30 +723,6 @@ public class TemplateService
|
||||
return Result<bool>.Success(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recursively collects the (template, new name) pairs for a renamed derived
|
||||
/// template and every cascaded inner derived template beneath it. Each inner
|
||||
/// derived's new name is re-derived from its renamed parent and the slot's
|
||||
/// instance name (mirroring the cascade <see cref="CreateCascadedCompositionAsync"/>
|
||||
/// builds and the recursion in <see cref="CascadeDeleteDerivedAsync"/>).
|
||||
/// </summary>
|
||||
private async Task CollectCascadeRenamesAsync(
|
||||
Template derived,
|
||||
string newName,
|
||||
List<(Template Template, string NewName)> renames,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
renames.Add((derived, newName));
|
||||
|
||||
foreach (var child in derived.Compositions.ToList())
|
||||
{
|
||||
var childDerived = await _repository.GetTemplateByIdAsync(child.ComposedTemplateId, cancellationToken);
|
||||
if (childDerived != null && childDerived.IsDerived && childDerived.OwnerCompositionId == child.Id)
|
||||
await CollectCascadeRenamesAsync(
|
||||
childDerived, $"{newName}.{child.InstanceName}", renames, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recursively deletes a derived template along with the cascade of inner
|
||||
/// derived templates the compose flow created. Each composition row on the
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
|
||||
namespace ScadaLink.TemplateEngine.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Coverage for <see cref="TemplateNaming.QualifiedName"/> — the computed
|
||||
/// hierarchical name of a composition-derived template. Derived templates store
|
||||
/// only their contained name (the composition slot's <c>InstanceName</c>); the
|
||||
/// dotted path is resolved on read by walking the <c>OwnerCompositionId</c> chain.
|
||||
/// </summary>
|
||||
public class TemplateNamingTests
|
||||
{
|
||||
private static (Dictionary<int, Template> byId, Dictionary<int, TemplateComposition> compById)
|
||||
BuildGraph(params Template[] templates)
|
||||
{
|
||||
var byId = templates.ToDictionary(t => t.Id);
|
||||
var compById = templates
|
||||
.SelectMany(t => t.Compositions)
|
||||
.ToDictionary(c => c.Id);
|
||||
return (byId, compById);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QualifiedName_BaseTemplate_IsJustItsName()
|
||||
{
|
||||
var motorController = new Template("Motor Controller") { Id = 4 };
|
||||
var (byId, compById) = BuildGraph(motorController);
|
||||
|
||||
Assert.Equal("Motor Controller", TemplateNaming.QualifiedName(motorController, byId, compById));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QualifiedName_OneLevelDerived_PrefixesTheOwner()
|
||||
{
|
||||
// Motor Controller composes the Pump template into a slot named "Pump".
|
||||
var motorController = new Template("Motor Controller") { Id = 4 };
|
||||
motorController.Compositions.Add(
|
||||
new TemplateComposition("Pump") { Id = 1014, TemplateId = 4, ComposedTemplateId = 2018 });
|
||||
var derivedPump = new Template("Pump")
|
||||
{
|
||||
Id = 2018, IsDerived = true, OwnerCompositionId = 1014
|
||||
};
|
||||
var (byId, compById) = BuildGraph(motorController, derivedPump);
|
||||
|
||||
Assert.Equal("Motor Controller.Pump", TemplateNaming.QualifiedName(derivedPump, byId, compById));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QualifiedName_NestedDerived_WalksTheWholeChain()
|
||||
{
|
||||
// Motor Controller -> Pump slot -> TempSensor slot.
|
||||
var motorController = new Template("Motor Controller") { Id = 4 };
|
||||
motorController.Compositions.Add(
|
||||
new TemplateComposition("Pump") { Id = 1014, TemplateId = 4, ComposedTemplateId = 2018 });
|
||||
|
||||
var derivedPump = new Template("Pump")
|
||||
{
|
||||
Id = 2018, IsDerived = true, OwnerCompositionId = 1014
|
||||
};
|
||||
derivedPump.Compositions.Add(
|
||||
new TemplateComposition("TempSensor") { Id = 1015, TemplateId = 2018, ComposedTemplateId = 2019 });
|
||||
|
||||
var derivedTempSensor = new Template("TempSensor")
|
||||
{
|
||||
Id = 2019, IsDerived = true, OwnerCompositionId = 1015
|
||||
};
|
||||
var (byId, compById) = BuildGraph(motorController, derivedPump, derivedTempSensor);
|
||||
|
||||
Assert.Equal(
|
||||
"Motor Controller.Pump.TempSensor",
|
||||
TemplateNaming.QualifiedName(derivedTempSensor, byId, compById));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QualifiedName_DerivedWithMissingOwnerLink_FallsBackToStoredName()
|
||||
{
|
||||
// Defensive: a derived template whose owner composition is not in the
|
||||
// lookup must not throw — it falls back to the stored contained name.
|
||||
var orphan = new Template("TempSensor")
|
||||
{
|
||||
Id = 2019, IsDerived = true, OwnerCompositionId = 9999
|
||||
};
|
||||
var (byId, compById) = BuildGraph(orphan);
|
||||
|
||||
Assert.Equal("TempSensor", TemplateNaming.QualifiedName(orphan, byId, compById));
|
||||
}
|
||||
}
|
||||
@@ -359,7 +359,7 @@ public class TemplateServiceTests
|
||||
Assert.Equal("myModule", result.Value.InstanceName);
|
||||
Assert.NotNull(captured);
|
||||
Assert.True(captured!.IsDerived);
|
||||
Assert.Equal("Parent.myModule", captured.Name);
|
||||
Assert.Equal("myModule", captured.Name); // contained (slot) name, not the qualified path
|
||||
Assert.Equal(2, captured.ParentTemplateId);
|
||||
Assert.Single(captured.Attributes);
|
||||
Assert.True(captured.Attributes.First().IsInherited);
|
||||
@@ -372,13 +372,13 @@ public class TemplateServiceTests
|
||||
[Fact]
|
||||
public async Task AddComposition_CascadesChildCompositions()
|
||||
{
|
||||
// $Probe (base) → $Sensor.Probe1 (derived) ← $Sensor composes "Probe1"
|
||||
// Composing $Sensor into $Pump as "TempSensor" should produce:
|
||||
// $Pump.TempSensor (derived from $Sensor)
|
||||
// $Pump.TempSensor.Probe1 (derived from $Sensor.Probe1)
|
||||
// plus a composition row on $Pump.TempSensor pointing at the new inner derived.
|
||||
// $Probe (base) → $Sensor's "Probe1" derived ← $Sensor composes "Probe1"
|
||||
// Composing $Sensor into $Pump as "TempSensor" should produce two derived
|
||||
// templates whose stored names are their *contained* names (the slot
|
||||
// names) — "TempSensor" and "Probe1" — plus a composition row on the
|
||||
// TempSensor derived pointing at the new inner derived.
|
||||
var probe = new Template("Probe") { Id = 10 };
|
||||
var sensorProbe1 = new Template("Sensor.Probe1") { Id = 11, IsDerived = true, ParentTemplateId = 10, OwnerCompositionId = 1 };
|
||||
var sensorProbe1 = new Template("Probe1") { Id = 11, IsDerived = true, ParentTemplateId = 10, OwnerCompositionId = 1 };
|
||||
var sensor = new Template("Sensor") { Id = 2 };
|
||||
sensor.Compositions.Add(new TemplateComposition("Probe1") { Id = 1, TemplateId = 2, ComposedTemplateId = 11 });
|
||||
var pump = new Template("Pump") { Id = 1 };
|
||||
@@ -403,9 +403,9 @@ public class TemplateServiceTests
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(2, captured.Count);
|
||||
Assert.Equal("Pump.TempSensor", captured[0].Name);
|
||||
Assert.Equal("TempSensor", captured[0].Name);
|
||||
Assert.Equal(2, captured[0].ParentTemplateId);
|
||||
Assert.Equal("Pump.TempSensor.Probe1", captured[1].Name);
|
||||
Assert.Equal("Probe1", captured[1].Name);
|
||||
Assert.Equal(11, captured[1].ParentTemplateId);
|
||||
Assert.Equal(2, capturedCompositions.Count);
|
||||
Assert.Equal("TempSensor", capturedCompositions[0].InstanceName);
|
||||
@@ -428,9 +428,13 @@ public class TemplateServiceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddComposition_DerivedNameCollision_Fails()
|
||||
public async Task AddComposition_DerivedContainedName_NeedNotBeGloballyUnique()
|
||||
{
|
||||
var existing = new Template("Parent.myModule") { Id = 99 };
|
||||
// A derived template's stored name is its contained (slot) name, which
|
||||
// is only unique within its owner — it may freely coincide with an
|
||||
// unrelated base template's name. Composing a module as the slot
|
||||
// "myModule" succeeds even though a base template "myModule" exists.
|
||||
var existing = new Template("myModule") { Id = 99 };
|
||||
var moduleTemplate = new Template("Module") { Id = 2 };
|
||||
var template = new Template("Parent") { Id = 1 };
|
||||
|
||||
@@ -439,92 +443,67 @@ public class TemplateServiceTests
|
||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Template> { template, moduleTemplate, existing });
|
||||
|
||||
var captured = new List<Template>();
|
||||
_repoMock.Setup(r => r.AddTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<Template, CancellationToken>((t, _) => captured.Add(t))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var result = await _service.AddCompositionAsync(1, 2, "myModule", "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("already exists", result.Error);
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Single(captured);
|
||||
Assert.Equal("myModule", captured[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenameComposition_RenamesSlotAndDerivedTemplate()
|
||||
{
|
||||
// The derived template's stored name is the slot's contained name, so
|
||||
// renaming the slot renames the derived template to the new short name.
|
||||
var composition = new TemplateComposition("OldSlot") { Id = 50, TemplateId = 1, ComposedTemplateId = 77 };
|
||||
var owner = new Template("Pump") { Id = 1 };
|
||||
owner.Compositions.Add(composition);
|
||||
var derived = new Template("Pump.OldSlot") { Id = 77, IsDerived = true, OwnerCompositionId = 50, ParentTemplateId = 2 };
|
||||
var derived = new Template("OldSlot") { Id = 77, IsDerived = true, OwnerCompositionId = 50, ParentTemplateId = 2 };
|
||||
|
||||
_repoMock.Setup(r => r.GetTemplateCompositionByIdAsync(50, It.IsAny<CancellationToken>())).ReturnsAsync(composition);
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(owner);
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);
|
||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Template> { owner, derived });
|
||||
|
||||
var result = await _service.RenameCompositionAsync(50, "NewSlot", "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("NewSlot", result.Value.InstanceName);
|
||||
Assert.Equal("Pump.NewSlot", derived.Name);
|
||||
Assert.Equal("NewSlot", derived.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenameComposition_CascadesRenameToNestedDerivedTemplates()
|
||||
public async Task RenameComposition_RenamesOnlyTheSlotsOwnDerivedTemplate()
|
||||
{
|
||||
// Pump.TempSensor is the slot-owned derived; Pump.TempSensor.Probe1 is a
|
||||
// cascaded inner derived under it. Renaming the TempSensor slot to
|
||||
// MainSensor must rename BOTH derived templates so the dotted-path
|
||||
// naming invariant holds: Pump.MainSensor and Pump.MainSensor.Probe1.
|
||||
// With contained names, a nested derived template's name is its own
|
||||
// slot name and is unaffected by renaming an ancestor slot. Renaming
|
||||
// the TempSensor slot to MainSensor renames only that derived template;
|
||||
// the inner "Probe1" derived is left untouched.
|
||||
var innerComp = new TemplateComposition("Probe1") { Id = 51, TemplateId = 77, ComposedTemplateId = 78 };
|
||||
var innerDerived = new Template("Pump.TempSensor.Probe1") { Id = 78, IsDerived = true, OwnerCompositionId = 51, ParentTemplateId = 11 };
|
||||
var innerDerived = new Template("Probe1") { Id = 78, IsDerived = true, OwnerCompositionId = 51, ParentTemplateId = 11 };
|
||||
var composition = new TemplateComposition("TempSensor") { Id = 50, TemplateId = 1, ComposedTemplateId = 77 };
|
||||
var owner = new Template("Pump") { Id = 1 };
|
||||
owner.Compositions.Add(composition);
|
||||
var derived = new Template("Pump.TempSensor") { Id = 77, IsDerived = true, OwnerCompositionId = 50, ParentTemplateId = 2 };
|
||||
var derived = new Template("TempSensor") { Id = 77, IsDerived = true, OwnerCompositionId = 50, ParentTemplateId = 2 };
|
||||
derived.Compositions.Add(innerComp);
|
||||
|
||||
_repoMock.Setup(r => r.GetTemplateCompositionByIdAsync(50, It.IsAny<CancellationToken>())).ReturnsAsync(composition);
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(owner);
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(78, It.IsAny<CancellationToken>())).ReturnsAsync(innerDerived);
|
||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Template> { owner, derived, innerDerived });
|
||||
|
||||
var result = await _service.RenameCompositionAsync(50, "MainSensor", "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("MainSensor", result.Value.InstanceName);
|
||||
Assert.Equal("Pump.MainSensor", derived.Name);
|
||||
Assert.Equal("Pump.MainSensor.Probe1", innerDerived.Name);
|
||||
Assert.Equal("MainSensor", derived.Name);
|
||||
Assert.Equal("Probe1", innerDerived.Name);
|
||||
_repoMock.Verify(r => r.UpdateTemplateAsync(
|
||||
It.Is<Template>(t => t.Id == 78), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenameComposition_NestedCascadeNameCollision_Fails()
|
||||
{
|
||||
// A pre-existing template occupies the name the nested cascade would
|
||||
// produce (Pump.MainSensor.Probe1). The rename must abort before any
|
||||
// row mutates, so the full cascade name set must be pre-checked.
|
||||
var innerComp = new TemplateComposition("Probe1") { Id = 51, TemplateId = 77, ComposedTemplateId = 78 };
|
||||
var innerDerived = new Template("Pump.TempSensor.Probe1") { Id = 78, IsDerived = true, OwnerCompositionId = 51, ParentTemplateId = 11 };
|
||||
var composition = new TemplateComposition("TempSensor") { Id = 50, TemplateId = 1, ComposedTemplateId = 77 };
|
||||
var owner = new Template("Pump") { Id = 1 };
|
||||
owner.Compositions.Add(composition);
|
||||
var derived = new Template("Pump.TempSensor") { Id = 77, IsDerived = true, OwnerCompositionId = 50, ParentTemplateId = 2 };
|
||||
derived.Compositions.Add(innerComp);
|
||||
var collider = new Template("Pump.MainSensor.Probe1") { Id = 99 };
|
||||
|
||||
_repoMock.Setup(r => r.GetTemplateCompositionByIdAsync(50, It.IsAny<CancellationToken>())).ReturnsAsync(composition);
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(owner);
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(78, It.IsAny<CancellationToken>())).ReturnsAsync(innerDerived);
|
||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Template> { owner, derived, innerDerived, collider });
|
||||
|
||||
var result = await _service.RenameCompositionAsync(50, "MainSensor", "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("already exists", result.Error);
|
||||
_repoMock.Verify(r => r.UpdateTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
It.Is<Template>(t => t.Id == 78), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user