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:
+145
-1
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
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.Services;
|
||||
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.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
@@ -42,7 +44,13 @@ public sealed class ImportNameLookupTests : IDisposable
|
||||
.UseInMemoryDatabase(dbName)
|
||||
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
|
||||
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 =>
|
||||
ImportRoundTripBudgetTests.CountingProxy<IExternalSystemRepository>.Wrap(
|
||||
new ExternalSystemRepository(sp.GetRequiredService<ScadaBridgeDbContext>()), _calls));
|
||||
@@ -330,4 +338,140 @@ public sealed class ImportNameLookupTests : IDisposable
|
||||
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