diff --git a/src/ScadaLink.Transport/Export/DependencyResolver.cs b/src/ScadaLink.Transport/Export/DependencyResolver.cs new file mode 100644 index 0000000..7822fd4 --- /dev/null +++ b/src/ScadaLink.Transport/Export/DependencyResolver.cs @@ -0,0 +1,421 @@ +using ScadaLink.Commons.Entities.ExternalSystems; +using ScadaLink.Commons.Entities.InboundApi; +using ScadaLink.Commons.Entities.Notifications; +using ScadaLink.Commons.Entities.Scripts; +using ScadaLink.Commons.Entities.Templates; +using ScadaLink.Commons.Interfaces.Repositories; +using ScadaLink.Commons.Types.Transport; + +namespace ScadaLink.Transport.Export; + +/// +/// Expands an into the full set of entities that +/// must be in the bundle for the selection to be self-consistent. Walks the +/// dependency edges documented in §6.1 of the Transport design doc: +/// +/// Template composes Template (via TemplateComposition.ComposedTemplateId). +/// Template references SharedScript (by name, scanned in script bodies). +/// Template references ExternalSystem (by name, scanned in script bodies + attribute DataSourceReference). +/// ApiMethod references SharedScript (by name, scanned in ApiMethod.Script). +/// Selected Template's folder chain (ancestor folders included). +/// ExternalSystemDefinition pulls its ExternalSystemMethods. +/// +/// Templates are returned topologically sorted (base-before-derived) via Kahn's +/// algorithm so importers can apply them in order. +/// +public sealed class DependencyResolver +{ + private readonly ITemplateEngineRepository _templates; + private readonly IExternalSystemRepository _externalSystems; + private readonly INotificationRepository _notifications; + private readonly IInboundApiRepository _inboundApi; + + public DependencyResolver( + ITemplateEngineRepository templates, + IExternalSystemRepository externalSystems, + INotificationRepository notifications, + IInboundApiRepository inboundApi) + { + _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)); + } + + public async Task ResolveAsync(ExportSelection selection, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(selection); + + // ---- Seed: fetch the directly-selected entities ---- + var templates = new Dictionary(); + foreach (var id in selection.TemplateIds.Distinct()) + { + var t = await _templates.GetTemplateWithChildrenAsync(id, ct).ConfigureAwait(false); + if (t is not null) templates[t.Id] = t; + } + + var sharedScripts = new Dictionary(); + foreach (var id in selection.SharedScriptIds.Distinct()) + { + var s = await _templates.GetSharedScriptByIdAsync(id, ct).ConfigureAwait(false); + if (s is not null) sharedScripts[s.Id] = s; + } + + var externalSystems = new Dictionary(); + foreach (var id in selection.ExternalSystemIds.Distinct()) + { + var es = await _externalSystems.GetExternalSystemByIdAsync(id, ct).ConfigureAwait(false); + if (es is not null) externalSystems[es.Id] = es; + } + + var dbConnections = new Dictionary(); + foreach (var id in selection.DatabaseConnectionIds.Distinct()) + { + var db = await _externalSystems.GetDatabaseConnectionByIdAsync(id, ct).ConfigureAwait(false); + if (db is not null) dbConnections[db.Id] = db; + } + + var notificationLists = new Dictionary(); + foreach (var id in selection.NotificationListIds.Distinct()) + { + var nl = await _notifications.GetNotificationListByIdAsync(id, ct).ConfigureAwait(false); + if (nl is not null) notificationLists[nl.Id] = nl; + } + + var smtpConfigs = new Dictionary(); + foreach (var id in selection.SmtpConfigurationIds.Distinct()) + { + var sm = await _notifications.GetSmtpConfigurationByIdAsync(id, ct).ConfigureAwait(false); + if (sm is not null) smtpConfigs[sm.Id] = sm; + } + + var apiKeys = new Dictionary(); + foreach (var id in selection.ApiKeyIds.Distinct()) + { + var k = await _inboundApi.GetApiKeyByIdAsync(id, ct).ConfigureAwait(false); + if (k is not null) apiKeys[k.Id] = k; + } + + var apiMethods = new Dictionary(); + foreach (var id in selection.ApiMethodIds.Distinct()) + { + var m = await _inboundApi.GetApiMethodByIdAsync(id, ct).ConfigureAwait(false); + if (m is not null) apiMethods[m.Id] = m; + } + + // ---- Dependency expansion ---- + if (selection.IncludeDependencies) + { + await ExpandTemplateClosureAsync(templates, ct).ConfigureAwait(false); + await ExpandSharedScriptsFromTemplatesAsync(templates, sharedScripts, ct).ConfigureAwait(false); + await ExpandSharedScriptsFromApiMethodsAsync(apiMethods, sharedScripts, ct).ConfigureAwait(false); + await ExpandExternalSystemsFromTemplatesAsync(templates, externalSystems, ct).ConfigureAwait(false); + } + + // ExternalSystemMethods always travel with their parent ExternalSystem. + var externalSystemMethods = new List(); + foreach (var es in externalSystems.Values.OrderBy(x => x.Name, StringComparer.Ordinal)) + { + var methods = await _externalSystems + .GetMethodsByExternalSystemIdAsync(es.Id, ct) + .ConfigureAwait(false); + externalSystemMethods.AddRange(methods); + } + + // Folder ancestor chain — always pull, regardless of IncludeDependencies, + // so the imported tree never references missing parents. + var folders = await ResolveFolderChainAsync(templates.Values, ct).ConfigureAwait(false); + + // ---- Topological sort of templates (base-before-derived) ---- + var orderedTemplates = TopologicallySortTemplates(templates.Values); + + // ---- Build deterministic content manifest ---- + var manifest = BuildContentManifest( + folders, + orderedTemplates, + sharedScripts.Values, + externalSystems.Values, + externalSystemMethods, + dbConnections.Values, + notificationLists.Values, + smtpConfigs.Values, + apiKeys.Values, + apiMethods.Values); + + return new ResolvedExport( + TemplateFolders: folders, + Templates: orderedTemplates, + SharedScripts: sharedScripts.Values.OrderBy(s => s.Name, StringComparer.Ordinal).ToList(), + ExternalSystems: externalSystems.Values.OrderBy(e => e.Name, StringComparer.Ordinal).ToList(), + ExternalSystemMethods: externalSystemMethods, + DatabaseConnections: dbConnections.Values.OrderBy(d => d.Name, StringComparer.Ordinal).ToList(), + NotificationLists: notificationLists.Values.OrderBy(n => n.Name, StringComparer.Ordinal).ToList(), + SmtpConfigs: smtpConfigs.Values.OrderBy(s => s.Host, StringComparer.Ordinal).ToList(), + ApiKeys: apiKeys.Values.OrderBy(a => a.Name, StringComparer.Ordinal).ToList(), + ApiMethods: apiMethods.Values.OrderBy(a => a.Name, StringComparer.Ordinal).ToList(), + ContentManifest: manifest); + } + + // ---- Template composition closure ---- + private async Task ExpandTemplateClosureAsync(Dictionary templates, CancellationToken ct) + { + var queue = new Queue