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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user