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:
@@ -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
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("Name")
|
||||
.IsUnique();
|
||||
.IsUnique()
|
||||
.HasFilter("[IsDerived] = 0");
|
||||
|
||||
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)
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user