From 6ff5efd3f40864b40152cd0193436dcc6b810a05 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 9 Nov 2026 15:09:18 -0500 Subject: [PATCH] fix(transport): case-insensitive name lookups inside a record on import (ScadaBridge#100) Item 2 of #100, following the #93/#95 pattern (ToNameLookup: OrdinalIgnoreCase, first wins, never throws): - Overwrite child sync matches template attributes, alarms, scripts and native alarm sources, and external-system methods, case-insensitively, like their (parent, Name) unique indexes under SQL Server's collation. A member that differs only in case is updated in place instead of deleted and re-added (which lost the row id and could collide with the unique index). - ResolveAlarmScriptLinksAsync resolves the alarm and its on-trigger script case-insensitively; AlarmsEqual compares the script name the same way. - ArtifactDiff (preview) matches child collections case-insensitively so it agrees with the sync; notification recipients keep the ordinal match (no unique index, replaced wholesale). - The rewire passes found the template a DTO was imported onto with an ordinal index. Since #93 an Overwrite can match a differently-cased target and keeps its spelling, so the passes silently skipped it and the alarm on-trigger links the sync had cleared stayed null. TryGetImported adds a case-insensitive (base-first) fallback; composed/base template references keep the exact lookup (item 1 of #100 is still undecided). Test: ImportNameLookupTests.Members_inside_a_record_match_case_insensitively, over a proxy that gives the by-name template reads SQL Server's collation. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV --- .../Import/ArtifactDiff.cs | 27 ++-- .../Import/BundleImporter.cs | 85 +++++++--- .../Import/TemplateChildEquality.cs | 4 +- .../Import/ImportNameLookupTests.cs | 146 +++++++++++++++++- 4 files changed, 228 insertions(+), 34 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/ArtifactDiff.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/ArtifactDiff.cs index 7d394605..003990dd 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/ArtifactDiff.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/ArtifactDiff.cs @@ -253,7 +253,8 @@ public sealed class ArtifactDiff // number added/changed on an existing recipient is a real diff. (e, i) => e.EmailAddress == i.EmailAddress && e.PhoneNumber == i.PhoneNumber, "Recipients", - changes); + changes, + ignoreCase: false); return BuildItem("NotificationList", incoming.Name, changes); } @@ -622,12 +623,19 @@ public sealed class ArtifactDiff Func incomingKey, Func equal, string childCategory, - List changes) + List changes, + bool ignoreCase = true) { - var existingByName = existing.GroupBy(existingKey, StringComparer.Ordinal) - .ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal); - var incomingByName = incoming.GroupBy(incomingKey, StringComparer.Ordinal) - .ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal); + // Child names are unique per parent case-insensitively (the DB unique indexes + // under SQL Server's collation), and the Overwrite sync matches them that way, so + // the diff does too: a case-only difference is the same row (ScadaBridge#100). + // Notification recipients have no such index and are replaced wholesale, so they + // keep the ordinal match (ignoreCase: false). + var comparer = ignoreCase ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + var existingByName = existing.GroupBy(existingKey, comparer) + .ToDictionary(g => g.Key, g => g.First(), comparer); + var incomingByName = incoming.GroupBy(incomingKey, comparer) + .ToDictionary(g => g.Key, g => g.First(), comparer); var added = incomingByName.Keys.Where(k => !existingByName.ContainsKey(k)).OrderBy(k => k, StringComparer.Ordinal).ToList(); var removed = existingByName.Keys.Where(k => !incomingByName.ContainsKey(k)).OrderBy(k => k, StringComparer.Ordinal).ToList(); @@ -659,10 +667,9 @@ public sealed class ArtifactDiff IEnumerable incoming, List changes) { - var existingByName = existing.GroupBy(s => s.Name, StringComparer.Ordinal) - .ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal); - var incomingByName = incoming.GroupBy(s => s.Name, StringComparer.Ordinal) - .ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal); + // Case-insensitive, as DiffChildren (ScadaBridge#100). + var existingByName = BundleImporter.ToNameLookup(existing, s => s.Name); + var incomingByName = BundleImporter.ToNameLookup(incoming, s => s.Name); foreach (var name in incomingByName.Keys.Where(k => !existingByName.ContainsKey(k)).OrderBy(k => k, StringComparer.Ordinal)) { diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs index 8f6792dd..de72516e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs @@ -2639,8 +2639,11 @@ public sealed class BundleImporter : IBundleImporter string user, CancellationToken ct) { - var existingByName = ex.Attributes.ToDictionary(a => a.Name, a => a, StringComparer.Ordinal); - var dtoByName = dto.Attributes.ToDictionary(a => a.Name, a => a, StringComparer.Ordinal); + // Matched case-insensitively, like the target's unique index on (TemplateId, Name) + // under SQL Server's collation: a member that differs only in case is the same row + // and is updated in place, not deleted and re-added (ScadaBridge#100). + var existingByName = ToNameLookup(ex.Attributes, a => a.Name); + var dtoByName = ToNameLookup(dto.Attributes, a => a.Name); // Deletes — attributes present on the target but not in the bundle. foreach (var existing in existingByName.Values.ToList()) @@ -2756,8 +2759,11 @@ public sealed class BundleImporter : IBundleImporter string user, CancellationToken ct) { - var existingByName = ex.Alarms.ToDictionary(a => a.Name, a => a, StringComparer.Ordinal); - var dtoByName = dto.Alarms.ToDictionary(a => a.Name, a => a, StringComparer.Ordinal); + // Matched case-insensitively, like the target's unique index on (TemplateId, Name) + // under SQL Server's collation: a member that differs only in case is the same row + // and is updated in place, not deleted and re-added (ScadaBridge#100). + var existingByName = ToNameLookup(ex.Alarms, a => a.Name); + var dtoByName = ToNameLookup(dto.Alarms, a => a.Name); // On-trigger script is referenced by name in the bundle; resolve the // persisted FK back to a name over this template's scripts so the shared @@ -2883,8 +2889,11 @@ public sealed class BundleImporter : IBundleImporter string user, CancellationToken ct) { - var existingByName = ex.Scripts.ToDictionary(s => s.Name, s => s, StringComparer.Ordinal); - var dtoByName = dto.Scripts.ToDictionary(s => s.Name, s => s, StringComparer.Ordinal); + // Matched case-insensitively, like the target's unique index on (TemplateId, Name) + // under SQL Server's collation: a member that differs only in case is the same row + // and is updated in place, not deleted and re-added (ScadaBridge#100). + var existingByName = ToNameLookup(ex.Scripts, s => s.Name); + var dtoByName = ToNameLookup(dto.Scripts, s => s.Name); foreach (var existing in existingByName.Values.ToList()) { @@ -2989,8 +2998,11 @@ public sealed class BundleImporter : IBundleImporter string user, CancellationToken ct) { - var existingByName = ex.NativeAlarmSources.ToDictionary(s => s.Name, s => s, StringComparer.Ordinal); - var dtoByName = dto.NativeAlarmSources.ToDictionary(s => s.Name, s => s, StringComparer.Ordinal); + // Matched case-insensitively, like the target's unique index on (TemplateId, Name) + // under SQL Server's collation: a member that differs only in case is the same row + // and is updated in place, not deleted and re-added (ScadaBridge#100). + var existingByName = ToNameLookup(ex.NativeAlarmSources, s => s.Name); + var dtoByName = ToNameLookup(dto.NativeAlarmSources, s => s.Name); // Deletes — sources present on the target but not in the bundle. foreach (var existing in existingByName.Values.ToList()) @@ -3121,7 +3133,7 @@ public sealed class BundleImporter : IBundleImporter var importedName = resolution.Action == ResolutionAction.Rename ? (resolution.RenameTo ?? dto.Name) : dto.Name; - if (localByName.TryGetValue(importedName, out var stub)) + if (localByName.TryGetImported(importedName, out var stub)) targets.Add((dto, stub)); } if (targets.Count == 0) return; @@ -3139,12 +3151,13 @@ public sealed class BundleImporter : IBundleImporter { if (string.IsNullOrEmpty(alarmDto.OnTriggerScriptName)) continue; + // Member names are unique per template case-insensitively (ScadaBridge#100). var alarm = template.Alarms.FirstOrDefault(a => - string.Equals(a.Name, alarmDto.Name, StringComparison.Ordinal)); + string.Equals(a.Name, alarmDto.Name, StringComparison.OrdinalIgnoreCase)); if (alarm is null) continue; var script = template.Scripts.FirstOrDefault(s => - string.Equals(s.Name, alarmDto.OnTriggerScriptName, StringComparison.Ordinal)); + string.Equals(s.Name, alarmDto.OnTriggerScriptName, StringComparison.OrdinalIgnoreCase)); if (script is null) { // Unresolved — emit warning audit row, leave FK null. @@ -3216,7 +3229,7 @@ public sealed class BundleImporter : IBundleImporter ? (resolution.RenameTo ?? dto.Name) : dto.Name; - if (localByName.TryGetValue(importedName, out var parentStub)) + if (localByName.TryGetImported(importedName, out var parentStub)) targets.Add((dto, parentStub)); } if (targets.Count == 0) return; @@ -3340,7 +3353,7 @@ public sealed class BundleImporter : IBundleImporter : dto.Name; // The just-imported/updated template is tracked in the Local set. - if (!localByName.TryGetValue(importedName, out var template)) continue; + if (!localByName.TryGetImported(importedName, out var template)) continue; // A root-template bundle — or an Overwrite that dropped the base — // must clear any stale parent edge on the target row. @@ -3396,20 +3409,46 @@ public sealed class BundleImporter : IBundleImporter /// /// Indexes the change tracker's templates by name (ordinal; the first tracked /// template wins a duplicate, matching the FirstOrDefault scans this - /// replaces). Gitea #47: every DbSet.Local access runs a full + /// replaces). adds a case-insensitive + /// fallback for the template a bundled DTO itself landed on. Gitea #47: every DbSet.Local access runs a full /// DetectChanges over the tracker — every template, member and instance /// the import has staged — so the rewire passes take one snapshot per pass /// instead of one scan per bundled template (and per composition edge), which /// made a large import's apply quadratic in its size. /// - private Dictionary SnapshotLocalTemplatesByName() + private LocalTemplateIndex SnapshotLocalTemplatesByName() => + new(_dbContext.Templates.Local.ToList()); + + /// + /// The tracked-template name index of . + /// + private sealed class LocalTemplateIndex { - var byName = new Dictionary(StringComparer.Ordinal); - foreach (var template in _dbContext.Templates.Local) + private readonly Dictionary _exact = new(StringComparer.Ordinal); + private readonly Dictionary _ignoreCase; + + public LocalTemplateIndex(IReadOnlyList