fix(transport): case-insensitive name lookups, batch preview lookups, correct flattener doc (ScadaBridge#93)

- ApplyTemplatesAsync/PreviewAsync template lookups use a case-insensitive,
  never-throwing comparer (SQL Server collation); base template wins over derived.
  Base names are already unique case-insensitively via the filtered unique index
  on Template.Name, derived templates are not covered by it.
- Preview reads database connections, notification lists and API methods with one
  GetAll query each instead of one by-name query per bundled row.
- Component-TemplateEngine.md: the flattener does not call GetAllTemplatesAsync;
  it loads chains via GetTemplateWithChildrenAsync.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 13:46:45 -05:00
parent bec07378cc
commit c7d868c52e
3 changed files with 223 additions and 10 deletions
@@ -482,8 +482,7 @@ public sealed class BundleImporter : IBundleImporter
var hydratedTemplates = await _templateRepo
.GetTemplatesWithChildrenAsync(bundleTemplateNames, ct)
.ConfigureAwait(false);
var hydratedByName = hydratedTemplates
.ToDictionary(t => t.Name, t => t, StringComparer.Ordinal);
var hydratedByName = ToTemplateLookup(hydratedTemplates);
var templateNameById = await library.GetNameByIdAsync(ct).ConfigureAwait(false);
foreach (var tDto in content.Templates)
{
@@ -521,16 +520,24 @@ public sealed class BundleImporter : IBundleImporter
}
// ---- DatabaseConnections ----
// One read of the library instead of one by-name query per bundled row
// (ScadaBridge#93); matched case-insensitively like the SQL Server collation.
var dbConnectionByName = content.DatabaseConnections.Count == 0
? ToNameLookup(Array.Empty<DatabaseConnectionDefinition>(), x => x.Name)
: ToNameLookup(await _externalRepo.GetAllDatabaseConnectionsAsync(ct).ConfigureAwait(false), x => x.Name);
foreach (var db in content.DatabaseConnections)
{
var existing = await _externalRepo.GetDatabaseConnectionByNameAsync(db.Name, ct).ConfigureAwait(false);
dbConnectionByName.TryGetValue(db.Name, out var existing);
items.Add(_diff.CompareDatabaseConnection(db, existing));
}
// ---- NotificationLists ----
var notificationListByName = content.NotificationLists.Count == 0
? ToNameLookup(Array.Empty<NotificationList>(), x => x.Name)
: ToNameLookup(await _notificationRepo.GetAllNotificationListsAsync(ct).ConfigureAwait(false), x => x.Name);
foreach (var nl in content.NotificationLists)
{
var existing = await _notificationRepo.GetListByNameAsync(nl.Name, ct).ConfigureAwait(false);
notificationListByName.TryGetValue(nl.Name, out var existing);
items.Add(_diff.CompareNotificationList(nl, existing));
}
@@ -560,9 +567,12 @@ public sealed class BundleImporter : IBundleImporter
// from a hash). They are counted, ignored, and reported at apply time.
// ---- ApiMethods ----
var apiMethodByName = content.ApiMethods.Count == 0
? ToNameLookup(Array.Empty<ApiMethod>(), x => x.Name)
: ToNameLookup(await _inboundApiRepo.GetAllApiMethodsAsync(ct).ConfigureAwait(false), x => x.Name);
foreach (var m in content.ApiMethods)
{
var existing = await _inboundApiRepo.GetMethodByNameAsync(m.Name, ct).ConfigureAwait(false);
apiMethodByName.TryGetValue(m.Name, out var existing);
items.Add(_diff.CompareApiMethod(m, existing));
}
@@ -2313,6 +2323,32 @@ public sealed class BundleImporter : IBundleImporter
}
}
/// <summary>
/// Name lookup that matches the way SQL Server's default case-insensitive
/// collation matches (<see cref="StringComparer.OrdinalIgnoreCase"/>) and can never
/// throw on a duplicate key: the first row for a name wins. Used wherever a
/// by-name DB result is re-keyed in memory (ScadaBridge#93).
/// </summary>
internal static Dictionary<string, T> ToNameLookup<T>(IEnumerable<T> rows, Func<T, string> name)
{
var lookup = new Dictionary<string, T>(StringComparer.OrdinalIgnoreCase);
foreach (var row in rows)
{
lookup.TryAdd(name(row), row);
}
return lookup;
}
/// <summary>
/// <see cref="ToNameLookup{T}"/> for templates. Base templates are unique by name
/// (case-insensitively) through the filtered unique index on <c>Name</c>, but
/// derived templates hold their composition slot name and are NOT in that index,
/// so two rows can differ only in case. A base (user-authored) template wins over
/// a derived one, which is the row an import actually targets.
/// </summary>
internal static Dictionary<string, Template> ToTemplateLookup(IEnumerable<Template> rows) =>
ToNameLookup(rows.OrderBy(t => t.IsDerived), t => t.Name);
private async Task ApplyTemplatesAsync(
IReadOnlyList<TemplateDto> dtos,
Dictionary<(string, string), ImportResolution> map,
@@ -2328,10 +2364,11 @@ public sealed class BundleImporter : IBundleImporter
.Where(d => ResolveOrDefault(map, "Template", d.Name).Action == ResolutionAction.Overwrite)
.Select(d => d.Name)
.ToList();
var byName = (overwriteNames.Count == 0
? Array.Empty<Template>()
: await _templateRepo.GetTemplatesWithChildrenAsync(overwriteNames, ct).ConfigureAwait(false))
.ToDictionary(t => t.Name, t => t, StringComparer.Ordinal);
// The by-name query runs under SQL Server's case-insensitive collation, so the
// in-memory lookup must match case-insensitively too (ScadaBridge#93).
var byName = ToTemplateLookup(overwriteNames.Count == 0
? Array.Empty<Template>()
: await _templateRepo.GetTemplatesWithChildrenAsync(overwriteNames, ct).ConfigureAwait(false));
// Folder ids must be materialised before we wire templates' FolderId
// FKs. ApplyTemplateFoldersAsync staged the rows via AddFolderAsync