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
This commit is contained in:
@@ -253,7 +253,8 @@ public sealed class ArtifactDiff
|
|||||||
// number added/changed on an existing recipient is a real diff.
|
// number added/changed on an existing recipient is a real diff.
|
||||||
(e, i) => e.EmailAddress == i.EmailAddress && e.PhoneNumber == i.PhoneNumber,
|
(e, i) => e.EmailAddress == i.EmailAddress && e.PhoneNumber == i.PhoneNumber,
|
||||||
"Recipients",
|
"Recipients",
|
||||||
changes);
|
changes,
|
||||||
|
ignoreCase: false);
|
||||||
|
|
||||||
return BuildItem("NotificationList", incoming.Name, changes);
|
return BuildItem("NotificationList", incoming.Name, changes);
|
||||||
}
|
}
|
||||||
@@ -622,12 +623,19 @@ public sealed class ArtifactDiff
|
|||||||
Func<TIncoming, string> incomingKey,
|
Func<TIncoming, string> incomingKey,
|
||||||
Func<TExisting, TIncoming, bool> equal,
|
Func<TExisting, TIncoming, bool> equal,
|
||||||
string childCategory,
|
string childCategory,
|
||||||
List<FieldChange> changes)
|
List<FieldChange> changes,
|
||||||
|
bool ignoreCase = true)
|
||||||
{
|
{
|
||||||
var existingByName = existing.GroupBy(existingKey, StringComparer.Ordinal)
|
// Child names are unique per parent case-insensitively (the DB unique indexes
|
||||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
|
// under SQL Server's collation), and the Overwrite sync matches them that way, so
|
||||||
var incomingByName = incoming.GroupBy(incomingKey, StringComparer.Ordinal)
|
// the diff does too: a case-only difference is the same row (ScadaBridge#100).
|
||||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
|
// 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 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();
|
var removed = existingByName.Keys.Where(k => !incomingByName.ContainsKey(k)).OrderBy(k => k, StringComparer.Ordinal).ToList();
|
||||||
@@ -659,10 +667,9 @@ public sealed class ArtifactDiff
|
|||||||
IEnumerable<TemplateScriptDto> incoming,
|
IEnumerable<TemplateScriptDto> incoming,
|
||||||
List<FieldChange> changes)
|
List<FieldChange> changes)
|
||||||
{
|
{
|
||||||
var existingByName = existing.GroupBy(s => s.Name, StringComparer.Ordinal)
|
// Case-insensitive, as DiffChildren (ScadaBridge#100).
|
||||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
|
var existingByName = BundleImporter.ToNameLookup(existing, s => s.Name);
|
||||||
var incomingByName = incoming.GroupBy(s => s.Name, StringComparer.Ordinal)
|
var incomingByName = BundleImporter.ToNameLookup(incoming, s => s.Name);
|
||||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
|
|
||||||
|
|
||||||
foreach (var name in incomingByName.Keys.Where(k => !existingByName.ContainsKey(k)).OrderBy(k => k, StringComparer.Ordinal))
|
foreach (var name in incomingByName.Keys.Where(k => !existingByName.ContainsKey(k)).OrderBy(k => k, StringComparer.Ordinal))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2639,8 +2639,11 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
string user,
|
string user,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var existingByName = ex.Attributes.ToDictionary(a => a.Name, a => a, StringComparer.Ordinal);
|
// Matched case-insensitively, like the target's unique index on (TemplateId, Name)
|
||||||
var dtoByName = dto.Attributes.ToDictionary(a => a.Name, a => a, StringComparer.Ordinal);
|
// 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.
|
// Deletes — attributes present on the target but not in the bundle.
|
||||||
foreach (var existing in existingByName.Values.ToList())
|
foreach (var existing in existingByName.Values.ToList())
|
||||||
@@ -2756,8 +2759,11 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
string user,
|
string user,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var existingByName = ex.Alarms.ToDictionary(a => a.Name, a => a, StringComparer.Ordinal);
|
// Matched case-insensitively, like the target's unique index on (TemplateId, Name)
|
||||||
var dtoByName = dto.Alarms.ToDictionary(a => a.Name, a => a, StringComparer.Ordinal);
|
// 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
|
// 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
|
// 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,
|
string user,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var existingByName = ex.Scripts.ToDictionary(s => s.Name, s => s, StringComparer.Ordinal);
|
// Matched case-insensitively, like the target's unique index on (TemplateId, Name)
|
||||||
var dtoByName = dto.Scripts.ToDictionary(s => s.Name, s => s, StringComparer.Ordinal);
|
// 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())
|
foreach (var existing in existingByName.Values.ToList())
|
||||||
{
|
{
|
||||||
@@ -2989,8 +2998,11 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
string user,
|
string user,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var existingByName = ex.NativeAlarmSources.ToDictionary(s => s.Name, s => s, StringComparer.Ordinal);
|
// Matched case-insensitively, like the target's unique index on (TemplateId, Name)
|
||||||
var dtoByName = dto.NativeAlarmSources.ToDictionary(s => s.Name, s => s, StringComparer.Ordinal);
|
// 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.
|
// Deletes — sources present on the target but not in the bundle.
|
||||||
foreach (var existing in existingByName.Values.ToList())
|
foreach (var existing in existingByName.Values.ToList())
|
||||||
@@ -3121,7 +3133,7 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
var importedName = resolution.Action == ResolutionAction.Rename
|
var importedName = resolution.Action == ResolutionAction.Rename
|
||||||
? (resolution.RenameTo ?? dto.Name)
|
? (resolution.RenameTo ?? dto.Name)
|
||||||
: dto.Name;
|
: dto.Name;
|
||||||
if (localByName.TryGetValue(importedName, out var stub))
|
if (localByName.TryGetImported(importedName, out var stub))
|
||||||
targets.Add((dto, stub));
|
targets.Add((dto, stub));
|
||||||
}
|
}
|
||||||
if (targets.Count == 0) return;
|
if (targets.Count == 0) return;
|
||||||
@@ -3139,12 +3151,13 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(alarmDto.OnTriggerScriptName)) continue;
|
if (string.IsNullOrEmpty(alarmDto.OnTriggerScriptName)) continue;
|
||||||
|
|
||||||
|
// Member names are unique per template case-insensitively (ScadaBridge#100).
|
||||||
var alarm = template.Alarms.FirstOrDefault(a =>
|
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;
|
if (alarm is null) continue;
|
||||||
|
|
||||||
var script = template.Scripts.FirstOrDefault(s =>
|
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)
|
if (script is null)
|
||||||
{
|
{
|
||||||
// Unresolved — emit warning audit row, leave FK null.
|
// Unresolved — emit warning audit row, leave FK null.
|
||||||
@@ -3216,7 +3229,7 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
? (resolution.RenameTo ?? dto.Name)
|
? (resolution.RenameTo ?? dto.Name)
|
||||||
: dto.Name;
|
: dto.Name;
|
||||||
|
|
||||||
if (localByName.TryGetValue(importedName, out var parentStub))
|
if (localByName.TryGetImported(importedName, out var parentStub))
|
||||||
targets.Add((dto, parentStub));
|
targets.Add((dto, parentStub));
|
||||||
}
|
}
|
||||||
if (targets.Count == 0) return;
|
if (targets.Count == 0) return;
|
||||||
@@ -3340,7 +3353,7 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
: dto.Name;
|
: dto.Name;
|
||||||
|
|
||||||
// The just-imported/updated template is tracked in the Local set.
|
// 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 —
|
// A root-template bundle — or an Overwrite that dropped the base —
|
||||||
// must clear any stale parent edge on the target row.
|
// must clear any stale parent edge on the target row.
|
||||||
@@ -3396,20 +3409,46 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Indexes the change tracker's templates by name (ordinal; the first tracked
|
/// Indexes the change tracker's templates by name (ordinal; the first tracked
|
||||||
/// template wins a duplicate, matching the <c>FirstOrDefault</c> scans this
|
/// template wins a duplicate, matching the <c>FirstOrDefault</c> scans this
|
||||||
/// replaces). Gitea #47: every <c>DbSet.Local</c> access runs a full
|
/// replaces). <see cref="LocalTemplateIndex.TryGetImported"/> adds a case-insensitive
|
||||||
|
/// fallback for the template a bundled DTO itself landed on. Gitea #47: every <c>DbSet.Local</c> access runs a full
|
||||||
/// <c>DetectChanges</c> over the tracker — every template, member and instance
|
/// <c>DetectChanges</c> over the tracker — every template, member and instance
|
||||||
/// the import has staged — so the rewire passes take one snapshot per pass
|
/// 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
|
/// instead of one scan per bundled template (and per composition edge), which
|
||||||
/// made a large import's apply quadratic in its size.
|
/// made a large import's apply quadratic in its size.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private Dictionary<string, Template> SnapshotLocalTemplatesByName()
|
private LocalTemplateIndex SnapshotLocalTemplatesByName() =>
|
||||||
|
new(_dbContext.Templates.Local.ToList());
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The tracked-template name index of <see cref="SnapshotLocalTemplatesByName"/>.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class LocalTemplateIndex
|
||||||
{
|
{
|
||||||
var byName = new Dictionary<string, Template>(StringComparer.Ordinal);
|
private readonly Dictionary<string, Template> _exact = new(StringComparer.Ordinal);
|
||||||
foreach (var template in _dbContext.Templates.Local)
|
private readonly Dictionary<string, Template> _ignoreCase;
|
||||||
|
|
||||||
|
public LocalTemplateIndex(IReadOnlyList<Template> tracked)
|
||||||
{
|
{
|
||||||
byName.TryAdd(template.Name, template);
|
foreach (var template in tracked)
|
||||||
|
{
|
||||||
|
_exact.TryAdd(template.Name, template);
|
||||||
}
|
}
|
||||||
return byName;
|
_ignoreCase = ToTemplateLookup(tracked);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Exact-name lookup (composed / base template references).</summary>
|
||||||
|
public bool TryGetValue(string name, out Template template) =>
|
||||||
|
_exact.TryGetValue(name, out template!);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up the template a bundled DTO was imported onto. An Overwrite matches a
|
||||||
|
/// differently-cased target (ScadaBridge#93) and keeps the target's spelling, so an
|
||||||
|
/// exact lookup missed it and the rewire passes silently skipped the template —
|
||||||
|
/// dropping the alarm on-trigger links the child sync had just cleared
|
||||||
|
/// (ScadaBridge#100). Exact match first, then case-insensitive, base template first.
|
||||||
|
/// </summary>
|
||||||
|
public bool TryGetImported(string name, out Template template) =>
|
||||||
|
_exact.TryGetValue(name, out template!) || _ignoreCase.TryGetValue(name, out template!);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -3728,8 +3767,10 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
var existingMethods = await _externalRepo
|
var existingMethods = await _externalRepo
|
||||||
.GetMethodsByExternalSystemIdAsync(ex.Id, ct)
|
.GetMethodsByExternalSystemIdAsync(ex.Id, ct)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
var existingByName = existingMethods.ToDictionary(m => m.Name, m => m, StringComparer.Ordinal);
|
// Case-insensitive, like the (ExternalSystemDefinitionId, Name) unique index
|
||||||
var dtoByName = dto.Methods.ToDictionary(m => m.Name, m => m, StringComparer.Ordinal);
|
// (ScadaBridge#100).
|
||||||
|
var existingByName = ToNameLookup(existingMethods, m => m.Name);
|
||||||
|
var dtoByName = ToNameLookup(dto.Methods, m => m.Name);
|
||||||
|
|
||||||
// Deletes — methods present on the target but not in the bundle.
|
// Deletes — methods present on the target but not in the bundle.
|
||||||
foreach (var existing in existingByName.Values.ToList())
|
foreach (var existing in existingByName.Values.ToList())
|
||||||
@@ -3889,7 +3930,7 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
if (sys is null) continue;
|
if (sys is null) continue;
|
||||||
|
|
||||||
var existing = await _externalRepo.GetMethodsByExternalSystemIdAsync(sys.Id, ct).ConfigureAwait(false);
|
var existing = await _externalRepo.GetMethodsByExternalSystemIdAsync(sys.Id, ct).ConfigureAwait(false);
|
||||||
var existingNames = existing.Select(m => m.Name).ToHashSet(StringComparer.Ordinal);
|
var existingNames = existing.Select(m => m.Name).ToHashSet(StringComparer.OrdinalIgnoreCase); // ScadaBridge#100
|
||||||
|
|
||||||
foreach (var methodDto in dto.Methods)
|
foreach (var methodDto in dto.Methods)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -62,7 +62,9 @@ internal static class TemplateChildEquality
|
|||||||
&& e.IsLocked == i.IsLocked
|
&& e.IsLocked == i.IsLocked
|
||||||
&& e.LockedInDerived == i.LockedInDerived
|
&& e.LockedInDerived == i.LockedInDerived
|
||||||
&& e.IsMesReportable == i.IsMesReportable
|
&& e.IsMesReportable == i.IsMesReportable
|
||||||
&& scriptNameById(e.OnTriggerScriptId) == i.OnTriggerScriptName;
|
// The bundle names the script; the apply resolves it case-insensitively
|
||||||
|
// (script names are unique per template that way, ScadaBridge#100).
|
||||||
|
&& string.Equals(scriptNameById(e.OnTriggerScriptId), i.OnTriggerScriptName, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// True when the persisted script equals its bundle DTO across every writable
|
/// True when the persisted script equals its bundle DTO across every writable
|
||||||
|
|||||||
+145
-1
@@ -1,4 +1,5 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
|
using System.Reflection;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
@@ -11,6 +12,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
|||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||||
@@ -42,7 +44,13 @@ public sealed class ImportNameLookupTests : IDisposable
|
|||||||
.UseInMemoryDatabase(dbName)
|
.UseInMemoryDatabase(dbName)
|
||||||
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
|
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
|
||||||
services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
|
services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
|
||||||
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
// The in-memory provider compares strings ordinally; SQL Server's collation does
|
||||||
|
// not. The proxy gives the by-name template reads the server's case-insensitive
|
||||||
|
// matching so a re-cased target behaves as it would in production.
|
||||||
|
services.AddScoped<ITemplateEngineRepository>(sp => CollationProxy.Wrap(
|
||||||
|
new TemplateEngineRepository(
|
||||||
|
sp.GetRequiredService<ScadaBridgeDbContext>(), sp.GetRequiredService<ITemplateGraphWatermark>()),
|
||||||
|
sp.GetRequiredService<ScadaBridgeDbContext>()));
|
||||||
services.AddScoped<IExternalSystemRepository>(sp =>
|
services.AddScoped<IExternalSystemRepository>(sp =>
|
||||||
ImportRoundTripBudgetTests.CountingProxy<IExternalSystemRepository>.Wrap(
|
ImportRoundTripBudgetTests.CountingProxy<IExternalSystemRepository>.Wrap(
|
||||||
new ExternalSystemRepository(sp.GetRequiredService<ScadaBridgeDbContext>()), _calls));
|
new ExternalSystemRepository(sp.GetRequiredService<ScadaBridgeDbContext>()), _calls));
|
||||||
@@ -330,4 +338,140 @@ public sealed class ImportNameLookupTests : IDisposable
|
|||||||
Assert.Single(motors, t => !t.IsDerived);
|
Assert.Single(motors, t => !t.IsDerived);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// ---- ScadaBridge#100: names inside a record ---------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Template members (attributes, alarms, scripts, native alarm sources) and external
|
||||||
|
/// system methods are unique per parent case-insensitively under SQL Server's
|
||||||
|
/// collation. A target whose member names differ from the bundle's only in case holds
|
||||||
|
/// the SAME members: preview must report them unchanged, and an Overwrite must keep
|
||||||
|
/// the rows (same ids, no delete + re-add) and the alarm's on-trigger script link —
|
||||||
|
/// also when the template name itself differs only in case.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task Members_inside_a_record_match_case_insensitively()
|
||||||
|
{
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
|
var template = new Template("Pump");
|
||||||
|
template.Attributes.Add(new TemplateAttribute("Speed") { Value = "1", DataType = DataType.Int32 });
|
||||||
|
template.Scripts.Add(new TemplateScript("OnTrip", "return 1;"));
|
||||||
|
template.Alarms.Add(new TemplateAlarm("HighTemp") { PriorityLevel = 1 });
|
||||||
|
template.NativeAlarmSources.Add(new TemplateNativeAlarmSource("Boiler") { ConnectionName = "c", SourceReference = "r" });
|
||||||
|
ctx.Templates.Add(template);
|
||||||
|
var erp = new ExternalSystemDefinition("Erp", "http://erp", "None");
|
||||||
|
ctx.ExternalSystemDefinitions.Add(erp);
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
template.Alarms.Single().OnTriggerScriptId = template.Scripts.Single().Id;
|
||||||
|
ctx.ExternalSystemMethods.Add(new ExternalSystemMethod("GetOrder", "GET", "/orders") { ExternalSystemDefinitionId = erp.Id });
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var sessionId = await ExportAndLoadAsync(async ctx => new ExportSelection(
|
||||||
|
TemplateIds: await ctx.Templates.Select(x => x.Id).ToListAsync(),
|
||||||
|
SharedScriptIds: Array.Empty<int>(),
|
||||||
|
ExternalSystemIds: await ctx.ExternalSystemDefinitions.Select(x => x.Id).ToListAsync(),
|
||||||
|
DatabaseConnectionIds: Array.Empty<int>(),
|
||||||
|
NotificationListIds: Array.Empty<int>(),
|
||||||
|
SmtpConfigurationIds: Array.Empty<int>(),
|
||||||
|
ApiMethodIds: Array.Empty<int>(),
|
||||||
|
IncludeDependencies: false));
|
||||||
|
|
||||||
|
// Re-case every name on the target; ids are what an in-place update must keep.
|
||||||
|
int[] memberIds;
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
|
(await ctx.Templates.SingleAsync()).Name = "PUMP";
|
||||||
|
var attribute = await ctx.TemplateAttributes.SingleAsync();
|
||||||
|
var script = await ctx.TemplateScripts.SingleAsync();
|
||||||
|
var alarm = await ctx.TemplateAlarms.SingleAsync();
|
||||||
|
var source = await ctx.TemplateNativeAlarmSources.SingleAsync();
|
||||||
|
var method = await ctx.ExternalSystemMethods.SingleAsync();
|
||||||
|
attribute.Name = "SPEED";
|
||||||
|
script.Name = "ontrip";
|
||||||
|
alarm.Name = "hightemp";
|
||||||
|
source.Name = "BOILER";
|
||||||
|
method.Name = "getorder";
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
memberIds = [attribute.Id, script.Id, alarm.Id, source.Id, method.Id];
|
||||||
|
}
|
||||||
|
|
||||||
|
var preview = await PreviewAsync(sessionId);
|
||||||
|
Assert.Equal(ConflictKind.Identical, Assert.Single(preview.Items, i => i.EntityType == "Template").Kind);
|
||||||
|
Assert.Equal(ConflictKind.Identical, Assert.Single(preview.Items, i => i.EntityType == "ExternalSystem").Kind);
|
||||||
|
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
await scope.ServiceProvider.GetRequiredService<IBundleImporter>().ApplyAsync(
|
||||||
|
sessionId,
|
||||||
|
[
|
||||||
|
new ImportResolution("Template", "Pump", ResolutionAction.Overwrite, null),
|
||||||
|
new ImportResolution("ExternalSystem", "Erp", ResolutionAction.Overwrite, null),
|
||||||
|
],
|
||||||
|
user: "bob");
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
|
var attribute = await ctx.TemplateAttributes.SingleAsync();
|
||||||
|
var script = await ctx.TemplateScripts.SingleAsync();
|
||||||
|
var alarm = await ctx.TemplateAlarms.SingleAsync();
|
||||||
|
var source = await ctx.TemplateNativeAlarmSources.SingleAsync();
|
||||||
|
var method = await ctx.ExternalSystemMethods.SingleAsync();
|
||||||
|
Assert.Equal(memberIds, new[] { attribute.Id, script.Id, alarm.Id, source.Id, method.Id });
|
||||||
|
Assert.Equal(script.Id, alarm.OnTriggerScriptId);
|
||||||
|
Assert.DoesNotContain(await ctx.AuditLogEntries.Select(e => e.Action).ToListAsync(),
|
||||||
|
a => a.EndsWith("Deleted", StringComparison.Ordinal) || a.EndsWith("Added", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gives <see cref="ITemplateEngineRepository"/>'s by-name template reads SQL Server's
|
||||||
|
/// case-insensitive name matching over the in-memory provider: the requested names are
|
||||||
|
/// replaced by every stored template name equal to one of them ignoring case.
|
||||||
|
/// </summary>
|
||||||
|
public class CollationProxy : DispatchProxy
|
||||||
|
{
|
||||||
|
private ITemplateEngineRepository _inner = null!;
|
||||||
|
private ScadaBridgeDbContext _context = null!;
|
||||||
|
|
||||||
|
/// <summary>Creates the proxy over <paramref name="inner"/>.</summary>
|
||||||
|
/// <param name="inner">The real repository.</param>
|
||||||
|
/// <param name="context">The context the repository reads.</param>
|
||||||
|
/// <returns>The proxy.</returns>
|
||||||
|
public static ITemplateEngineRepository Wrap(ITemplateEngineRepository inner, ScadaBridgeDbContext context)
|
||||||
|
{
|
||||||
|
var proxy = Create<ITemplateEngineRepository, CollationProxy>();
|
||||||
|
var self = (CollationProxy)(object)proxy;
|
||||||
|
self._inner = inner;
|
||||||
|
self._context = context;
|
||||||
|
return proxy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override object? Invoke(MethodInfo? targetMethod, object?[]? args)
|
||||||
|
{
|
||||||
|
if (targetMethod!.Name is nameof(ITemplateEngineRepository.GetTemplatesWithChildrenAsync)
|
||||||
|
or nameof(ITemplateEngineRepository.GetBaseTemplatesWithChildrenAsync)
|
||||||
|
&& args![0] is IEnumerable<string> names)
|
||||||
|
{
|
||||||
|
var wanted = names.Where(n => n is not null).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
args[0] = _context.Templates.AsNoTracking().Select(t => t.Name).AsEnumerable()
|
||||||
|
.Where(wanted.Contains).Distinct(StringComparer.Ordinal).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return targetMethod.Invoke(_inner, args);
|
||||||
|
}
|
||||||
|
catch (TargetInvocationException ex) when (ex.InnerException is not null)
|
||||||
|
{
|
||||||
|
throw ex.InnerException;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user