feat(transport): serialize site/connection/instance entities<->DTOs (M8 B2)
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Transport.Serialization;
|
||||
@@ -27,6 +29,13 @@ 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);
|
||||
|
||||
// M8 name-link lookups. Instances/data-connections reference their owning
|
||||
// site by the portable SiteIdentifier (not the EF surrogate SiteId); a
|
||||
// connection binding references its DataConnection by Name. Build both
|
||||
// id→name maps once so the projections below resolve in O(1).
|
||||
var siteIdentifierBySiteId = aggregate.Sites.ToDictionary(s => s.Id, s => s.SiteIdentifier);
|
||||
var connectionNameByConnectionId = aggregate.DataConnections.ToDictionary(c => c.Id, c => c.Name);
|
||||
|
||||
return new BundleContentDto(
|
||||
TemplateFolders: aggregate.TemplateFolders.Select(f => new TemplateFolderDto(
|
||||
Name: f.Name,
|
||||
@@ -165,7 +174,74 @@ public sealed class EntitySerializer
|
||||
Script: m.Script,
|
||||
ParameterDefinitions: m.ParameterDefinitions,
|
||||
ReturnDefinition: m.ReturnDefinition,
|
||||
TimeoutSeconds: m.TimeoutSeconds)).ToList());
|
||||
TimeoutSeconds: m.TimeoutSeconds)).ToList())
|
||||
{
|
||||
// M8 site/instance-scoped payloads. These are init-only (non-positional)
|
||||
// on BundleContentDto, so they're set via object-initializer here.
|
||||
Sites = aggregate.Sites.Select(s => new SiteDto(
|
||||
SiteIdentifier: s.SiteIdentifier,
|
||||
Name: s.Name,
|
||||
Description: s.Description,
|
||||
NodeAAddress: s.NodeAAddress,
|
||||
NodeBAddress: s.NodeBAddress,
|
||||
GrpcNodeAAddress: s.GrpcNodeAAddress,
|
||||
GrpcNodeBAddress: s.GrpcNodeBAddress)).ToList(),
|
||||
DataConnections = aggregate.DataConnections.Select(c =>
|
||||
{
|
||||
// The protocol-specific Primary/Backup configuration JSON typically
|
||||
// embeds endpoints + credentials, so the whole blob rides inside the
|
||||
// SecretsBlock (a "share without secrets" export drops it as a unit).
|
||||
// Omit a key entirely when its source value is null/empty so the
|
||||
// importer's TryGetValue cleanly yields "no value" rather than "".
|
||||
var secretValues = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
if (!string.IsNullOrEmpty(c.PrimaryConfiguration))
|
||||
{
|
||||
secretValues["PrimaryConfiguration"] = c.PrimaryConfiguration;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(c.BackupConfiguration))
|
||||
{
|
||||
secretValues["BackupConfiguration"] = c.BackupConfiguration;
|
||||
}
|
||||
|
||||
return new DataConnectionDto(
|
||||
SiteIdentifier: siteIdentifierBySiteId.TryGetValue(c.SiteId, out var sid) ? sid : string.Empty,
|
||||
Name: c.Name,
|
||||
Protocol: c.Protocol,
|
||||
FailoverRetryCount: c.FailoverRetryCount,
|
||||
Secrets: secretValues.Count > 0 ? new SecretsBlock(secretValues) : null);
|
||||
}).ToList(),
|
||||
Instances = aggregate.Instances.Select(inst => new InstanceDto(
|
||||
UniqueName: inst.UniqueName,
|
||||
TemplateName: templateNameById.TryGetValue(inst.TemplateId, out var tn) ? tn : string.Empty,
|
||||
SiteIdentifier: siteIdentifierBySiteId.TryGetValue(inst.SiteId, out var isid) ? isid : string.Empty,
|
||||
// TODO(M8): Areas don't travel in the bundle yet — EntityAggregate
|
||||
// carries no Areas collection, so there's no id→name lookup. Emit
|
||||
// null until area transport is added; the importer leaves AreaId null.
|
||||
AreaName: null,
|
||||
State: inst.State,
|
||||
AttributeOverrides: inst.AttributeOverrides.Select(o => new InstanceAttributeOverrideDto(
|
||||
AttributeName: o.AttributeName,
|
||||
OverrideValue: o.OverrideValue,
|
||||
ElementDataType: o.ElementDataType)).ToList(),
|
||||
AlarmOverrides: inst.AlarmOverrides.Select(o => new InstanceAlarmOverrideDto(
|
||||
AlarmCanonicalName: o.AlarmCanonicalName,
|
||||
TriggerConfigurationOverride: o.TriggerConfigurationOverride,
|
||||
PriorityLevelOverride: o.PriorityLevelOverride)).ToList(),
|
||||
NativeAlarmSourceOverrides: inst.NativeAlarmSourceOverrides.Select(o => new InstanceNativeAlarmSourceOverrideDto(
|
||||
SourceCanonicalName: o.SourceCanonicalName,
|
||||
ConnectionNameOverride: o.ConnectionNameOverride,
|
||||
SourceReferenceOverride: o.SourceReferenceOverride,
|
||||
ConditionFilterOverride: o.ConditionFilterOverride)).ToList(),
|
||||
// Connection bindings reference their DataConnection by NAME — the
|
||||
// numeric DataConnectionId FK can't survive a cross-environment
|
||||
// bundle, so the importer (D1) resolves ConnectionName→target FK at
|
||||
// apply time. If the FK doesn't resolve in this aggregate (orphan
|
||||
// row), the name comes through empty.
|
||||
ConnectionBindings: inst.ConnectionBindings.Select(b => new InstanceConnectionBindingDto(
|
||||
AttributeName: b.AttributeName,
|
||||
ConnectionName: connectionNameByConnectionId.TryGetValue(b.DataConnectionId, out var cn) ? cn : string.Empty,
|
||||
DataSourceReferenceOverride: b.DataSourceReferenceOverride)).ToList())).ToList(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Reconstructs a persistence-shaped <see cref="EntityAggregate"/> from a wire-shaped <see cref="BundleContentDto"/>.</summary>
|
||||
@@ -352,6 +428,100 @@ public sealed class EntitySerializer
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// M8: sites first — assign synthetic ids by ordinal. Addresses are carried
|
||||
// verbatim; the importer decides whether to keep or rewrite them.
|
||||
var sites = content.Sites
|
||||
.Select((dto, ix) => new Site(dto.Name, dto.SiteIdentifier)
|
||||
{
|
||||
Id = ix + 1,
|
||||
Description = dto.Description,
|
||||
NodeAAddress = dto.NodeAAddress,
|
||||
NodeBAddress = dto.NodeBAddress,
|
||||
GrpcNodeAAddress = dto.GrpcNodeAAddress,
|
||||
GrpcNodeBAddress = dto.GrpcNodeBAddress,
|
||||
})
|
||||
.ToList();
|
||||
var siteIdByIdentifier = sites.ToDictionary(s => s.SiteIdentifier, s => s.Id, StringComparer.Ordinal);
|
||||
|
||||
// Data connections: resolve owning site by SiteIdentifier, restore the
|
||||
// protocol config from the SecretsBlock (null when the key was omitted).
|
||||
var dataConnections = content.DataConnections
|
||||
.Select((dto, ix) => new DataConnection(
|
||||
dto.Name,
|
||||
dto.Protocol,
|
||||
siteIdByIdentifier.TryGetValue(dto.SiteIdentifier, out var sid) ? sid : 0)
|
||||
{
|
||||
Id = ix + 1,
|
||||
FailoverRetryCount = dto.FailoverRetryCount,
|
||||
PrimaryConfiguration = dto.Secrets?.Values.TryGetValue("PrimaryConfiguration", out var pc) == true ? pc : null,
|
||||
BackupConfiguration = dto.Secrets?.Values.TryGetValue("BackupConfiguration", out var bc) == true ? bc : null,
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Instances: resolve template + site by name/identifier. Areas don't travel
|
||||
// (see export TODO), so AreaId stays null. Connection bindings keep their
|
||||
// resolved DataConnection NAME on the wire DTO — the importer (D1) reads
|
||||
// bindings from BundleContentDto.Instances[].ConnectionBindings directly and
|
||||
// resolves ConnectionName→the target environment's DataConnectionId at apply
|
||||
// time. FromBundleContent is NOT on the importer's path (BundleImporter walks
|
||||
// the raw DTO), so here we materialize the binding entity with a placeholder
|
||||
// DataConnectionId = 0; it carries AttributeName + DataSourceReferenceOverride
|
||||
// so a round-trip preserves the binding shape, but the FK is intentionally
|
||||
// unresolved (there is no DataConnection-name→id map valid for the target DB).
|
||||
var instances = content.Instances
|
||||
.Select((dto, ix) =>
|
||||
{
|
||||
var inst = new Instance(dto.UniqueName)
|
||||
{
|
||||
Id = ix + 1,
|
||||
State = dto.State,
|
||||
TemplateId = templateIdByName.TryGetValue(dto.TemplateName, out var tid) ? tid : 0,
|
||||
SiteId = siteIdByIdentifier.TryGetValue(dto.SiteIdentifier, out var isid) ? isid : 0,
|
||||
AreaId = null,
|
||||
};
|
||||
foreach (var o in dto.AttributeOverrides)
|
||||
{
|
||||
inst.AttributeOverrides.Add(new InstanceAttributeOverride(o.AttributeName)
|
||||
{
|
||||
InstanceId = inst.Id,
|
||||
OverrideValue = o.OverrideValue,
|
||||
ElementDataType = o.ElementDataType,
|
||||
});
|
||||
}
|
||||
foreach (var o in dto.AlarmOverrides)
|
||||
{
|
||||
inst.AlarmOverrides.Add(new InstanceAlarmOverride(o.AlarmCanonicalName)
|
||||
{
|
||||
InstanceId = inst.Id,
|
||||
TriggerConfigurationOverride = o.TriggerConfigurationOverride,
|
||||
PriorityLevelOverride = o.PriorityLevelOverride,
|
||||
});
|
||||
}
|
||||
foreach (var o in dto.NativeAlarmSourceOverrides)
|
||||
{
|
||||
inst.NativeAlarmSourceOverrides.Add(new InstanceNativeAlarmSourceOverride(o.SourceCanonicalName)
|
||||
{
|
||||
InstanceId = inst.Id,
|
||||
ConnectionNameOverride = o.ConnectionNameOverride,
|
||||
SourceReferenceOverride = o.SourceReferenceOverride,
|
||||
ConditionFilterOverride = o.ConditionFilterOverride,
|
||||
});
|
||||
}
|
||||
foreach (var b in dto.ConnectionBindings)
|
||||
{
|
||||
inst.ConnectionBindings.Add(new InstanceConnectionBinding(b.AttributeName)
|
||||
{
|
||||
InstanceId = inst.Id,
|
||||
// FK left at 0 on purpose — the importer resolves it from the
|
||||
// DTO's ConnectionName against target-DB DataConnection ids.
|
||||
DataConnectionId = 0,
|
||||
DataSourceReferenceOverride = b.DataSourceReferenceOverride,
|
||||
});
|
||||
}
|
||||
return inst;
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new EntityAggregate(
|
||||
TemplateFolders: folders,
|
||||
Templates: templates,
|
||||
@@ -361,6 +531,11 @@ public sealed class EntitySerializer
|
||||
DatabaseConnections: databaseConnections,
|
||||
NotificationLists: notificationLists,
|
||||
SmtpConfigurations: smtpConfigurations,
|
||||
ApiMethods: apiMethods);
|
||||
ApiMethods: apiMethods)
|
||||
{
|
||||
Sites = sites,
|
||||
DataConnections = dataConnections,
|
||||
Instances = instances,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user