From c7d868c52e88eebec49dd47d1ee72804bdc09f51 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 9 Nov 2026 13:46:45 -0500 Subject: [PATCH] fix(transport): case-insensitive name lookups, batch preview lookups, correct flattener doc (ScadaBridge#93) - ApplyTemplatesAsync/PreviewAsync template lookups use a case-insensitive, never-throwing comparer (SQL Server collation); base template wins over derived. Base names are already unique case-insensitively via the filtered unique index on Template.Name, derived templates are not covered by it. - Preview reads database connections, notification lists and API methods with one GetAll query each instead of one by-name query per bundled row. - Component-TemplateEngine.md: the flattener does not call GetAllTemplatesAsync; it loads chains via GetTemplateWithChildrenAsync. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV --- docs/requirements/Component-TemplateEngine.md | 2 +- .../Import/BundleImporter.cs | 55 +++++- .../Import/ImportNameLookupTests.cs | 176 ++++++++++++++++++ 3 files changed, 223 insertions(+), 10 deletions(-) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/ImportNameLookupTests.cs diff --git a/docs/requirements/Component-TemplateEngine.md b/docs/requirements/Component-TemplateEngine.md index 9e6c2f44..619af992 100644 --- a/docs/requirements/Component-TemplateEngine.md +++ b/docs/requirements/Component-TemplateEngine.md @@ -239,7 +239,7 @@ falls back to `GetTemplateChainAsync` — the template's own inheritance chain, one load per level — since resyncing a leaf reads only its chain (Gitea #89). The read-only resolved-members query (`GetResolvedTemplateMembersCommand`, behind the editor's inherited view and CLI `template resolved-members`) reads that chain alone, -untracked. The flattener keeps reading the whole tracked graph via `GetAllTemplatesAsync`. +untracked. The flattener does not call `GetAllTemplatesAsync` (verified against `FlatteningPipeline`/`FlatteningService`, Gitea #93): the pipeline loads each template chain the instance needs through `GetTemplateWithChildrenAsync` (tracked, fully included, memoised per `FlattenSession`) and each composition's chain the same way, so it still reads tracked, body-bearing rows — only the whole-library load is not part of it. ### Native Alarm Source Resolution diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs index 82726e6e..dc17c779 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs @@ -482,8 +482,7 @@ public sealed class BundleImporter : IBundleImporter var hydratedTemplates = await _templateRepo .GetTemplatesWithChildrenAsync(bundleTemplateNames, ct) .ConfigureAwait(false); - var hydratedByName = hydratedTemplates - .ToDictionary(t => t.Name, t => t, StringComparer.Ordinal); + var hydratedByName = ToTemplateLookup(hydratedTemplates); var templateNameById = await library.GetNameByIdAsync(ct).ConfigureAwait(false); foreach (var tDto in content.Templates) { @@ -521,16 +520,24 @@ public sealed class BundleImporter : IBundleImporter } // ---- DatabaseConnections ---- + // One read of the library instead of one by-name query per bundled row + // (ScadaBridge#93); matched case-insensitively like the SQL Server collation. + var dbConnectionByName = content.DatabaseConnections.Count == 0 + ? ToNameLookup(Array.Empty(), x => x.Name) + : ToNameLookup(await _externalRepo.GetAllDatabaseConnectionsAsync(ct).ConfigureAwait(false), x => x.Name); foreach (var db in content.DatabaseConnections) { - var existing = await _externalRepo.GetDatabaseConnectionByNameAsync(db.Name, ct).ConfigureAwait(false); + dbConnectionByName.TryGetValue(db.Name, out var existing); items.Add(_diff.CompareDatabaseConnection(db, existing)); } // ---- NotificationLists ---- + var notificationListByName = content.NotificationLists.Count == 0 + ? ToNameLookup(Array.Empty(), x => x.Name) + : ToNameLookup(await _notificationRepo.GetAllNotificationListsAsync(ct).ConfigureAwait(false), x => x.Name); foreach (var nl in content.NotificationLists) { - var existing = await _notificationRepo.GetListByNameAsync(nl.Name, ct).ConfigureAwait(false); + notificationListByName.TryGetValue(nl.Name, out var existing); items.Add(_diff.CompareNotificationList(nl, existing)); } @@ -560,9 +567,12 @@ public sealed class BundleImporter : IBundleImporter // from a hash). They are counted, ignored, and reported at apply time. // ---- ApiMethods ---- + var apiMethodByName = content.ApiMethods.Count == 0 + ? ToNameLookup(Array.Empty(), x => x.Name) + : ToNameLookup(await _inboundApiRepo.GetAllApiMethodsAsync(ct).ConfigureAwait(false), x => x.Name); foreach (var m in content.ApiMethods) { - var existing = await _inboundApiRepo.GetMethodByNameAsync(m.Name, ct).ConfigureAwait(false); + apiMethodByName.TryGetValue(m.Name, out var existing); items.Add(_diff.CompareApiMethod(m, existing)); } @@ -2313,6 +2323,32 @@ public sealed class BundleImporter : IBundleImporter } } + /// + /// Name lookup that matches the way SQL Server's default case-insensitive + /// collation matches () and can never + /// throw on a duplicate key: the first row for a name wins. Used wherever a + /// by-name DB result is re-keyed in memory (ScadaBridge#93). + /// + internal static Dictionary ToNameLookup(IEnumerable rows, Func name) + { + var lookup = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var row in rows) + { + lookup.TryAdd(name(row), row); + } + return lookup; + } + + /// + /// for templates. Base templates are unique by name + /// (case-insensitively) through the filtered unique index on Name, but + /// derived templates hold their composition slot name and are NOT in that index, + /// so two rows can differ only in case. A base (user-authored) template wins over + /// a derived one, which is the row an import actually targets. + /// + internal static Dictionary ToTemplateLookup(IEnumerable