feat(template-engine): contained names for composition-derived templates
A composition-derived template now stores its contained name — the
composition slot's InstanceName (e.g. "Pump"), unique only within its
owner — instead of the dotted global path ("Motor Controller.Pump").
The qualified hierarchical name is computed on read.
- TemplateNaming.QualifiedName: walks the OwnerCompositionId chain to
build the dotted path; null-safe, cycle-guarded.
- TemplateConfiguration: the unique index on Template.Name becomes
filtered (WHERE IsDerived = 0) — base templates stay globally unique;
derived templates' uniqueness is the existing (TemplateId,
InstanceName) index on TemplateComposition.
- Migration ContainedDerivedTemplateNames: rewrites derived rows to the
contained name; Down rebuilds the dotted names via a recursive CTE
before restoring the global index.
- TemplateService: composition create/rename store the contained name;
the dotted-name collision pre-checks and cascade-rename are removed
(a slot rename no longer touches nested derived templates).
- TemplateEdit: title shows the contained name; the qualified path is a
breadcrumb subtitle; "composed inside" uses the owner's qualified name.
TDD: 4 TemplateNaming tests + updated composition tests. TemplateEngine
293, ConfigurationDatabase 114, CentralUI 316 green. Migration applied to
the dev cluster and verified in the browser (Motor Controller.Pump now
titled "Pump"; nested Motor Controller.Pump.TempSensor resolves).
Design: docs/plans/2026-05-18-contained-template-names-design.md
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
# Contained Names for Composition-Derived Templates — Design
|
# Contained Names for Composition-Derived Templates — Design
|
||||||
|
|
||||||
**Date:** 2026-05-18
|
**Date:** 2026-05-18
|
||||||
**Status:** Approved (brainstorming) — implementation to follow
|
**Status:** Implemented
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
- 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 Granularity
|
||||||
|
|
||||||
Override and lock rules apply per entity type at the following 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>
|
<a href="/design/templates/@_baseTemplate.Id"><code>@_baseTemplate.Name</code></a>
|
||||||
@if (_ownerTemplate != null && _ownerComposition != null)
|
@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>
|
||||||
</div>
|
</div>
|
||||||
@@ -248,6 +248,12 @@
|
|||||||
{
|
{
|
||||||
<span class="text-muted ms-2">inherits @(_templates.FirstOrDefault(t => t.Id == _selectedTemplate.ParentTemplateId)?.Name)</span>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<button class="btn btn-outline-info btn-sm me-1" @onclick="RunValidation" disabled="@_validating">
|
<button class="btn btn-outline-info btn-sm me-1" @onclick="RunValidation" disabled="@_validating">
|
||||||
@@ -1701,6 +1707,18 @@
|
|||||||
else { _toast.ShowError(result.Error); }
|
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 ----
|
// ---- Editor metadata builders ----
|
||||||
|
|
||||||
private async Task<IReadOnlyList<ScadaLink.CentralUI.ScriptAnalysis.CompositionContext>> BuildChildContextsAsync(
|
private async Task<IReadOnlyList<ScadaLink.CentralUI.ScriptAnalysis.CompositionContext>> BuildChildContextsAsync(
|
||||||
|
|||||||
@@ -17,7 +17,12 @@ public class TemplateConfiguration : IEntityTypeConfiguration<Template>
|
|||||||
builder.Property(t => t.Description)
|
builder.Property(t => t.Description)
|
||||||
.HasMaxLength(2000);
|
.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)
|
// Self-referencing parent template (inheritance)
|
||||||
builder.HasOne<Template>()
|
builder.HasOne<Template>()
|
||||||
|
|||||||
1351
src/ScadaLink.ConfigurationDatabase/Migrations/20260518214444_ContainedDerivedTemplateNames.Designer.cs
generated
Normal file
1351
src/ScadaLink.ConfigurationDatabase/Migrations/20260518214444_ContainedDerivedTemplateNames.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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("FolderId");
|
||||||
|
|
||||||
b.HasIndex("Name")
|
b.HasIndex("Name")
|
||||||
.IsUnique();
|
.IsUnique()
|
||||||
|
.HasFilter("[IsDerived] = 0");
|
||||||
|
|
||||||
b.HasIndex("ParentTemplateId");
|
b.HasIndex("ParentTemplateId");
|
||||||
|
|
||||||
|
|||||||
53
src/ScadaLink.TemplateEngine/TemplateNaming.cs
Normal file
53
src/ScadaLink.TemplateEngine/TemplateNaming.cs
Normal file
@@ -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)
|
if (collisions.Count > 0)
|
||||||
return Result<TemplateComposition>.Failure(string.Join(" ", collisions));
|
return Result<TemplateComposition>.Failure(string.Join(" ", collisions));
|
||||||
|
|
||||||
// Derived template name uses dot-separated path: "<parent>.<slot>". The
|
// No global name pre-check: derived templates store their contained
|
||||||
// cascade may create additional derived templates one level per slot
|
// (slot) name, which need only be unique within the owner — and that is
|
||||||
// (composing $Sensor with a Probe1 slot into $Pump produces both
|
// already enforced above and by the (TemplateId, InstanceName) index.
|
||||||
// $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.");
|
|
||||||
|
|
||||||
var composition = await CreateCascadedCompositionAsync(template, baseTemplate, instanceName, user, cancellationToken);
|
var composition = await CreateCascadedCompositionAsync(template, baseTemplate, instanceName, user, cancellationToken);
|
||||||
await _auditService.LogAsync(user, "Create", "TemplateComposition", "0", instanceName, composition, cancellationToken);
|
await _auditService.LogAsync(user, "Create", "TemplateComposition", "0", instanceName, composition, cancellationToken);
|
||||||
await _repository.SaveChangesAsync(cancellationToken);
|
await _repository.SaveChangesAsync(cancellationToken);
|
||||||
@@ -633,8 +622,10 @@ public class TemplateService
|
|||||||
string user,
|
string user,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var derivedName = $"{outerTemplate.Name}.{instanceName}";
|
// A derived template stores its contained name — the slot's instance
|
||||||
var derived = BuildDerivedTemplate(source, derivedName);
|
// 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.AddTemplateAsync(derived, cancellationToken);
|
||||||
await _repository.SaveChangesAsync(cancellationToken);
|
await _repository.SaveChangesAsync(cancellationToken);
|
||||||
@@ -664,19 +655,6 @@ public class TemplateService
|
|||||||
return composition;
|
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(
|
public async Task<Result<TemplateComposition>> RenameCompositionAsync(
|
||||||
int compositionId,
|
int compositionId,
|
||||||
string newInstanceName,
|
string newInstanceName,
|
||||||
@@ -700,33 +678,15 @@ public class TemplateService
|
|||||||
return Result<TemplateComposition>.Failure(
|
return Result<TemplateComposition>.Failure(
|
||||||
$"Slot name '{newInstanceName}' already exists on '{owner.Name}'.");
|
$"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);
|
var derived = await _repository.GetTemplateByIdAsync(composition.ComposedTemplateId, cancellationToken);
|
||||||
if (derived != null && derived.IsDerived && derived.OwnerCompositionId == compositionId)
|
if (derived != null && derived.IsDerived && derived.OwnerCompositionId == compositionId)
|
||||||
{
|
{
|
||||||
var newDerivedName = $"{owner.Name}.{newInstanceName}";
|
derived.Name = newInstanceName;
|
||||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
await _repository.UpdateTemplateAsync(derived, 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
composition.InstanceName = newInstanceName;
|
composition.InstanceName = newInstanceName;
|
||||||
@@ -763,30 +723,6 @@ public class TemplateService
|
|||||||
return Result<bool>.Success(true);
|
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>
|
/// <summary>
|
||||||
/// Recursively deletes a derived template along with the cascade of inner
|
/// Recursively deletes a derived template along with the cascade of inner
|
||||||
/// derived templates the compose flow created. Each composition row on the
|
/// derived templates the compose flow created. Each composition row on the
|
||||||
|
|||||||
87
tests/ScadaLink.TemplateEngine.Tests/TemplateNamingTests.cs
Normal file
87
tests/ScadaLink.TemplateEngine.Tests/TemplateNamingTests.cs
Normal file
@@ -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.Equal("myModule", result.Value.InstanceName);
|
||||||
Assert.NotNull(captured);
|
Assert.NotNull(captured);
|
||||||
Assert.True(captured!.IsDerived);
|
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.Equal(2, captured.ParentTemplateId);
|
||||||
Assert.Single(captured.Attributes);
|
Assert.Single(captured.Attributes);
|
||||||
Assert.True(captured.Attributes.First().IsInherited);
|
Assert.True(captured.Attributes.First().IsInherited);
|
||||||
@@ -372,13 +372,13 @@ public class TemplateServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task AddComposition_CascadesChildCompositions()
|
public async Task AddComposition_CascadesChildCompositions()
|
||||||
{
|
{
|
||||||
// $Probe (base) → $Sensor.Probe1 (derived) ← $Sensor composes "Probe1"
|
// $Probe (base) → $Sensor's "Probe1" derived ← $Sensor composes "Probe1"
|
||||||
// Composing $Sensor into $Pump as "TempSensor" should produce:
|
// Composing $Sensor into $Pump as "TempSensor" should produce two derived
|
||||||
// $Pump.TempSensor (derived from $Sensor)
|
// templates whose stored names are their *contained* names (the slot
|
||||||
// $Pump.TempSensor.Probe1 (derived from $Sensor.Probe1)
|
// names) — "TempSensor" and "Probe1" — plus a composition row on the
|
||||||
// plus a composition row on $Pump.TempSensor pointing at the new inner derived.
|
// TempSensor derived pointing at the new inner derived.
|
||||||
var probe = new Template("Probe") { Id = 10 };
|
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 };
|
var sensor = new Template("Sensor") { Id = 2 };
|
||||||
sensor.Compositions.Add(new TemplateComposition("Probe1") { Id = 1, TemplateId = 2, ComposedTemplateId = 11 });
|
sensor.Compositions.Add(new TemplateComposition("Probe1") { Id = 1, TemplateId = 2, ComposedTemplateId = 11 });
|
||||||
var pump = new Template("Pump") { Id = 1 };
|
var pump = new Template("Pump") { Id = 1 };
|
||||||
@@ -403,9 +403,9 @@ public class TemplateServiceTests
|
|||||||
|
|
||||||
Assert.True(result.IsSuccess);
|
Assert.True(result.IsSuccess);
|
||||||
Assert.Equal(2, captured.Count);
|
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(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(11, captured[1].ParentTemplateId);
|
||||||
Assert.Equal(2, capturedCompositions.Count);
|
Assert.Equal(2, capturedCompositions.Count);
|
||||||
Assert.Equal("TempSensor", capturedCompositions[0].InstanceName);
|
Assert.Equal("TempSensor", capturedCompositions[0].InstanceName);
|
||||||
@@ -428,9 +428,13 @@ public class TemplateServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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 moduleTemplate = new Template("Module") { Id = 2 };
|
||||||
var template = new Template("Parent") { Id = 1 };
|
var template = new Template("Parent") { Id = 1 };
|
||||||
|
|
||||||
@@ -439,92 +443,67 @@ public class TemplateServiceTests
|
|||||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Template> { template, moduleTemplate, existing });
|
.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");
|
var result = await _service.AddCompositionAsync(1, 2, "myModule", "admin");
|
||||||
|
|
||||||
Assert.True(result.IsFailure);
|
Assert.True(result.IsSuccess);
|
||||||
Assert.Contains("already exists", result.Error);
|
Assert.Single(captured);
|
||||||
|
Assert.Equal("myModule", captured[0].Name);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RenameComposition_RenamesSlotAndDerivedTemplate()
|
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 composition = new TemplateComposition("OldSlot") { Id = 50, TemplateId = 1, ComposedTemplateId = 77 };
|
||||||
var owner = new Template("Pump") { Id = 1 };
|
var owner = new Template("Pump") { Id = 1 };
|
||||||
owner.Compositions.Add(composition);
|
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.GetTemplateCompositionByIdAsync(50, It.IsAny<CancellationToken>())).ReturnsAsync(composition);
|
||||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(owner);
|
_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(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");
|
var result = await _service.RenameCompositionAsync(50, "NewSlot", "admin");
|
||||||
|
|
||||||
Assert.True(result.IsSuccess);
|
Assert.True(result.IsSuccess);
|
||||||
Assert.Equal("NewSlot", result.Value.InstanceName);
|
Assert.Equal("NewSlot", result.Value.InstanceName);
|
||||||
Assert.Equal("Pump.NewSlot", derived.Name);
|
Assert.Equal("NewSlot", derived.Name);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RenameComposition_CascadesRenameToNestedDerivedTemplates()
|
public async Task RenameComposition_RenamesOnlyTheSlotsOwnDerivedTemplate()
|
||||||
{
|
{
|
||||||
// Pump.TempSensor is the slot-owned derived; Pump.TempSensor.Probe1 is a
|
// With contained names, a nested derived template's name is its own
|
||||||
// cascaded inner derived under it. Renaming the TempSensor slot to
|
// slot name and is unaffected by renaming an ancestor slot. Renaming
|
||||||
// MainSensor must rename BOTH derived templates so the dotted-path
|
// the TempSensor slot to MainSensor renames only that derived template;
|
||||||
// naming invariant holds: Pump.MainSensor and Pump.MainSensor.Probe1.
|
// the inner "Probe1" derived is left untouched.
|
||||||
var innerComp = new TemplateComposition("Probe1") { Id = 51, TemplateId = 77, ComposedTemplateId = 78 };
|
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 composition = new TemplateComposition("TempSensor") { Id = 50, TemplateId = 1, ComposedTemplateId = 77 };
|
||||||
var owner = new Template("Pump") { Id = 1 };
|
var owner = new Template("Pump") { Id = 1 };
|
||||||
owner.Compositions.Add(composition);
|
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);
|
derived.Compositions.Add(innerComp);
|
||||||
|
|
||||||
_repoMock.Setup(r => r.GetTemplateCompositionByIdAsync(50, It.IsAny<CancellationToken>())).ReturnsAsync(composition);
|
_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(1, It.IsAny<CancellationToken>())).ReturnsAsync(owner);
|
||||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);
|
_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.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");
|
var result = await _service.RenameCompositionAsync(50, "MainSensor", "admin");
|
||||||
|
|
||||||
Assert.True(result.IsSuccess);
|
Assert.True(result.IsSuccess);
|
||||||
Assert.Equal("MainSensor", result.Value.InstanceName);
|
Assert.Equal("MainSensor", result.Value.InstanceName);
|
||||||
Assert.Equal("Pump.MainSensor", derived.Name);
|
Assert.Equal("MainSensor", derived.Name);
|
||||||
Assert.Equal("Pump.MainSensor.Probe1", innerDerived.Name);
|
Assert.Equal("Probe1", innerDerived.Name);
|
||||||
_repoMock.Verify(r => r.UpdateTemplateAsync(
|
_repoMock.Verify(r => r.UpdateTemplateAsync(
|
||||||
It.Is<Template>(t => t.Id == 78), It.IsAny<CancellationToken>()), Times.Once);
|
It.Is<Template>(t => t.Id == 78), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
}
|
|
||||||
|
|
||||||
[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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user