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