fix(transport): case-insensitive remaining library lookups; base-only template reads for import targets (ScadaBridge#95)
- Preview matches template folders, shared scripts, external systems, SMTP (by Host) and SMS (by AccountSid) with the #93 ToNameLookup comparer (OrdinalIgnoreCase, first wins, never throws), matching SQL Server's case-insensitive collation, so a differently-cased target row is reported as a conflict instead of New. - Apply-side in-memory lookups do the same: ApplyTemplateFoldersAsync, the template folder-id map, ResolveFolderParentEdgesAsync, ApplySmtpConfigsAsync and ApplySmsConfigsAsync. An Overwrite now updates the existing row instead of adding a duplicate (folder names are only unique per parent, so the old ToDictionary could also throw). - New ITemplateEngineRepository.GetBaseTemplatesWithChildrenAsync filters IsDerived in SQL. Preview and the Overwrite load in ApplyTemplatesAsync use it, so a derived slot template that shares a bundled name is never the import target. GetTemplatesWithChildrenAsync is unchanged for the id-keyed callers (export closure, rewire passes), which can need derived rows. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
+10
@@ -31,6 +31,16 @@ public interface ITemplateEngineRepository
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that resolves to a read-only list of matched templates with children loaded.</returns>
|
||||
Task<IReadOnlyList<Template>> GetTemplatesWithChildrenAsync(IEnumerable<string> names, CancellationToken cancellationToken = default);
|
||||
/// <summary>
|
||||
/// <see cref="GetTemplatesWithChildrenAsync(IEnumerable{string}, CancellationToken)"/> restricted to
|
||||
/// BASE templates (<see cref="Template.IsDerived"/> is false). Derived templates carry their
|
||||
/// composition slot name, so a by-name read meant for the user-authored template the name
|
||||
/// identifies (a bundle import's target) would otherwise also match derived rows (ScadaBridge#95).
|
||||
/// </summary>
|
||||
/// <param name="names">Template names to load. Duplicate / null / empty names are filtered out.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that resolves to the matched base templates with children loaded.</returns>
|
||||
Task<IReadOnlyList<Template>> GetBaseTemplatesWithChildrenAsync(IEnumerable<string> names, CancellationToken cancellationToken = default);
|
||||
/// <summary>Retrieves all templates.</summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that resolves to a read-only list of all templates.</returns>
|
||||
|
||||
+11
-2
@@ -53,8 +53,17 @@ public class TemplateEngineRepository : ITemplateEngineRepository
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Template>> GetTemplatesWithChildrenAsync(
|
||||
public Task<IReadOnlyList<Template>> GetTemplatesWithChildrenAsync(
|
||||
IEnumerable<string> names, CancellationToken cancellationToken = default)
|
||||
=> GetTemplatesWithChildrenAsync(names, baseOnly: false, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<Template>> GetBaseTemplatesWithChildrenAsync(
|
||||
IEnumerable<string> names, CancellationToken cancellationToken = default)
|
||||
=> GetTemplatesWithChildrenAsync(names, baseOnly: true, cancellationToken);
|
||||
|
||||
private async Task<IReadOnlyList<Template>> GetTemplatesWithChildrenAsync(
|
||||
IEnumerable<string> names, bool baseOnly, CancellationToken cancellationToken)
|
||||
{
|
||||
// Bulk lookup replaces the per-name N+1 in
|
||||
// BundleImporter.PreviewAsync. Filter out null / empty / duplicate
|
||||
@@ -67,7 +76,7 @@ public class TemplateEngineRepository : ITemplateEngineRepository
|
||||
if (distinct.Length == 0) return Array.Empty<Template>();
|
||||
|
||||
return await _context.Templates
|
||||
.Where(t => distinct.Contains(t.Name))
|
||||
.Where(t => distinct.Contains(t.Name) && (!baseOnly || !t.IsDerived))
|
||||
.Include(t => t.Attributes)
|
||||
.Include(t => t.Alarms)
|
||||
.Include(t => t.Scripts)
|
||||
|
||||
@@ -465,7 +465,11 @@ public sealed class BundleImporter : IBundleImporter
|
||||
|
||||
// ---- TemplateFolders ----
|
||||
var allFolders = await _templateRepo.GetAllFoldersAsync(ct).ConfigureAwait(false);
|
||||
var folderByName = allFolders.ToDictionary(f => f.Name, f => f, StringComparer.Ordinal);
|
||||
// Every library lookup below matches the way SQL Server's case-insensitive
|
||||
// collation (and the unique indexes it enforces) does, so a target row that differs
|
||||
// only in case is reported as the conflict it will be on apply, not as New
|
||||
// (ScadaBridge#93, #95).
|
||||
var folderByName = ToNameLookup(allFolders, f => f.Name);
|
||||
var folderNameById = allFolders.ToDictionary(f => f.Id, f => f.Name);
|
||||
foreach (var fDto in content.TemplateFolders)
|
||||
{
|
||||
@@ -479,8 +483,10 @@ public sealed class BundleImporter : IBundleImporter
|
||||
// name (classic N+1). The bulk variant fetches every matching template
|
||||
// with children eager-loaded in a single round-trip.
|
||||
var bundleTemplateNames = content.Templates.Select(t => t.Name);
|
||||
// Base templates only: a derived template carries its composition slot name and is
|
||||
// never what a bundled template of that name targets (ScadaBridge#95).
|
||||
var hydratedTemplates = await _templateRepo
|
||||
.GetTemplatesWithChildrenAsync(bundleTemplateNames, ct)
|
||||
.GetBaseTemplatesWithChildrenAsync(bundleTemplateNames, ct)
|
||||
.ConfigureAwait(false);
|
||||
var hydratedByName = ToTemplateLookup(hydratedTemplates);
|
||||
var templateNameById = await library.GetNameByIdAsync(ct).ConfigureAwait(false);
|
||||
@@ -494,9 +500,7 @@ public sealed class BundleImporter : IBundleImporter
|
||||
// One read of each library (04/P4); DetectBlockersAsync reuses the same lists
|
||||
// for its known-resolvable name sets instead of reading them again.
|
||||
var allSharedScripts = await _templateRepo.GetAllSharedScriptsAsync(ct).ConfigureAwait(false);
|
||||
var sharedScriptByName = allSharedScripts
|
||||
.GroupBy(x => x.Name, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
|
||||
var sharedScriptByName = ToNameLookup(allSharedScripts, x => x.Name);
|
||||
foreach (var s in content.SharedScripts)
|
||||
{
|
||||
sharedScriptByName.TryGetValue(s.Name, out var existing);
|
||||
@@ -505,9 +509,7 @@ public sealed class BundleImporter : IBundleImporter
|
||||
|
||||
// ---- ExternalSystems (+ their methods) ----
|
||||
var allExternalSystems = await _externalRepo.GetAllExternalSystemsAsync(ct).ConfigureAwait(false);
|
||||
var externalSystemByName = allExternalSystems
|
||||
.GroupBy(x => x.Name, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
|
||||
var externalSystemByName = ToNameLookup(allExternalSystems, x => x.Name);
|
||||
foreach (var es in content.ExternalSystems)
|
||||
{
|
||||
externalSystemByName.TryGetValue(es.Name, out var existing);
|
||||
@@ -543,7 +545,7 @@ public sealed class BundleImporter : IBundleImporter
|
||||
|
||||
// ---- SmtpConfigurations (no by-host lookup — scan GetAll) ----
|
||||
var allSmtp = await _notificationRepo.GetAllSmtpConfigurationsAsync(ct).ConfigureAwait(false);
|
||||
var smtpByHost = allSmtp.ToDictionary(s => s.Host, s => s, StringComparer.Ordinal);
|
||||
var smtpByHost = ToNameLookup(allSmtp, s => s.Host);
|
||||
foreach (var sm in content.SmtpConfigs)
|
||||
{
|
||||
smtpByHost.TryGetValue(sm.Host, out var existing);
|
||||
@@ -552,7 +554,7 @@ public sealed class BundleImporter : IBundleImporter
|
||||
|
||||
// ---- SmsConfigurations (no by-AccountSid lookup — scan GetAll) ----
|
||||
var allSms = await _notificationRepo.GetAllSmsConfigurationsAsync(ct).ConfigureAwait(false);
|
||||
var smsBySid = allSms.ToDictionary(s => s.AccountSid, s => s, StringComparer.Ordinal);
|
||||
var smsBySid = ToNameLookup(allSms, s => s.AccountSid);
|
||||
foreach (var sms in content.SmsConfigs)
|
||||
{
|
||||
smsBySid.TryGetValue(sms.AccountSid, out var existing);
|
||||
@@ -2281,7 +2283,7 @@ public sealed class BundleImporter : IBundleImporter
|
||||
{
|
||||
if (dtos.Count == 0) return;
|
||||
var existing = await _templateRepo.GetAllFoldersAsync(ct).ConfigureAwait(false);
|
||||
var byName = existing.ToDictionary(f => f.Name, f => f, StringComparer.Ordinal);
|
||||
var byName = ToNameLookup(existing, f => f.Name);
|
||||
|
||||
foreach (var dto in dtos)
|
||||
{
|
||||
@@ -2366,9 +2368,11 @@ public sealed class BundleImporter : IBundleImporter
|
||||
.ToList();
|
||||
// The by-name query runs under SQL Server's case-insensitive collation, so the
|
||||
// in-memory lookup must match case-insensitively too (ScadaBridge#93).
|
||||
// Base templates only (ScadaBridge#95): an Overwrite must never land on a derived row
|
||||
// that happens to carry the bundled template's name.
|
||||
var byName = ToTemplateLookup(overwriteNames.Count == 0
|
||||
? Array.Empty<Template>()
|
||||
: await _templateRepo.GetTemplatesWithChildrenAsync(overwriteNames, ct).ConfigureAwait(false));
|
||||
: await _templateRepo.GetBaseTemplatesWithChildrenAsync(overwriteNames, ct).ConfigureAwait(false));
|
||||
|
||||
// Folder ids must be materialised before we wire templates' FolderId
|
||||
// FKs. ApplyTemplateFoldersAsync staged the rows via AddFolderAsync
|
||||
@@ -2379,7 +2383,8 @@ public sealed class BundleImporter : IBundleImporter
|
||||
// unit. The cost is one extra round-trip per import, negligible.
|
||||
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||
var folders = await _templateRepo.GetAllFoldersAsync(ct).ConfigureAwait(false);
|
||||
var folderIdByName = folders.ToDictionary(f => f.Name, f => f.Id, StringComparer.Ordinal);
|
||||
var folderIdByName = ToNameLookup(folders, f => f.Name).ToDictionary(
|
||||
kv => kv.Key, kv => kv.Value.Id, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Honour folder renames -- a TemplateFolder resolved as Rename was
|
||||
// written under its new name; templates that reference the old name in
|
||||
@@ -3816,7 +3821,7 @@ public sealed class BundleImporter : IBundleImporter
|
||||
{
|
||||
if (dtos.Count == 0) return;
|
||||
var allFolders = await _templateRepo.GetAllFoldersAsync(ct).ConfigureAwait(false);
|
||||
var byName = allFolders.ToDictionary(f => f.Name, f => f, StringComparer.Ordinal);
|
||||
var byName = ToNameLookup(allFolders, f => f.Name);
|
||||
|
||||
foreach (var dto in dtos)
|
||||
{
|
||||
@@ -4066,7 +4071,7 @@ public sealed class BundleImporter : IBundleImporter
|
||||
{
|
||||
if (dtos.Count == 0) return;
|
||||
var all = await _notificationRepo.GetAllSmtpConfigurationsAsync(ct).ConfigureAwait(false);
|
||||
var byHost = all.ToDictionary(s => s.Host, s => s, StringComparer.Ordinal);
|
||||
var byHost = ToNameLookup(all, s => s.Host);
|
||||
|
||||
foreach (var dto in dtos)
|
||||
{
|
||||
@@ -4152,7 +4157,7 @@ public sealed class BundleImporter : IBundleImporter
|
||||
{
|
||||
if (dtos.Count == 0) return;
|
||||
var all = await _notificationRepo.GetAllSmsConfigurationsAsync(ct).ConfigureAwait(false);
|
||||
var bySid = all.ToDictionary(s => s.AccountSid, s => s, StringComparer.Ordinal);
|
||||
var bySid = ToNameLookup(all, s => s.AccountSid);
|
||||
|
||||
foreach (var dto in dtos)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user