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:
@@ -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