fix(transport): carry derived composition-slot templates and rebuild them through the compose flow (ScadaBridge#118)

Bundles did not record that a template was a derived slot template, so the
importer re-created every slot as a BASE template named after the slot.

- Export: TemplateDto gains an additive, null-omitted `Derivation`
  (owner path + base path by stable names); a derived template carries only
  its own rows (overrides/additions, not inherited placeholders). The closure
  pulls in each derived template's base/owner; base orders first.
  schemaVersion 1.1 -> 1.2, bundleFormatVersion stays 1.
- Import: base templates go through the ordinary passes; derived templates are
  ordered topologically (DerivedTemplatePlan; cycle / duplicate / malformed =
  preview Blocker + SemanticValidationException before anything is staged),
  their owner and base resolved by path (#100 root rule), and the slot is
  created with TemplateService.AddCompositionAsync, synced in place when it
  already exists with the same base, or deleted+recomposed when the base
  differs; own rows applied as overrides, then ResyncInheritedMembersAsync.
  Missing owner/base fails that record (BundleImportDerivedTemplateUnresolved
  audit + ImportResult warning), never creating a base in its place.
- Preview/diff: derived rows are keyed by qualified name and compared against
  the target slot at the same owner path (own rows + base), so an unchanged
  slot is Identical and a same-named base template is never matched.
- Pre-#118 bundles import exactly as before (new fixture
  pre-118-derived-plaintext.scadabundle, written by a13aabc7). The field is in
  content, pinned by the signed manifest's contentHash.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-10 05:02:06 -05:00
parent fb04b54d62
commit 84a050420f
14 changed files with 1676 additions and 41 deletions
@@ -422,11 +422,21 @@ public sealed class DependencyResolver
IReadOnlyCollection<Template> frontier = templates.Values.ToList();
while (frontier.Count > 0)
{
frontier = await LoadMissingTemplatesAsync(
frontier.SelectMany(t => t.Compositions).Select(c => c.ComposedTemplateId),
templates,
templateLookup,
ct).ConfigureAwait(false);
// ScadaBridge#118: a derived (composition-slot) template is rebuilt on import by
// composing its BASE under its OWNER, so both travel with it — the base through
// ParentTemplateId, the owner through the OwnerCompositionId row (normally the
// owner is what pulled the slot in; it is looked up only when it is not loaded).
var ids = frontier.SelectMany(t => t.Compositions).Select(c => c.ComposedTemplateId).ToList();
foreach (var derived in frontier.Where(t => t.IsDerived))
{
if (derived.ParentTemplateId is { } baseId) ids.Add(baseId);
if (derived.OwnerCompositionId is not { } compId) continue;
if (templates.Values.Any(t => t.Compositions.Any(c => c.Id == compId))) continue;
var composition = await _templates.GetTemplateCompositionByIdAsync(compId, ct).ConfigureAwait(false);
if (composition is not null) ids.Add(composition.TemplateId);
}
frontier = await LoadMissingTemplatesAsync(ids, templates, templateLookup, ct).ConfigureAwait(false);
}
}
@@ -581,6 +591,13 @@ public sealed class DependencyResolver
outEdges[comp.ComposedTemplateId].Add(t.Id);
inDegree[t.Id]++;
}
// ScadaBridge#118: a derived slot template also follows its base.
if (t.IsDerived && t.ParentTemplateId is { } baseId && baseId != t.Id && nodes.ContainsKey(baseId))
{
outEdges[baseId].Add(t.Id);
inDegree[t.Id]++;
}
}
var queue = new Queue<int>(
@@ -629,8 +646,11 @@ public sealed class DependencyResolver
}
foreach (var t in templates)
{
var deps = t.Compositions
.Select(c => $"Template:{(templates.FirstOrDefault(x => x.Id == c.ComposedTemplateId)?.Name ?? c.ComposedTemplateId.ToString())}")
var depIds = t.Compositions.Select(c => c.ComposedTemplateId).ToList();
// ScadaBridge#118: a derived slot template depends on its base too.
if (t.IsDerived && t.ParentTemplateId is { } baseId) depIds.Add(baseId);
var deps = depIds
.Select(id => $"Template:{(templates.FirstOrDefault(x => x.Id == id)?.Name ?? id.ToString())}")
.OrderBy(x => x, StringComparer.Ordinal)
.ToList();
entries.Add(new ManifestContentEntry("Template", t.Name, 1, deps));
@@ -74,11 +74,72 @@ public sealed class ArtifactDiff
AddIfDifferent(changes, "Description", existing.Description, incoming.Description);
AddIfDifferent(changes, "FolderName", FolderNameOf(existing, folderNameById), incoming.FolderName);
AddIfDifferent(changes, "BaseTemplateName", BaseTemplateNameOf(existing, templateNameById), incoming.BaseTemplateName);
CollectTemplateBodyChanges(incoming, existing, existing, templateNameById, changes);
return BuildItem("Template", incoming.Name, changes);
}
/// <summary>
/// ScadaBridge#118 — compares an incoming DERIVED (composition-slot) template against the
/// target's derived template at the same owner path. Derived-ness is part of the
/// identity: the item is keyed by the qualified name (<paramref name="qualifiedName"/>),
/// it is never matched against a base template that happens to share the slot name, and
/// a target slot that is not a derived template (a pre-derived composition straight onto
/// a base) reads as Modified. Only the target's OWN rows (overrides and additions,
/// <c>IsInherited == false</c>) are compared — the bundle carries no inherited
/// placeholders — together with the base the slot derives from (by qualified name).
/// </summary>
/// <param name="incoming">The incoming derived template (its <c>Derivation</c> is set).</param>
/// <param name="qualifiedName">The incoming template's qualified name (its preview identity).</param>
/// <param name="existing">The target's template at that owner path, or null if the slot is absent.</param>
/// <param name="existingBaseQualifiedName">The qualified name of <paramref name="existing"/>'s base, when resolvable.</param>
/// <param name="templateNameById">Template-id→name map for the composition targets.</param>
/// <returns>An import preview item describing the conflict type and differences.</returns>
public ImportPreviewItem CompareDerivedTemplate(
TemplateDto incoming,
string qualifiedName,
Template? existing,
string? existingBaseQualifiedName,
IReadOnlyDictionary<int, string>? templateNameById = null)
{
ArgumentNullException.ThrowIfNull(incoming);
ArgumentNullException.ThrowIfNull(qualifiedName);
if (existing is null)
{
return New("Template", qualifiedName);
}
var changes = new List<FieldChange>();
AddIfDifferent(changes, "IsDerived", existing.IsDerived, true);
AddIfDifferent(changes, "BaseTemplate", existingBaseQualifiedName, incoming.Derivation?.BaseQualifiedName());
AddIfDifferent(changes, "Description", existing.Description, incoming.Description);
var own = new Template(existing.Name)
{
Id = existing.Id,
Description = existing.Description,
Attributes = existing.Attributes.Where(a => !a.IsInherited).ToList(),
Alarms = existing.Alarms.Where(a => !a.IsInherited).ToList(),
Scripts = existing.Scripts.Where(x => !x.IsInherited).ToList(),
Compositions = existing.Compositions,
NativeAlarmSources = existing.NativeAlarmSources.Where(n => !n.IsInherited).ToList(),
};
// Alarm on-trigger links may point at an inherited script row, so resolve
// them over every script the target template holds.
CollectTemplateBodyChanges(incoming, own, existing, templateNameById, changes);
return BuildItem("Template", qualifiedName, changes);
}
private static void CollectTemplateBodyChanges(
TemplateDto incoming,
Template existing,
Template scriptSource,
IReadOnlyDictionary<int, string>? templateNameById,
List<FieldChange> changes)
{
// Bundles reference the alarm on-trigger script by name; resolve the
// persisted FK back to a name over this template's own scripts so the
// shared equality can compare it. See TemplateChildEquality.
var scriptNameById = TemplateChildEquality.ScriptNameResolver(existing.Scripts);
var scriptNameById = TemplateChildEquality.ScriptNameResolver(scriptSource.Scripts);
// Children: compare each child collection by name. We track which names
// were added, which were removed, and which existed on both sides but
@@ -127,8 +188,6 @@ public sealed class ArtifactDiff
TemplateChildEquality.NativeAlarmSourcesEqual,
"NativeAlarmSources",
changes);
return BuildItem("Template", incoming.Name, changes);
}
/// <summary>
@@ -0,0 +1,457 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
namespace ZB.MOM.WW.ScadaBridge.Transport.Import;
/// <summary>
/// ScadaBridge#118 — derived (composition-slot) templates on import.
/// <para>
/// A bundle marks a derived template with <see cref="TemplateDto.Derivation"/> (owner path +
/// base path) and carries only its own rows. The ordinary template passes never see it —
/// they would create it as a base template named after its slot, which is the bug #118
/// fixed. Instead, once the base templates are applied and flushed, each derived template
/// is rebuilt through the same <see cref="TemplateService"/> compose flow the Central UI and
/// the management API use (<see cref="TemplateService.AddCompositionAsync"/>, which creates
/// the slot-owned derived template, its inherited placeholders and the nested slots of the
/// base), its own rows are applied as overrides, and
/// <see cref="TemplateService.ResyncInheritedMembersAsync"/> reconciles the placeholders.
/// A slot that already exists on the target with the same base is synced in place; one with
/// a different base is removed through <see cref="TemplateService.DeleteCompositionAsync"/>
/// and recomposed. An owner or base that resolves in neither the bundle nor the target
/// fails that one record — audited as <c>BundleImportDerivedTemplateUnresolved</c> and
/// reported on <see cref="ImportResult.Warnings"/> — and no base template is created in its place.
/// </para>
/// </summary>
public sealed partial class BundleImporter
{
private readonly record struct TemplateRow(int Id, string Name, bool IsDerived);
private readonly record struct PathResult(int? Id, string? Failure);
/// <summary>
/// Resolves a (root template, slot path) name path to a template id. The root follows the
/// ScadaBridge#100 reference rule — exact name, base template first; a differently-cased
/// base (the row an Overwrite lands on, ScadaBridge#93) is the fallback; a name held only by
/// derived templates is not a valid root and is reported, never guessed. Each slot is then
/// matched on the current template's compositions (exact, then case-insensitive).
/// </summary>
private static async Task<PathResult> ResolveTemplatePathAsync(
string root,
IReadOnlyList<string> slots,
Func<string, Task<IReadOnlyList<TemplateRow>>> templatesNamed,
Func<int, Task<IReadOnlyList<TemplateComposition>>> compositionsOf)
{
var rows = (await templatesNamed(root).ConfigureAwait(false))
.Where(r => string.Equals(r.Name, root, StringComparison.OrdinalIgnoreCase))
.ToList();
var bases = rows.Where(r => !r.IsDerived).ToList();
var exact = bases.Where(r => string.Equals(r.Name, root, StringComparison.Ordinal)).ToList();
int currentId;
if (exact.Count > 0)
{
currentId = exact.Min(r => r.Id);
}
else if (bases.Count == 1)
{
currentId = bases[0].Id;
}
else if (bases.Count > 1)
{
return new PathResult(null, $"base template name '{root}' matches several target templates by case only; refusing to guess.");
}
else if (rows.Count > 0)
{
return new PathResult(null,
$"'{root}' is held only by derived (composition-slot) templates in the target and by no base template; "
+ "a derivation root must be a base template (ScadaBridge#100).");
}
else
{
return new PathResult(null, $"base template '{root}' is present in neither the bundle nor the target.");
}
var path = root;
foreach (var slot in slots)
{
var comps = await compositionsOf(currentId).ConfigureAwait(false);
var match = comps.FirstOrDefault(c => string.Equals(c.InstanceName, slot, StringComparison.Ordinal));
if (match is null)
{
var ci = comps.Where(c => string.Equals(c.InstanceName, slot, StringComparison.OrdinalIgnoreCase)).ToList();
match = ci.Count == 1 ? ci[0] : null;
}
if (match is null)
{
return new PathResult(null, $"slot '{slot}' was not found under '{path}'.");
}
currentId = match.ComposedTemplateId;
path += "." + slot;
}
return new PathResult(currentId, null);
}
/// <summary>The persisted name of a bundled base template, honouring a Rename resolution.</summary>
private static string ImportedRootName(Dictionary<(string, string), ImportResolution> map, string root)
{
var resolution = ResolveOrDefault(map, "Template", root);
return resolution.Action == ResolutionAction.Rename && !string.IsNullOrEmpty(resolution.RenameTo)
? resolution.RenameTo
: root;
}
/// <summary>
/// The top-level derived slots the bundle carries, per bundled owner name: the composition
/// rows the ordinary composition pass must neither create nor delete, because
/// <see cref="ApplyDerivedTemplatesAsync"/> owns them.
/// </summary>
private static Dictionary<string, HashSet<string>> DerivedSlotsByOwner(DerivedTemplatePlan plan)
{
var result = new Dictionary<string, HashSet<string>>(StringComparer.Ordinal);
foreach (var dto in plan.Ordered)
{
var d = dto.Derivation!;
if (d.SlotPath.Count != 1) continue;
if (!result.TryGetValue(d.OwnerTemplateName, out var slots))
{
slots = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
result[d.OwnerTemplateName] = slots;
}
slots.Add(d.SlotPath[0]);
}
return result;
}
// ---- Preview ----
/// <summary>
/// One preview row per bundled derived template, keyed by its qualified name and compared
/// against the target's template at the same owner path (never against a base template
/// that shares the slot name).
/// </summary>
private async Task<IReadOnlyList<ImportPreviewItem>> PreviewDerivedTemplatesAsync(
IReadOnlyList<TemplateDto> derived,
TemplateLibrary library,
IReadOnlyDictionary<int, string> templateNameById,
CancellationToken ct)
{
if (derived.Count == 0) return Array.Empty<ImportPreviewItem>();
var analysis = await library.GetAnalysisAsync(ct).ConfigureAwait(false);
var byId = analysis.ToDictionary(t => t.Id);
var compositionById = analysis.SelectMany(t => t.Compositions)
.GroupBy(c => c.Id).ToDictionary(g => g.Key, g => g.First());
var rows = analysis.Select(t => new TemplateRow(t.Id, t.Name, t.IsDerived)).ToList();
Task<IReadOnlyList<TemplateRow>> Named(string _) => Task.FromResult<IReadOnlyList<TemplateRow>>(rows);
Task<IReadOnlyList<TemplateComposition>> CompositionsOf(int id) =>
Task.FromResult<IReadOnlyList<TemplateComposition>>(
byId.TryGetValue(id, out var t) ? t.Compositions.ToList() : new List<TemplateComposition>());
var existingIdByKey = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var dto in derived)
{
var d = dto.Derivation!;
if (d.SlotPath is not { Count: > 0 }) continue;
var found = await ResolveTemplatePathAsync(d.OwnerTemplateName, d.SlotPath, Named, CompositionsOf).ConfigureAwait(false);
if (found.Id is { } id) existingIdByKey[d.QualifiedName()] = id;
}
var hydrated = existingIdByKey.Count == 0
? new Dictionary<int, Template>()
: (await _templateRepo.GetTemplatesWithChildrenAsync(
existingIdByKey.Values.Where(byId.ContainsKey).Select(id => byId[id].Name), ct).ConfigureAwait(false))
.GroupBy(t => t.Id).ToDictionary(g => g.Key, g => g.First());
var items = new List<ImportPreviewItem>(derived.Count);
foreach (var dto in derived)
{
var key = DerivedTemplatePlan.Key(dto);
Template? existing = null;
string? existingBase = null;
if (existingIdByKey.TryGetValue(key, out var id) && hydrated.TryGetValue(id, out var full))
{
existing = full;
existingBase = byId.TryGetValue(id, out var row)
? EntitySerializer.TryBuildDerivation(row, byId, compositionById)?.BaseQualifiedName()
: null;
}
items.Add(_diff.CompareDerivedTemplate(dto, key, existing, existingBase, templateNameById));
}
return items;
}
// ---- Apply ----
/// <summary>
/// Rebuilds every bundled derived template through the compose flow, in
/// <see cref="DerivedTemplatePlan"/> order. Runs after the base templates are flushed and
/// the merged graph passed the acyclicity guard; <see cref="TemplateService"/> saves as it
/// goes, inside the import's transaction.
/// </summary>
/// <returns>The ids of the derived templates written (for the stale-instance probe) and
/// one warning per derived template that could not be imported.</returns>
private async Task<(IReadOnlyList<int> Touched, IReadOnlyList<string> Warnings)> ApplyDerivedTemplatesAsync(
DerivedTemplatePlan plan,
Dictionary<(string, string), ImportResolution> map,
string user,
ImportSummary summary,
CancellationToken ct)
{
var touched = new List<int>();
var warnings = new List<string>();
if (plan.Ordered.Count == 0) return (touched, warnings);
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
var service = new TemplateService(_templateRepo, _auditService);
async Task<IReadOnlyList<TemplateRow>> Named(string name)
{
var lower = name.ToLower();
return await _dbContext.Templates.AsNoTracking()
.Where(t => t.Name.ToLower() == lower)
.Select(t => new TemplateRow(t.Id, t.Name, t.IsDerived))
.ToListAsync(ct).ConfigureAwait(false);
}
async Task<IReadOnlyList<TemplateComposition>> CompositionsOf(int id) =>
await _dbContext.TemplateCompositions.AsNoTracking()
.Where(c => c.TemplateId == id)
.ToListAsync(ct).ConfigureAwait(false);
foreach (var dto in plan.Ordered)
{
var d = dto.Derivation!;
var key = d.QualifiedName();
if (ResolveOrDefault(map, "Template", key).Action == ResolutionAction.Skip)
{
summary.Skipped++;
continue;
}
var ownerSlots = d.SlotPath.Take(d.SlotPath.Count - 1).ToList();
var ownerPath = TemplateDerivationDto.Qualify(d.OwnerTemplateName, ownerSlots);
var owner = await ResolveTemplatePathAsync(
ImportedRootName(map, d.OwnerTemplateName), ownerSlots, Named, CompositionsOf).ConfigureAwait(false);
if (owner.Id is not { } ownerId)
{
await FailDerivedAsync(dto, key, $"its owner '{ownerPath}' could not be resolved: {owner.Failure}").ConfigureAwait(false);
continue;
}
var baseResult = await ResolveTemplatePathAsync(
ImportedRootName(map, d.BaseTemplateName), d.BaseSlotPath, Named, CompositionsOf).ConfigureAwait(false);
if (baseResult.Id is not { } baseId)
{
await FailDerivedAsync(dto, key,
$"its base '{d.BaseQualifiedName()}' could not be resolved: {baseResult.Failure} "
+ "The slot was not created, and no base template was created in its place.").ConfigureAwait(false);
continue;
}
var slot = d.SlotPath[^1];
var ownerComps = await CompositionsOf(ownerId).ConfigureAwait(false);
var existingComp = ownerComps.FirstOrDefault(c => string.Equals(c.InstanceName, slot, StringComparison.Ordinal))
?? ownerComps.FirstOrDefault(c => string.Equals(c.InstanceName, slot, StringComparison.OrdinalIgnoreCase));
int? derivedId = null;
if (existingComp is not null)
{
var current = await _dbContext.Templates.AsNoTracking()
.Where(t => t.Id == existingComp.ComposedTemplateId)
.Select(t => new { t.Id, t.IsDerived, t.OwnerCompositionId, t.ParentTemplateId })
.FirstOrDefaultAsync(ct).ConfigureAwait(false);
if (current is not null && current.IsDerived
&& current.OwnerCompositionId == existingComp.Id && current.ParentTemplateId == baseId)
{
derivedId = current.Id;
}
else
{
// The slot exists but is not this base's derived template (another base, or a
// pre-derived composition straight onto a base): rebuild it.
var deleted = await service.DeleteCompositionAsync(existingComp.Id, user, ct).ConfigureAwait(false);
if (!deleted.IsSuccess)
{
await FailDerivedAsync(dto, key, $"the existing slot could not be replaced: {deleted.Error}").ConfigureAwait(false);
continue;
}
}
}
var composed = false;
if (derivedId is null)
{
var added = await service.AddCompositionAsync(ownerId, baseId, slot, user, ct).ConfigureAwait(false);
if (!added.IsSuccess)
{
await FailDerivedAsync(dto, key, $"the compose flow rejected it: {added.Error}").ConfigureAwait(false);
continue;
}
derivedId = added.Value.ComposedTemplateId;
composed = true;
}
await SyncDerivedOwnMembersAsync(derivedId.Value, dto, key, user, ct).ConfigureAwait(false);
var resync = await service.ResyncInheritedMembersAsync(derivedId.Value, user, ct).ConfigureAwait(false);
if (!resync.IsSuccess)
{
warnings.Add($"Derived template '{key}': inherited members were not resynced: {resync.Error}");
}
touched.Add(derivedId.Value);
if (composed) summary.Added++;
else summary.Overwritten++;
}
return (touched, warnings);
async Task FailDerivedAsync(TemplateDto dto, string key, string reason)
{
summary.Skipped++;
warnings.Add($"Derived template '{key}' was not imported: {reason}");
await _auditService.LogAsync(
user,
"BundleImportDerivedTemplateUnresolved",
"Template",
"0",
key,
new
{
DerivedTemplateName = key,
OwnerTemplateName = dto.Derivation!.OwnerTemplateName,
dto.Derivation.SlotPath,
BaseTemplateName = dto.Derivation.BaseQualifiedName(),
Reason = reason,
},
ct).ConfigureAwait(false);
}
}
/// <summary>
/// Applies a derived template's own bundled rows onto the composed (or existing) derived
/// template: each bundled row becomes an override (or an addition) with
/// <c>IsInherited = false</c>; an own row on the target that the bundle no longer carries
/// reverts to an inherited placeholder, which the following resync re-syncs to the base —
/// or removes, when the base has no such member.
/// </summary>
private async Task SyncDerivedOwnMembersAsync(int derivedId, TemplateDto dto, string key, string user, CancellationToken ct)
{
var derived = await _templateRepo.GetTemplateByIdAsync(derivedId, ct).ConfigureAwait(false)
?? throw new InvalidOperationException($"Derived template {derivedId} vanished during import.");
derived.Description = dto.Description;
var attrs = ToNameLookup(derived.Attributes, a => a.Name);
foreach (var a in dto.Attributes)
{
if (!attrs.TryGetValue(a.Name, out var row))
{
row = new TemplateAttribute(a.Name) { TemplateId = derived.Id };
derived.Attributes.Add(row);
}
row.Value = ImportValueNormalizer.NormalizeListValue(a.Value, a.DataType, a.ElementDataType);
row.DataType = a.DataType;
row.ElementDataType = a.ElementDataType;
row.IsLocked = a.IsLocked;
row.Description = a.Description;
row.DataSourceReference = a.DataSourceReference;
row.LockedInDerived = a.LockedInDerived;
row.IsInherited = false;
}
RevertStale(derived.Attributes, dto.Attributes.Select(x => x.Name), x => x.Name, x => x.IsInherited = true, x => x.IsInherited);
var scripts = ToNameLookup(derived.Scripts, s => s.Name);
foreach (var s in dto.Scripts)
{
if (!scripts.TryGetValue(s.Name, out var row))
{
row = new TemplateScript(s.Name, s.Code) { TemplateId = derived.Id };
derived.Scripts.Add(row);
}
row.Code = s.Code;
row.TriggerType = s.TriggerType;
row.TriggerConfiguration = s.TriggerConfiguration;
row.ParameterDefinitions = s.ParameterDefinitions;
row.ReturnDefinition = s.ReturnDefinition;
row.IsLocked = s.IsLocked;
row.MinTimeBetweenRuns = s.MinTimeBetweenRuns;
row.ExecutionTimeoutSeconds = s.ExecutionTimeoutSeconds;
row.LockedInDerived = s.LockedInDerived;
row.IsInherited = false;
}
RevertStale(derived.Scripts, dto.Scripts.Select(x => x.Name), x => x.Name, x => x.IsInherited = true, x => x.IsInherited);
var alarms = ToNameLookup(derived.Alarms, a => a.Name);
var ownAlarms = new List<(TemplateAlarm Row, string? Script)>();
foreach (var a in dto.Alarms)
{
if (!alarms.TryGetValue(a.Name, out var row))
{
row = new TemplateAlarm(a.Name) { TemplateId = derived.Id };
derived.Alarms.Add(row);
}
row.Description = a.Description;
row.PriorityLevel = a.PriorityLevel;
row.TriggerType = a.TriggerType;
row.TriggerConfiguration = a.TriggerConfiguration;
row.IsLocked = a.IsLocked;
row.LockedInDerived = a.LockedInDerived;
row.IsMesReportable = a.IsMesReportable;
row.IsInherited = false;
ownAlarms.Add((row, a.OnTriggerScriptName));
}
RevertStale(derived.Alarms, dto.Alarms.Select(x => x.Name), x => x.Name, x => x.IsInherited = true, x => x.IsInherited);
var sources = ToNameLookup(derived.NativeAlarmSources, n => n.Name);
foreach (var n in dto.NativeAlarmSources)
{
if (!sources.TryGetValue(n.Name, out var row))
{
row = new TemplateNativeAlarmSource(n.Name) { TemplateId = derived.Id };
derived.NativeAlarmSources.Add(row);
}
row.Description = n.Description;
row.ConnectionName = n.ConnectionName;
row.SourceReference = n.SourceReference;
row.ConditionFilter = n.ConditionFilter;
row.IsMesReportable = n.IsMesReportable;
row.IsLocked = n.IsLocked;
row.LockedInDerived = n.LockedInDerived;
row.IsInherited = false;
}
RevertStale(derived.NativeAlarmSources, dto.NativeAlarmSources.Select(x => x.Name), x => x.Name, x => x.IsInherited = true, x => x.IsInherited);
// New script rows need ids before an alarm can point at them.
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
var scriptIdByName = ToNameLookup(derived.Scripts, s => s.Name);
foreach (var (row, script) in ownAlarms)
{
row.OnTriggerScriptId = script is not null && scriptIdByName.TryGetValue(script, out var s) ? s.Id : null;
}
await _auditService.LogAsync(user, "Update", "Template", derived.Id.ToString(), key,
new
{
QualifiedName = key,
BaseTemplateName = dto.Derivation!.BaseQualifiedName(),
OwnAttributes = dto.Attributes.Count,
OwnAlarms = dto.Alarms.Count,
OwnScripts = dto.Scripts.Count,
OwnNativeAlarmSources = dto.NativeAlarmSources.Count,
},
ct).ConfigureAwait(false);
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
static void RevertStale<T>(
IEnumerable<T> rows, IEnumerable<string> bundled, Func<T, string> name, Action<T> revert, Func<T, bool> isInherited)
{
var keep = new HashSet<string>(bundled, StringComparer.OrdinalIgnoreCase);
foreach (var row in rows.Where(r => !isInherited(r) && !keep.Contains(name(r))).ToList())
{
revert(row);
}
}
}
}
@@ -56,7 +56,7 @@ namespace ZB.MOM.WW.ScadaBridge.Transport.Import;
/// avoid double-logging.
/// </para>
/// </summary>
public sealed class BundleImporter : IBundleImporter
public sealed partial class BundleImporter : IBundleImporter
{
// All bundle content deserialization goes through BundleJsonOptions.Default.
// IMPORTANT — unknown JSON properties must remain ALLOWED (the default
@@ -513,7 +513,10 @@ public sealed class BundleImporter : IBundleImporter
// and called GetTemplateWithChildrenAsync(stub.Id) once per matching
// name (classic N+1). The bulk variant fetches every matching template
// with children eager-loaded in a single round-trip.
var bundleTemplateNames = content.Templates.Select(t => t.Name);
// ScadaBridge#118: a bundled DERIVED template is compared against the target's
// template at its owner path, under its qualified name, never by its bare slot name.
var baseTemplateDtos = content.Templates.Where(t => !DerivedTemplatePlan.IsDerived(t)).ToList();
var bundleTemplateNames = baseTemplateDtos.Select(t => t.Name);
// Base templates only: a derived template carries its composition slot name and is
// never what a bundled template of that name targets (ScadaBridge#95).
var hydratedTemplates = await _templateRepo
@@ -521,11 +524,13 @@ public sealed class BundleImporter : IBundleImporter
.ConfigureAwait(false);
var hydratedByName = ToTemplateLookup(hydratedTemplates);
var templateNameById = await library.GetNameByIdAsync(ct).ConfigureAwait(false);
foreach (var tDto in content.Templates)
foreach (var tDto in baseTemplateDtos)
{
hydratedByName.TryGetValue(tDto.Name, out var existing);
items.Add(_diff.CompareTemplate(tDto, existing, folderNameById, templateNameById));
}
items.AddRange(await PreviewDerivedTemplatesAsync(
content.Templates.Where(DerivedTemplatePlan.IsDerived).ToList(), library, templateNameById, ct).ConfigureAwait(false));
// ---- SharedScripts ----
// One read of each library (04/P4); DetectBlockersAsync reuses the same lists
@@ -867,7 +872,20 @@ public sealed class BundleImporter : IBundleImporter
// ∪ (templates already in the target DB). An instance whose TemplateName is
// in neither is unresolvable.
var bundleTemplateNames = new HashSet<string>(StringComparer.Ordinal);
foreach (var t in content.Templates) bundleTemplateNames.Add(t.Name);
// A derived slot template is not something an instance is created from (#118).
foreach (var t in content.Templates.Where(t => !DerivedTemplatePlan.IsDerived(t))) bundleTemplateNames.Add(t.Name);
// ScadaBridge#118: a malformed or cyclic derivation fails the apply outright.
foreach (var error in DerivedTemplatePlan.Build(content.Templates).Errors)
{
blockers.Add(new ImportPreviewItem(
EntityType: "Template",
Name: "(derived templates)",
ExistingVersion: null,
IncomingVersion: null,
Kind: ConflictKind.Blocker,
FieldDiffJson: null,
BlockerReason: error));
}
// Honour a rename: a bundled template resolved as Rename is created under
// its new name, but at preview time no resolution has been chosen yet, so
// the in-bundle name is the original DTO name — which is what an instance
@@ -1136,7 +1154,7 @@ public sealed class BundleImporter : IBundleImporter
foreach (var t in content.Templates)
{
if (IsSkipResolution(resolutionMap, "Template", t.Name)) continue;
if (IsSkipResolution(resolutionMap, "Template", DerivedTemplatePlan.Key(t))) continue;
foreach (var s in t.Scripts) CheckMember(t, "Script", s.Name);
foreach (var a in t.Alarms) CheckMember(t, "Alarm", a.Name);
foreach (var c in t.Compositions) CheckMember(t, "Composition slot", c.InstanceName);
@@ -1311,12 +1329,12 @@ public sealed class BundleImporter : IBundleImporter
{
foreach (var t in content.Templates)
{
if (IsSkipResolution(resolutionMap, "Template", t.Name)) continue;
if (IsSkipResolution(resolutionMap, "Template", DerivedTemplatePlan.Key(t))) continue;
foreach (var s in t.Scripts)
{
if (!string.IsNullOrEmpty(s.Code))
{
yield return ("Template", t.Name, s.Name, s.Code);
yield return ("Template", DerivedTemplatePlan.Key(t), s.Name, s.Code);
}
var scriptExpr = ExtractTriggerExpression(s.TriggerType, s.TriggerConfiguration);
if (!string.IsNullOrEmpty(scriptExpr))
@@ -1465,7 +1483,7 @@ public sealed class BundleImporter : IBundleImporter
var warnings = new List<(string, string)>();
var bundleTemplates = new Dictionary<string, TemplateDto>(StringComparer.Ordinal);
foreach (var t in content.Templates) bundleTemplates[t.Name] = t;
foreach (var t in content.Templates.Where(t => !DerivedTemplatePlan.IsDerived(t))) bundleTemplates[t.Name] = t;
var targetByName = new Dictionary<string, Template>(StringComparer.Ordinal);
foreach (var t in targetTemplates) targetByName[t.Name] = t;
@@ -1848,9 +1866,13 @@ public sealed class BundleImporter : IBundleImporter
// trusts blindly — site gRPC endpoints and names that become actor names —
// are rejected here, with the change tracker still empty, rather than
// surfacing as a channel-build throw on central or an ActorOf throw at deploy.
// ScadaBridge#118: derived templates apply in owner/base order; a malformed or
// cyclic derivation is a hard error raised here, with nothing staged.
var derivedPlan = DerivedTemplatePlan.Build(content.Templates);
var ruleErrors = CollectActorNameViolations(content, resolutionMap)
.Select(v => v.Message)
.Concat(await CollectSiteEndpointErrorsAsync(content, resolutionMap, nameMap, ct).ConfigureAwait(false))
.Concat(derivedPlan.Errors)
.ToList();
if (ruleErrors.Count > 0 || validationErrors.Count > 0)
{
@@ -1897,7 +1919,11 @@ public sealed class BundleImporter : IBundleImporter
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
await ApplyTemplateFoldersAsync(content.TemplateFolders, resolutionMap, user, summary, ct).ConfigureAwait(false);
await ApplyTemplatesAsync(content.Templates, resolutionMap, user, summary, ct).ConfigureAwait(false);
// ScadaBridge#118: the ordinary template passes see base templates only; a derived
// (composition-slot) template is rebuilt by ApplyDerivedTemplatesAsync below. A pre-#118
// bundle carries no derivations, so this is every template, in bundle order.
var baseTemplateDtos = content.Templates.Where(t => !DerivedTemplatePlan.IsDerived(t)).ToList();
await ApplyTemplatesAsync(baseTemplateDtos, resolutionMap, user, summary, ct).ConfigureAwait(false);
await ApplySharedScriptsAsync(content.SharedScripts, resolutionMap, user, summary, ct).ConfigureAwait(false);
await ApplyExternalSystemsAsync(content.ExternalSystems, resolutionMap, user, summary, ct).ConfigureAwait(false);
await ApplyDatabaseConnectionsAsync(content.DatabaseConnections, resolutionMap, user, summary, ct).ConfigureAwait(false);
@@ -1924,9 +1950,10 @@ public sealed class BundleImporter : IBundleImporter
// remaining failure surface is the final BundleImported audit
// write itself.
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
await ResolveAlarmScriptLinksAsync(content.Templates, resolutionMap, user, ct).ConfigureAwait(false);
await ResolveCompositionEdgesAsync(content.Templates, resolutionMap, library, user, ct).ConfigureAwait(false);
await ResolveInheritanceEdgesAsync(content.Templates, resolutionMap, library, user, ct).ConfigureAwait(false);
await ResolveAlarmScriptLinksAsync(baseTemplateDtos, resolutionMap, user, ct).ConfigureAwait(false);
await ResolveCompositionEdgesAsync(
baseTemplateDtos, resolutionMap, library, DerivedSlotsByOwner(derivedPlan), user, ct).ConfigureAwait(false);
await ResolveInheritanceEdgesAsync(baseTemplateDtos, resolutionMap, library, user, ct).ConfigureAwait(false);
// Folder parent edges are name-keyed too — resolve them AFTER the flush
// above so every imported folder has a materialised id to point at
// (the Add pass created the folders parent-less). Mirrors the template
@@ -1951,6 +1978,12 @@ public sealed class BundleImporter : IBundleImporter
// SaveChanges precedes this check so nothing survives that rollback.
EnsureNoTemplateGraphCycles(await library.GetAnalysisAsync(ct).ConfigureAwait(false));
// ---- Derived (composition-slot) templates, through the compose flow (#118) ----
// After the guard, so nothing it saves precedes the cycle check; the compose flow
// runs its own composition / cross-graph cycle and collision checks per slot.
var (derivedTouched, derivedWarnings) = await ApplyDerivedTemplatesAsync(
derivedPlan, resolutionMap, user, summary, ct).ConfigureAwait(false);
// ---- Site/instance-scoped apply: instances LAST ----
// The instance pass needs every FK target materialised first:
// • template id — resolved by name against the just-flushed central
@@ -1976,7 +2009,7 @@ public sealed class BundleImporter : IBundleImporter
// is the next statement, so a read against the change tracker here sees
// the full post-apply graph before commit.
var staleInstanceIds = await ComputeStaleInstanceIdsAsync(
content, resolutionMap, library, ct).ConfigureAwait(false);
content, resolutionMap, library, derivedTouched, ct).ConfigureAwait(false);
await _auditService.LogAsync(
user: user,
@@ -2017,7 +2050,7 @@ public sealed class BundleImporter : IBundleImporter
StaleInstanceIds: staleInstanceIds,
AuditEventCorrelation: bundleImportId.ToString(),
ApiKeysIgnored: apiKeysIgnored,
Warnings: validationWarnings);
Warnings: derivedWarnings.Count == 0 ? validationWarnings : validationWarnings.Concat(derivedWarnings).ToList());
}
/// <summary>
@@ -2151,6 +2184,7 @@ public sealed class BundleImporter : IBundleImporter
BundleContentDto content,
Dictionary<(string EntityType, string Name), ImportResolution> resolutionMap,
TemplateLibrary library,
IReadOnlyList<int> derivedTemplateIds,
CancellationToken ct)
{
// No flattening pipeline registered → can't recompute hashes. Staleness is
@@ -2167,8 +2201,10 @@ public sealed class BundleImporter : IBundleImporter
// to their (unchanged) target ids.
var targetTemplateIdByName = await library.GetIdByNameAsync(ct).ConfigureAwait(false);
var overwrittenTemplateIds = new HashSet<int>();
foreach (var dto in content.Templates)
// ScadaBridge#118: every derived slot template the import (re)built changes what its
// owners flatten to, so it seeds the expansion like an overwritten template.
var overwrittenTemplateIds = new HashSet<int>(derivedTemplateIds);
foreach (var dto in content.Templates.Where(t => !DerivedTemplatePlan.IsDerived(t)))
{
var resolution = ResolveOrDefault(resolutionMap, "Template", dto.Name);
if (resolution.Action != ResolutionAction.Overwrite) continue;
@@ -3294,6 +3330,7 @@ public sealed class BundleImporter : IBundleImporter
IReadOnlyList<TemplateDto> dtos,
Dictionary<(string, string), ImportResolution> resolutionMap,
TemplateLibrary library,
IReadOnlyDictionary<string, HashSet<string>> derivedSlotsByOwner,
string user,
CancellationToken ct)
{
@@ -3326,22 +3363,38 @@ public sealed class BundleImporter : IBundleImporter
{
if (!loadedById.TryGetValue(parentStub.Id, out var parent)) continue;
// ScadaBridge#118: the slots this owner's bundled DERIVED templates fill are
// composed (or kept and synced) by ApplyDerivedTemplatesAsync through the compose
// flow, so this pass neither deletes nor writes those rows. Empty for a pre-#118
// bundle, which keeps the clear-and-rewrite below exactly as it was.
derivedSlotsByOwner.TryGetValue(dto.Name, out var derivedSlots);
// Clear existing rows (Overwrite-truly-overwrites). EF Core tracks
// the deletes via the navigation; explicit DeleteAsync ensures the
// change is staged even when the relational provider doesn't
// detect orphans automatically.
if (parent.Compositions.Count > 0)
{
var existingComps = parent.Compositions.ToList();
var existingComps = parent.Compositions
.Where(c => derivedSlots is null || !derivedSlots.Contains(c.InstanceName))
.ToList();
foreach (var existing in existingComps)
{
await _templateRepo.DeleteTemplateCompositionAsync(existing.Id, ct).ConfigureAwait(false);
}
parent.Compositions.Clear();
if (derivedSlots is null)
{
parent.Compositions.Clear();
}
else
{
foreach (var existing in existingComps) parent.Compositions.Remove(existing);
}
}
foreach (var compDto in dto.Compositions)
{
if (derivedSlots is not null && derivedSlots.Contains(compDto.InstanceName)) continue;
if (string.IsNullOrEmpty(compDto.ComposedTemplateName))
{
await _auditService.LogAsync(
@@ -5337,7 +5390,7 @@ public sealed class BundleImporter : IBundleImporter
// ---- Instance template references ----
var bundleTemplateNames = new HashSet<string>(StringComparer.Ordinal);
foreach (var t in content.Templates)
foreach (var t in content.Templates.Where(t => !DerivedTemplatePlan.IsDerived(t)))
{
var resolution = ResolveOrDefault(resolutionMap, "Template", t.Name);
if (resolution.Action == ResolutionAction.Skip) continue;
@@ -5502,7 +5555,7 @@ public sealed class BundleImporter : IBundleImporter
{
// Skip-resolved templates aren't being written, so their script
// references don't need to resolve.
var resolution = ResolveOrDefault(resolutionMap, "Template", t.Name);
var resolution = ResolveOrDefault(resolutionMap, "Template", DerivedTemplatePlan.Key(t));
if (resolution.Action == ResolutionAction.Skip) continue;
foreach (var s in t.Scripts)
{
@@ -5600,7 +5653,12 @@ public sealed class BundleImporter : IBundleImporter
});
}
foreach (var dto in content.Templates)
// ScadaBridge#118: a derived template carries only its own rows (its inherited members
// come from the base at compose time), so a one-template flatten of the DTO would
// report every reference to an inherited member as missing. The compose flow and the
// deploy-time flatten validate it against the full graph; the trust gate above
// still covers its script bodies.
foreach (var dto in content.Templates.Where(t => !DerivedTemplatePlan.IsDerived(t)))
{
var resolution = ResolveOrDefault(resolutionMap, "Template", dto.Name);
if (resolution.Action == ResolutionAction.Skip) continue;
@@ -0,0 +1,127 @@
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
namespace ZB.MOM.WW.ScadaBridge.Transport.Import;
/// <summary>
/// ScadaBridge#118 — the apply order of a bundle's derived (composition-slot) templates.
/// <para>
/// A derived template is rebuilt by composing its base under its owner, so it can only be
/// applied once both exist on the target. Base templates are applied by the ordinary
/// template pass first; between derived templates the dependencies are the owner (a nested
/// slot's owner is itself a derived template) and the base (a cascade-created nested slot
/// derives from the source-side derived template). <see cref="Build"/> orders the derived
/// templates topologically over those edges — the bundle's own order is irrelevant, so a
/// hand-edited bundle that lists a derived template before its base still applies — and
/// reports a cycle or a malformed derivation as an error, which the importer raises before
/// anything is written.
/// </para>
/// </summary>
internal sealed class DerivedTemplatePlan
{
private DerivedTemplatePlan(IReadOnlyList<TemplateDto> ordered, IReadOnlyList<string> errors)
{
Ordered = ordered;
Errors = errors;
}
/// <summary>The derived templates in apply order (owners and bases first).</summary>
public IReadOnlyList<TemplateDto> Ordered { get; }
/// <summary>Malformed derivations, duplicate identities and cycles — each a hard import error.</summary>
public IReadOnlyList<string> Errors { get; }
/// <summary>
/// The identity a bundled template is previewed and resolved under: its name for a base
/// template, its qualified owner path (<c>Owner.Slot…</c>) for a derived one — a derived
/// template's own name is only its slot name and is shared by every slot of that name.
/// </summary>
/// <param name="dto">The bundled template.</param>
/// <returns>The preview / resolution key.</returns>
public static string Key(TemplateDto dto) => dto.Derivation?.QualifiedName() ?? dto.Name;
/// <summary>Whether <paramref name="dto"/> is a derived (composition-slot) template.</summary>
/// <param name="dto">The bundled template.</param>
/// <returns><c>true</c> when it carries a derivation.</returns>
public static bool IsDerived(TemplateDto dto) => dto.Derivation is not null;
/// <summary>Builds the plan over a bundle's templates (base templates are ignored).</summary>
/// <param name="templates">Every bundled template.</param>
/// <returns>The ordered derived templates plus any errors.</returns>
public static DerivedTemplatePlan Build(IEnumerable<TemplateDto> templates)
{
var errors = new List<string>();
var byKey = new Dictionary<string, TemplateDto>(StringComparer.Ordinal);
foreach (var dto in templates.Where(IsDerived))
{
var d = dto.Derivation!;
if (string.IsNullOrEmpty(d.OwnerTemplateName)
|| d.SlotPath is null || d.SlotPath.Count == 0 || d.SlotPath.Any(string.IsNullOrEmpty)
|| string.IsNullOrEmpty(d.BaseTemplateName)
|| d.BaseSlotPath is null || d.BaseSlotPath.Any(string.IsNullOrEmpty))
{
errors.Add($"Derived template '{dto.Name}' carries a malformed derivation (owner, slot path and base are all required).");
continue;
}
var key = d.QualifiedName();
if (!byKey.TryAdd(key, dto))
{
errors.Add($"Derived template '{key}' appears more than once in the bundle.");
}
}
// Edges: dependency → dependant, for dependencies that are themselves bundled
// derived templates (an owner or base outside this set is resolved on apply).
var inDegree = byKey.Keys.ToDictionary(k => k, _ => 0, StringComparer.Ordinal);
var dependants = byKey.Keys.ToDictionary(k => k, _ => new List<string>(), StringComparer.Ordinal);
foreach (var (key, dto) in byKey)
{
var d = dto.Derivation!;
var deps = new HashSet<string>(StringComparer.Ordinal);
if (d.SlotPath.Count > 1)
deps.Add(TemplateDerivationDto.Qualify(d.OwnerTemplateName, d.SlotPath.Take(d.SlotPath.Count - 1).ToList()));
if (d.BaseSlotPath.Count > 0)
deps.Add(d.BaseQualifiedName());
foreach (var dep in deps)
{
if (dep == key || !byKey.ContainsKey(dep))
{
if (dep == key)
{
// Self-dependency is a one-node cycle.
inDegree[key]++;
}
continue;
}
dependants[dep].Add(key);
inDegree[key]++;
}
}
var queue = new Queue<string>(inDegree.Where(kv => kv.Value == 0).Select(kv => kv.Key)
.OrderBy(k => k, StringComparer.Ordinal));
var ordered = new List<TemplateDto>(byKey.Count);
while (queue.Count > 0)
{
var key = queue.Dequeue();
ordered.Add(byKey[key]);
foreach (var next in dependants[key].OrderBy(k => k, StringComparer.Ordinal))
{
if (--inDegree[next] == 0) queue.Enqueue(next);
}
}
if (ordered.Count != byKey.Count)
{
var cyclic = inDegree.Where(kv => kv.Value > 0).Select(kv => kv.Key)
.OrderBy(k => k, StringComparer.Ordinal);
errors.Add(
"Derived templates cannot be ordered — their owner/base references form a cycle: "
+ string.Join(", ", cyclic) + ".");
}
return new DerivedTemplatePlan(ordered, errors);
}
}
@@ -176,6 +176,57 @@ public sealed record TemplateDto(
/// </summary>
public IReadOnlyList<TemplateNativeAlarmSourceDto> NativeAlarmSources { get; init; } =
Array.Empty<TemplateNativeAlarmSourceDto>();
// ScadaBridge#118: set only on a DERIVED (composition-slot) template. Init-only with a
// null default for the same forward/source-compat reasons as NativeAlarmSources, and
// omitted from the JSON when null (BundleJsonOptions WhenWritingNull), so a base
// template's wire shape is byte-identical to a pre-#118 bundle. A bundle written before
// #118 has no Derivation anywhere and imports exactly as it always did. When set, the
// member lists carry only the derived template's OWN rows (overrides + additions,
// IsInherited == false); the inherited placeholders are rebuilt on import by the normal
// compose flow from the base.
/// <summary>
/// The derived (composition-slot) template's place in the composition graph, or
/// <c>null</c> for a base template (and for every template of a pre-#118 bundle).
/// </summary>
public TemplateDerivationDto? Derivation { get; init; }
}
/// <summary>
/// ScadaBridge#118 — where a derived (composition-slot) template sits, by stable name
/// paths instead of ids. A derived template's own <see cref="TemplateDto.Name"/> is its
/// slot name, unique only within its owner, so it is identified by its owner path: the
/// base template at the root of the owner chain plus the slot names from it down to this
/// template (<see cref="SlotPath"/>, whose last element is this template's slot). The base
/// it derives from is addressed the same way: a base template name plus an empty
/// <see cref="BaseSlotPath"/>, or — for the nested slots the compose cascade creates — the
/// source-side derived template under that base.
/// </summary>
/// <param name="OwnerTemplateName">The base template at the root of the owner chain.</param>
/// <param name="SlotPath">Slot instance names from <paramref name="OwnerTemplateName"/> down to this template.</param>
/// <param name="BaseTemplateName">The base template at the root of the inherited-from path.</param>
/// <param name="BaseSlotPath">Slot names under <paramref name="BaseTemplateName"/>; empty when the base is a base template.</param>
public sealed record TemplateDerivationDto(
string OwnerTemplateName,
IReadOnlyList<string> SlotPath,
string BaseTemplateName,
IReadOnlyList<string> BaseSlotPath)
{
/// <summary>The dotted qualified name of the derived template (<c>Owner.Slot…</c>) —
/// its identity in the import preview and resolutions.</summary>
/// <returns>The qualified name.</returns>
public string QualifiedName() => Qualify(OwnerTemplateName, SlotPath);
/// <summary>The dotted qualified name of the base the template derives from.</summary>
/// <returns>The qualified base name.</returns>
public string BaseQualifiedName() => Qualify(BaseTemplateName, BaseSlotPath);
/// <summary>Joins a root template name and a slot path with dots.</summary>
/// <param name="root">The root template name.</param>
/// <param name="slots">The slot path below it.</param>
/// <returns><paramref name="root"/> when the path is empty, else <c>root.slot.slot…</c>.</returns>
public static string Qualify(string root, IReadOnlyList<string> slots) =>
slots.Count == 0 ? root : root + "." + string.Join('.', slots);
}
/// <summary>
@@ -28,6 +28,11 @@ public sealed class EntitySerializer
var folderNameById = aggregate.TemplateFolders.ToDictionary(f => f.Id, f => f.Name);
var templateNameById = aggregate.Templates.ToDictionary(t => t.Id, t => t.Name);
var templateById = aggregate.Templates.ToDictionary(t => t.Id);
var compositionById = aggregate.Templates
.SelectMany(t => t.Compositions)
.GroupBy(c => c.Id)
.ToDictionary(g => g.Key, g => g.First());
// Name-link lookups. Instances/data-connections reference their owning
// site by the portable SiteIdentifier (not the EF surrogate SiteId); a
@@ -49,12 +54,20 @@ public sealed class EntitySerializer
// (TemplateAlarm.OnTriggerScriptId FK is scoped to TemplateId),
// so we don't need a global script index.
var scriptNameById = t.Scripts.ToDictionary(s => s.Id, s => s.Name);
// ScadaBridge#118: a derived (composition-slot) template travels as its
// derivation plus its OWN rows only — the inherited placeholders are the
// base's members, which the importer's compose flow re-materializes. A
// derivation that cannot be resolved inside this aggregate (owner or base
// missing) falls back to the pre-#118 shape rather than emitting a dangling path.
var derivation = TryBuildDerivation(t, templateById, compositionById);
bool Own(bool isInherited) => derivation is null || !isInherited;
return new TemplateDto(
Name: t.Name,
FolderName: t.FolderId is { } fid && folderNameById.TryGetValue(fid, out var fname) ? fname : null,
BaseTemplateName: t.ParentTemplateId is { } btid && templateNameById.TryGetValue(btid, out var bname) ? bname : null,
Description: t.Description,
Attributes: t.Attributes.Select(a => new TemplateAttributeDto(
Attributes: t.Attributes.Where(a => Own(a.IsInherited)).Select(a => new TemplateAttributeDto(
Name: a.Name,
Value: a.Value,
DataType: a.DataType,
@@ -63,7 +76,7 @@ public sealed class EntitySerializer
DataSourceReference: a.DataSourceReference,
ElementDataType: a.ElementDataType,
LockedInDerived: a.LockedInDerived)).ToList(),
Alarms: t.Alarms.Select(a => new TemplateAlarmDto(
Alarms: t.Alarms.Where(a => Own(a.IsInherited)).Select(a => new TemplateAlarmDto(
Name: a.Name,
Description: a.Description,
PriorityLevel: a.PriorityLevel,
@@ -79,7 +92,7 @@ public sealed class EntitySerializer
OnTriggerScriptName: a.OnTriggerScriptId is { } sid && scriptNameById.TryGetValue(sid, out var sn) ? sn : null,
LockedInDerived: a.LockedInDerived,
IsMesReportable: a.IsMesReportable)).ToList(),
Scripts: t.Scripts.Select(s => new TemplateScriptDto(
Scripts: t.Scripts.Where(s => Own(s.IsInherited)).Select(s => new TemplateScriptDto(
Name: s.Name,
Code: s.Code,
TriggerType: s.TriggerType,
@@ -95,8 +108,10 @@ public sealed class EntitySerializer
ComposedTemplateName: templateNameById.TryGetValue(c.ComposedTemplateId, out var cn) ? cn : string.Empty)).ToList())
{
// Native alarm sources carry ALL rows including IsInherited
// placeholders — the collision detector depends on them.
NativeAlarmSources = t.NativeAlarmSources.Select(n => new TemplateNativeAlarmSourceDto(
// placeholders — the collision detector depends on them — except
// on a template that travels with a Derivation (#118), whose
// placeholders the importer's compose flow rebuilds from the base.
NativeAlarmSources = t.NativeAlarmSources.Where(n => Own(n.IsInherited)).Select(n => new TemplateNativeAlarmSourceDto(
Name: n.Name,
Description: n.Description,
ConnectionName: n.ConnectionName,
@@ -106,6 +121,7 @@ public sealed class EntitySerializer
IsInherited: n.IsInherited,
LockedInDerived: n.LockedInDerived,
IsMesReportable: n.IsMesReportable)).ToList(),
Derivation = derivation,
};
}).ToList(),
SharedScripts: aggregate.SharedScripts.Select(s => new SharedScriptDto(
@@ -307,6 +323,61 @@ public sealed class EntitySerializer
};
}
/// <summary>
/// ScadaBridge#118: the derivation of a derived (composition-slot) template — its owner
/// path and its base path — or <c>null</c> for a base template, or when the owner or
/// base chain leaves the aggregate (the template then travels in the pre-#118 shape).
/// </summary>
internal static TemplateDerivationDto? TryBuildDerivation(
Template template,
IReadOnlyDictionary<int, Template> templateById,
IReadOnlyDictionary<int, TemplateComposition> compositionById)
{
if (!template.IsDerived || template.ParentTemplateId is not { } baseId) return null;
if (!templateById.TryGetValue(baseId, out var baseTemplate)) return null;
var owner = TryBuildPath(template, templateById, compositionById);
if (owner is null) return null;
(string Root, List<string> Slots)? basePath = baseTemplate.IsDerived
? TryBuildPath(baseTemplate, templateById, compositionById)
: (baseTemplate.Name, new List<string>());
if (basePath is null) return null;
return new TemplateDerivationDto(
OwnerTemplateName: owner.Value.Root,
SlotPath: owner.Value.Slots,
BaseTemplateName: basePath.Value.Root,
BaseSlotPath: basePath.Value.Slots);
}
// Walks a derived template's OwnerCompositionId chain up to the base template at its
// root: (root name, slot names root→template). Null when a link leaves the aggregate
// or the chain loops (the composition graph is acyclic by the save-gate; defensive).
private static (string Root, List<string> Slots)? TryBuildPath(
Template template,
IReadOnlyDictionary<int, Template> templateById,
IReadOnlyDictionary<int, TemplateComposition> compositionById)
{
var slots = new List<string>();
var visited = new HashSet<int>();
var current = template;
while (current.IsDerived)
{
if (!visited.Add(current.Id)
|| current.OwnerCompositionId is not { } compId
|| !compositionById.TryGetValue(compId, out var composition)
|| composition.ComposedTemplateId != current.Id
|| !templateById.TryGetValue(composition.TemplateId, out var owner))
{
return null;
}
slots.Insert(0, composition.InstanceName);
current = owner;
}
return (current.Name, slots);
}
/// <summary>Reconstructs a persistence-shaped <see cref="EntityAggregate"/> from a wire-shaped <see cref="BundleContentDto"/>.</summary>
/// <param name="content">The bundle content DTO to deserialize.</param>
/// <returns>An <see cref="EntityAggregate"/> with entities linked by name, ready for import conflict resolution.</returns>
@@ -17,7 +17,10 @@ public sealed class ManifestBuilder
// site/dataConnection/instance summary counts (and their content arrays).
// Minor bumps are additive-only: ManifestValidator gates solely on
// BundleFormatVersion, so an older "1.0" bundle still validates Ok.
public const string CurrentSchemaVersion = "1.1";
// 1.1 → 1.2 (ScadaBridge#118): TemplateDto.Derivation marks derived composition-slot
// templates. Additive — an older importer ignores the field (and imports the slot as
// it always did), a 1.0/1.1 bundle has no derivations and imports unchanged.
public const string CurrentSchemaVersion = "1.2";
/// <summary>
/// Builds a <see cref="BundleManifest"/> with the current format version, a SHA-256 content hash,