feat(transport): resolve site/instance export selection + deps (M8 B1)
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;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
@@ -22,6 +24,13 @@ namespace ZB.MOM.WW.ScadaBridge.Transport.Export;
|
||||
/// </list>
|
||||
/// Templates are returned topologically sorted (base-before-derived) via Kahn's
|
||||
/// algorithm so importers can apply them in order.
|
||||
/// <para>
|
||||
/// M8 adds site/instance-scoped expansion: selecting a site pulls its
|
||||
/// <c>DataConnection</c>s and <c>Instance</c>s; selecting an instance (with
|
||||
/// <see cref="ExportSelection.IncludeDependencies"/>) pulls its owning site, the
|
||||
/// data connections it binds, and feeds its template into the existing template
|
||||
/// closure so the template/shared-script/external-system graph expands too.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class DependencyResolver
|
||||
{
|
||||
@@ -29,22 +38,26 @@ public sealed class DependencyResolver
|
||||
private readonly IExternalSystemRepository _externalSystems;
|
||||
private readonly INotificationRepository _notifications;
|
||||
private readonly IInboundApiRepository _inboundApi;
|
||||
private readonly ISiteRepository _siteRepository;
|
||||
|
||||
/// <summary>Initializes a new instance of <see cref="DependencyResolver"/>.</summary>
|
||||
/// <param name="templates">Repository for template and shared script access.</param>
|
||||
/// <param name="templates">Repository for template, instance and shared script access.</param>
|
||||
/// <param name="externalSystems">Repository for external system definitions and methods.</param>
|
||||
/// <param name="notifications">Repository for notification lists and SMTP configurations.</param>
|
||||
/// <param name="inboundApi">Repository for inbound API keys and methods.</param>
|
||||
/// <param name="siteRepository">Repository for sites and site-scoped data connections.</param>
|
||||
public DependencyResolver(
|
||||
ITemplateEngineRepository templates,
|
||||
IExternalSystemRepository externalSystems,
|
||||
INotificationRepository notifications,
|
||||
IInboundApiRepository inboundApi)
|
||||
IInboundApiRepository inboundApi,
|
||||
ISiteRepository siteRepository)
|
||||
{
|
||||
_templates = templates ?? throw new ArgumentNullException(nameof(templates));
|
||||
_externalSystems = externalSystems ?? throw new ArgumentNullException(nameof(externalSystems));
|
||||
_notifications = notifications ?? throw new ArgumentNullException(nameof(notifications));
|
||||
_inboundApi = inboundApi ?? throw new ArgumentNullException(nameof(inboundApi));
|
||||
_siteRepository = siteRepository ?? throw new ArgumentNullException(nameof(siteRepository));
|
||||
}
|
||||
|
||||
/// <summary>Expands the selection into a fully self-consistent set of entities for bundling.</summary>
|
||||
@@ -108,9 +121,53 @@ public sealed class DependencyResolver
|
||||
if (m is not null) apiMethods[m.Id] = m;
|
||||
}
|
||||
|
||||
// ---- Seed: site/instance-scoped selection (M8) ----
|
||||
// sites/dataConnections/instances are keyed by surrogate id for dedup.
|
||||
var sites = new Dictionary<int, Site>();
|
||||
var dataConnections = new Dictionary<int, DataConnection>();
|
||||
var instances = new Dictionary<int, Instance>();
|
||||
|
||||
// Selecting a SITE pulls its data connections and all of its instances. The
|
||||
// instance's overrides/bindings are loaded eagerly so the serializer can read
|
||||
// them off the entity navigation collections.
|
||||
foreach (var siteId in selection.SiteIds.Distinct())
|
||||
{
|
||||
var site = await _siteRepository.GetSiteByIdAsync(siteId, ct).ConfigureAwait(false);
|
||||
if (site is null) continue;
|
||||
sites[site.Id] = site;
|
||||
|
||||
foreach (var conn in await _siteRepository.GetDataConnectionsBySiteIdAsync(site.Id, ct).ConfigureAwait(false))
|
||||
{
|
||||
dataConnections[conn.Id] = conn;
|
||||
}
|
||||
|
||||
foreach (var siteInstance in await _siteRepository.GetInstancesBySiteIdAsync(site.Id, ct).ConfigureAwait(false))
|
||||
{
|
||||
if (instances.ContainsKey(siteInstance.Id)) continue;
|
||||
var loaded = await LoadInstanceWithChildrenAsync(siteInstance.Id, ct).ConfigureAwait(false);
|
||||
if (loaded is not null) instances[loaded.Id] = loaded;
|
||||
}
|
||||
}
|
||||
|
||||
// Selecting an INSTANCE directly. Dedup against instances already pulled in
|
||||
// via their owning site above (a site + one of its instances selected together
|
||||
// must not double-add the instance).
|
||||
foreach (var instanceId in selection.InstanceIds.Distinct())
|
||||
{
|
||||
if (instances.ContainsKey(instanceId)) continue;
|
||||
var loaded = await LoadInstanceWithChildrenAsync(instanceId, ct).ConfigureAwait(false);
|
||||
if (loaded is not null) instances[loaded.Id] = loaded;
|
||||
}
|
||||
|
||||
// ---- Dependency expansion ----
|
||||
if (selection.IncludeDependencies)
|
||||
{
|
||||
// Each gathered instance pulls in its owning site, the data connections it
|
||||
// binds, and (by feeding instance.TemplateId into the template dictionary)
|
||||
// its template — so the template/shared-script/external-system closure below
|
||||
// expands transitively over instance templates too.
|
||||
await ExpandSiteInstanceClosureAsync(instances, sites, dataConnections, templates, ct).ConfigureAwait(false);
|
||||
|
||||
await ExpandTemplateClosureAsync(templates, ct).ConfigureAwait(false);
|
||||
await ExpandSharedScriptsFromTemplatesAsync(templates, sharedScripts, ct).ConfigureAwait(false);
|
||||
await ExpandSharedScriptsFromApiMethodsAsync(apiMethods, sharedScripts, ct).ConfigureAwait(false);
|
||||
@@ -134,6 +191,22 @@ public sealed class DependencyResolver
|
||||
// ---- Topological sort of templates (base-before-derived) ----
|
||||
var orderedTemplates = TopologicallySortTemplates(templates.Values);
|
||||
|
||||
// ---- Deterministic site/instance ordering (M8) ----
|
||||
// siteId → SiteIdentifier map: every connection's/instance's owning site lands
|
||||
// in `sites` after closure expansion, so ordering + dependsOn edges resolve names.
|
||||
var siteIdentifierById = sites.Values.ToDictionary(s => s.Id, s => s.SiteIdentifier);
|
||||
|
||||
var orderedSites = sites.Values
|
||||
.OrderBy(s => s.SiteIdentifier, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
var orderedDataConnections = dataConnections.Values
|
||||
.OrderBy(c => SiteIdentifierOf(c.SiteId, siteIdentifierById), StringComparer.Ordinal)
|
||||
.ThenBy(c => c.Name, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
var orderedInstances = instances.Values
|
||||
.OrderBy(i => i.UniqueName, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
// ---- Build deterministic content manifest ----
|
||||
var manifest = BuildContentManifest(
|
||||
folders,
|
||||
@@ -144,7 +217,12 @@ public sealed class DependencyResolver
|
||||
dbConnections.Values,
|
||||
notificationLists.Values,
|
||||
smtpConfigs.Values,
|
||||
apiMethods.Values);
|
||||
apiMethods.Values,
|
||||
orderedSites,
|
||||
orderedDataConnections,
|
||||
orderedInstances,
|
||||
siteIdentifierById,
|
||||
templates);
|
||||
|
||||
return new ResolvedExport(
|
||||
TemplateFolders: folders,
|
||||
@@ -156,9 +234,101 @@ public sealed class DependencyResolver
|
||||
NotificationLists: notificationLists.Values.OrderBy(n => n.Name, StringComparer.Ordinal).ToList(),
|
||||
SmtpConfigs: smtpConfigs.Values.OrderBy(s => s.Host, StringComparer.Ordinal).ToList(),
|
||||
ApiMethods: apiMethods.Values.OrderBy(a => a.Name, StringComparer.Ordinal).ToList(),
|
||||
ContentManifest: manifest);
|
||||
ContentManifest: manifest)
|
||||
{
|
||||
Sites = orderedSites,
|
||||
DataConnections = orderedDataConnections,
|
||||
Instances = orderedInstances,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Instance child loading (M8) ----
|
||||
// Loads an instance and attaches its overrides/bindings onto the entity's own
|
||||
// navigation collections, so the serializer reads children off the entity (the
|
||||
// same shape central-config entities use). Returns null if the instance is gone.
|
||||
private async Task<Instance?> LoadInstanceWithChildrenAsync(int instanceId, CancellationToken ct)
|
||||
{
|
||||
var instance = await _templates.GetInstanceByIdAsync(instanceId, ct).ConfigureAwait(false);
|
||||
if (instance is null) return null;
|
||||
|
||||
var attributeOverrides = await _templates.GetOverridesByInstanceIdAsync(instance.Id, ct).ConfigureAwait(false);
|
||||
var alarmOverrides = await _templates.GetAlarmOverridesByInstanceIdAsync(instance.Id, ct).ConfigureAwait(false);
|
||||
var nativeAlarmOverrides = await _templates.GetNativeAlarmSourceOverridesByInstanceIdAsync(instance.Id, ct).ConfigureAwait(false);
|
||||
var bindings = await _templates.GetBindingsByInstanceIdAsync(instance.Id, ct).ConfigureAwait(false);
|
||||
|
||||
instance.AttributeOverrides = attributeOverrides.ToList();
|
||||
instance.AlarmOverrides = alarmOverrides.ToList();
|
||||
instance.NativeAlarmSourceOverrides = nativeAlarmOverrides.ToList();
|
||||
instance.ConnectionBindings = bindings.ToList();
|
||||
return instance;
|
||||
}
|
||||
|
||||
// ---- Site/instance closure (M8) ----
|
||||
// For every gathered instance: ensure its owning Site is in the bundle, include
|
||||
// the DataConnections it binds (via ConnectionBinding.DataConnectionId resolved
|
||||
// within the instance's site, plus NativeAlarmSourceOverride.ConnectionNameOverride
|
||||
// resolved by name within that site), and feed its TemplateId into the template
|
||||
// dictionary so the template closure expands over it.
|
||||
private async Task ExpandSiteInstanceClosureAsync(
|
||||
Dictionary<int, Instance> instances,
|
||||
Dictionary<int, Site> sites,
|
||||
Dictionary<int, DataConnection> dataConnections,
|
||||
Dictionary<int, Template> templates,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Snapshot the instances: the loop only grows sites/dataConnections/templates,
|
||||
// never instances, so iterating a snapshot keeps the dictionary stable.
|
||||
foreach (var instance in instances.Values.ToList())
|
||||
{
|
||||
// 1. Owning site.
|
||||
if (!sites.ContainsKey(instance.SiteId))
|
||||
{
|
||||
var site = await _siteRepository.GetSiteByIdAsync(instance.SiteId, ct).ConfigureAwait(false);
|
||||
if (site is not null) sites[site.Id] = site;
|
||||
}
|
||||
|
||||
// 2. Template → the existing template closure picks it up below.
|
||||
if (!templates.ContainsKey(instance.TemplateId))
|
||||
{
|
||||
var template = await _templates.GetTemplateWithChildrenAsync(instance.TemplateId, ct).ConfigureAwait(false);
|
||||
if (template is not null) templates[template.Id] = template;
|
||||
}
|
||||
|
||||
// 3. Data connections the instance references, resolved within its site.
|
||||
// Connection bindings reference connections by id; native-alarm-source
|
||||
// overrides reference them by name — resolve both against the site set.
|
||||
var bindingConnectionIds = instance.ConnectionBindings
|
||||
.Select(b => b.DataConnectionId)
|
||||
.Where(id => !dataConnections.ContainsKey(id))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var referencedConnectionNames = instance.NativeAlarmSourceOverrides
|
||||
.Select(o => o.ConnectionNameOverride)
|
||||
.Where(n => !string.IsNullOrEmpty(n))
|
||||
.Select(n => n!)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
if (bindingConnectionIds.Count == 0 && referencedConnectionNames.Count == 0) continue;
|
||||
|
||||
var siteConnections = await _siteRepository
|
||||
.GetDataConnectionsBySiteIdAsync(instance.SiteId, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
foreach (var conn in siteConnections)
|
||||
{
|
||||
if (dataConnections.ContainsKey(conn.Id)) continue;
|
||||
if (bindingConnectionIds.Contains(conn.Id) || referencedConnectionNames.Contains(conn.Name))
|
||||
{
|
||||
dataConnections[conn.Id] = conn;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string SiteIdentifierOf(int siteId, IReadOnlyDictionary<int, string> siteIdentifierById) =>
|
||||
siteIdentifierById.TryGetValue(siteId, out var id) ? id : siteId.ToString();
|
||||
|
||||
// ---- Template composition closure ----
|
||||
private async Task ExpandTemplateClosureAsync(Dictionary<int, Template> templates, CancellationToken ct)
|
||||
{
|
||||
@@ -362,7 +532,12 @@ public sealed class DependencyResolver
|
||||
IEnumerable<DatabaseConnectionDefinition> dbConnections,
|
||||
IEnumerable<NotificationList> notificationLists,
|
||||
IEnumerable<SmtpConfiguration> smtpConfigs,
|
||||
IEnumerable<ApiMethod> apiMethods)
|
||||
IEnumerable<ApiMethod> apiMethods,
|
||||
IReadOnlyList<Site> sites,
|
||||
IReadOnlyList<DataConnection> dataConnections,
|
||||
IReadOnlyList<Instance> instances,
|
||||
IReadOnlyDictionary<int, string> siteIdentifierById,
|
||||
IReadOnlyDictionary<int, Template> templatesById)
|
||||
{
|
||||
var entries = new List<ManifestContentEntry>();
|
||||
|
||||
@@ -418,6 +593,66 @@ public sealed class DependencyResolver
|
||||
entries.Add(new ManifestContentEntry("ApiMethod", m.Name, 1, Array.Empty<string>()));
|
||||
}
|
||||
|
||||
// ---- M8: site/instance-scoped manifest rows ----
|
||||
// Sites are roots (no dependsOn). DataConnections depend on their site.
|
||||
// Instances depend on their template, their site, and each data connection
|
||||
// they bind. Inputs arrive pre-ordered, so iterate as-is for determinism.
|
||||
var connectionById = dataConnections.ToDictionary(c => c.Id);
|
||||
var connectionBySiteAndName = dataConnections
|
||||
.GroupBy(c => c.SiteId)
|
||||
.ToDictionary(g => g.Key, g => g.ToDictionary(c => c.Name, StringComparer.Ordinal));
|
||||
|
||||
foreach (var site in sites)
|
||||
{
|
||||
entries.Add(new ManifestContentEntry("Site", site.SiteIdentifier, 1, Array.Empty<string>()));
|
||||
}
|
||||
foreach (var conn in dataConnections)
|
||||
{
|
||||
var siteIdentifier = SiteIdentifierOf(conn.SiteId, siteIdentifierById);
|
||||
entries.Add(new ManifestContentEntry(
|
||||
"DataConnection",
|
||||
$"{siteIdentifier}/{conn.Name}",
|
||||
1,
|
||||
new[] { $"Site:{siteIdentifier}" }));
|
||||
}
|
||||
foreach (var instance in instances)
|
||||
{
|
||||
var siteIdentifier = SiteIdentifierOf(instance.SiteId, siteIdentifierById);
|
||||
var templateName = templatesById.TryGetValue(instance.TemplateId, out var tpl)
|
||||
? tpl.Name
|
||||
: instance.TemplateId.ToString();
|
||||
|
||||
var deps = new List<string>
|
||||
{
|
||||
$"Template:{templateName}",
|
||||
$"Site:{siteIdentifier}",
|
||||
};
|
||||
|
||||
// Connection-binding edges (resolved by id within the export closure).
|
||||
foreach (var binding in instance.ConnectionBindings)
|
||||
{
|
||||
if (connectionById.TryGetValue(binding.DataConnectionId, out var conn))
|
||||
{
|
||||
deps.Add($"DataConnection:{SiteIdentifierOf(conn.SiteId, siteIdentifierById)}/{conn.Name}");
|
||||
}
|
||||
}
|
||||
// Native-alarm-source connection-name overrides (resolved by name within the site).
|
||||
if (connectionBySiteAndName.TryGetValue(instance.SiteId, out var byName))
|
||||
{
|
||||
foreach (var ovr in instance.NativeAlarmSourceOverrides)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(ovr.ConnectionNameOverride)
|
||||
&& byName.ContainsKey(ovr.ConnectionNameOverride))
|
||||
{
|
||||
deps.Add($"DataConnection:{siteIdentifier}/{ovr.ConnectionNameOverride}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var orderedDeps = deps.Distinct().OrderBy(x => x, StringComparer.Ordinal).ToList();
|
||||
entries.Add(new ManifestContentEntry("Instance", instance.UniqueName, 1, orderedDeps));
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi; // ApiMethod
|
||||
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;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
|
||||
@@ -25,4 +27,33 @@ public sealed record ResolvedExport(
|
||||
IReadOnlyList<SmtpConfiguration> SmtpConfigs,
|
||||
// Inbound API keys are not transported between environments (re-arch C4); only methods.
|
||||
IReadOnlyList<ApiMethod> ApiMethods,
|
||||
IReadOnlyList<ManifestContentEntry> ContentManifest);
|
||||
IReadOnlyList<ManifestContentEntry> ContentManifest)
|
||||
{
|
||||
// M8: site/instance-scoped closure. Additive init-only collections with empty
|
||||
// defaults so the positional ctor stays source-compatible for callers/tests
|
||||
// that only resolve central-config selections (sites/instances stay empty).
|
||||
// The resolver opts in via object-initializer. Each <see cref="Instance"/>
|
||||
// carries its overrides/bindings on its own navigation collections
|
||||
// (AttributeOverrides / AlarmOverrides / NativeAlarmSourceOverrides /
|
||||
// ConnectionBindings), populated by the resolver — the serializer reads them
|
||||
// off the entity, matching how the central-config entities carry their children.
|
||||
//
|
||||
// Ordering is fixed by the resolver: Sites by SiteIdentifier; DataConnections
|
||||
// by (SiteIdentifier, Name); Instances by UniqueName.
|
||||
|
||||
/// <summary>Sites in the export closure, ordered by <see cref="Site.SiteIdentifier"/>.</summary>
|
||||
public IReadOnlyList<Site> Sites { get; init; } = Array.Empty<Site>();
|
||||
|
||||
/// <summary>
|
||||
/// Site-scoped <see cref="DataConnection"/>s in the closure, ordered by
|
||||
/// (owning site identifier, connection name).
|
||||
/// </summary>
|
||||
public IReadOnlyList<DataConnection> DataConnections { get; init; } = Array.Empty<DataConnection>();
|
||||
|
||||
/// <summary>
|
||||
/// Instances in the closure, ordered by <see cref="Instance.UniqueName"/>. Each
|
||||
/// carries its loaded override/binding child collections on its navigation
|
||||
/// properties.
|
||||
/// </summary>
|
||||
public IReadOnlyList<Instance> Instances { get; init; } = Array.Empty<Instance>();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user