diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ITemplateEngineRepository.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ITemplateEngineRepository.cs
index 091f5396..c78677e6 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ITemplateEngineRepository.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ITemplateEngineRepository.cs
@@ -31,6 +31,16 @@ public interface ITemplateEngineRepository
/// Cancellation token.
/// A task that resolves to a read-only list of matched templates with children loaded.
Task> GetTemplatesWithChildrenAsync(IEnumerable names, CancellationToken cancellationToken = default);
+ ///
+ /// restricted to
+ /// BASE templates ( 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).
+ ///
+ /// Template names to load. Duplicate / null / empty names are filtered out.
+ /// Cancellation token.
+ /// A task that resolves to the matched base templates with children loaded.
+ Task> GetBaseTemplatesWithChildrenAsync(IEnumerable names, CancellationToken cancellationToken = default);
/// Retrieves all templates.
/// Cancellation token.
/// A task that resolves to a read-only list of all templates.
diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs
index ba088f47..5bef7c5a 100644
--- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs
@@ -53,8 +53,17 @@ public class TemplateEngineRepository : ITemplateEngineRepository
}
///
- public async Task> GetTemplatesWithChildrenAsync(
+ public Task> GetTemplatesWithChildrenAsync(
IEnumerable names, CancellationToken cancellationToken = default)
+ => GetTemplatesWithChildrenAsync(names, baseOnly: false, cancellationToken);
+
+ ///
+ public Task> GetBaseTemplatesWithChildrenAsync(
+ IEnumerable names, CancellationToken cancellationToken = default)
+ => GetTemplatesWithChildrenAsync(names, baseOnly: true, cancellationToken);
+
+ private async Task> GetTemplatesWithChildrenAsync(
+ IEnumerable 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();
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)
diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs
index dc17c779..c2a7a36d 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs
@@ -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()
- : 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)
{
diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/TemplateEngineRepositoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/TemplateEngineRepositoryTests.cs
index 91bee9b5..a8020f2c 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/TemplateEngineRepositoryTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/TemplateEngineRepositoryTests.cs
@@ -221,6 +221,27 @@ public class TemplateEngineRepositoryTests : IDisposable
Assert.Equal("Alpha", result[0].Name);
}
+ ///
+ /// ScadaBridge#95: the base-only bulk variant skips derived templates that share a
+ /// requested name, while the general variant still returns them.
+ ///
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task GetBaseTemplatesWithChildrenAsync_SkipsDerivedRowsSharingTheName()
+ {
+ var baseT = new Template("Alpha");
+ var derived = new Template("Alpha") { IsDerived = true };
+ var derivedOnly = new Template("Beta") { IsDerived = true };
+ _context.Templates.AddRange(baseT, derived, derivedOnly);
+ await _context.SaveChangesAsync();
+
+ var baseOnly = await _repository.GetBaseTemplatesWithChildrenAsync(new[] { "Alpha", "Beta" });
+ var all = await _repository.GetTemplatesWithChildrenAsync(new[] { "Alpha", "Beta" });
+
+ Assert.Equal(baseT.Id, Assert.Single(baseOnly).Id);
+ Assert.Equal(3, all.Count);
+ }
+
/// Verifies that the parent template id is preserved so the inheritance chain can be walked.
/// A task that represents the asynchronous operation.
[Fact]
diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/ImportApplyScalingTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/ImportApplyScalingTests.cs
index 760b3b59..46168e6f 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/ImportApplyScalingTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/ImportApplyScalingTests.cs
@@ -149,7 +149,8 @@ public sealed class ImportApplyScalingTests
return new ApplyCost(
detectChanges,
target.CallsTo(nameof(ITemplateEngineRepository.GetTemplateWithChildrenAsync)),
- target.CallsTo(nameof(ITemplateEngineRepository.GetTemplatesWithChildrenAsync)),
+ target.CallsTo(nameof(ITemplateEngineRepository.GetTemplatesWithChildrenAsync))
+ + target.CallsTo(nameof(ITemplateEngineRepository.GetBaseTemplatesWithChildrenAsync)),
result);
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/ImportNameLookupTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/ImportNameLookupTests.cs
index ffb103ed..b61dc83a 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/ImportNameLookupTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/ImportNameLookupTests.cs
@@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
+using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
@@ -173,4 +174,160 @@ public sealed class ImportNameLookupTests : IDisposable
var lookup = BundleImporter.ToNameLookup(new[] { a, b }, x => x.Name);
Assert.Same(a, lookup["list"]);
}
+ // ---- ScadaBridge#95: the remaining libraries + derived templates ------------------
+
+ private async Task ExportAndLoadAsync(Func> select)
+ {
+ Stream bundle;
+ await using (var scope = _provider.CreateAsyncScope())
+ {
+ var ctx = scope.ServiceProvider.GetRequiredService();
+ bundle = await scope.ServiceProvider.GetRequiredService().ExportAsync(
+ await select(ctx), user: "alice", sourceEnvironment: "dev", passphrase: null,
+ cancellationToken: CancellationToken.None);
+ }
+
+ await using (var scope = _provider.CreateAsyncScope())
+ {
+ return (await scope.ServiceProvider.GetRequiredService()
+ .LoadAsync(bundle, passphrase: null)).SessionId;
+ }
+ }
+
+ private async Task PreviewAsync(Guid sessionId)
+ {
+ await using var scope = _provider.CreateAsyncScope();
+ return await scope.ServiceProvider.GetRequiredService().PreviewAsync(sessionId);
+ }
+
+ ///
+ /// Folders, shared scripts, external systems, SMTP and SMS configurations whose target
+ /// spelling differs only in case are the SAME rows under SQL Server's collation: preview
+ /// must report them as conflicts (not New), and an Overwrite must update them in place
+ /// instead of adding a second row.
+ ///
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task Preview_and_apply_match_the_remaining_libraries_case_insensitively()
+ {
+ await using (var scope = _provider.CreateAsyncScope())
+ {
+ var ctx = scope.ServiceProvider.GetRequiredService();
+ var folder = new TemplateFolder("pumps");
+ ctx.TemplateFolders.Add(folder);
+ await ctx.SaveChangesAsync();
+ ctx.Templates.Add(new Template("Pump") { FolderId = folder.Id });
+ ctx.SharedScripts.Add(new SharedScript("Helper", "return 1;"));
+ ctx.ExternalSystemDefinitions.Add(new ExternalSystemDefinition("Erp", "http://erp", "None"));
+ ctx.SmtpConfigurations.Add(new SmtpConfiguration("mail.local", "None", "a@b.c"));
+ ctx.SmsConfigurations.Add(new SmsConfiguration("AC123"));
+ await ctx.SaveChangesAsync();
+ }
+
+ var sessionId = await ExportAndLoadAsync(async ctx => new ExportSelection(
+ TemplateIds: await ctx.Templates.Select(x => x.Id).ToListAsync(),
+ SharedScriptIds: await ctx.SharedScripts.Select(x => x.Id).ToListAsync(),
+ ExternalSystemIds: await ctx.ExternalSystemDefinitions.Select(x => x.Id).ToListAsync(),
+ DatabaseConnectionIds: Array.Empty(),
+ NotificationListIds: Array.Empty(),
+ SmtpConfigurationIds: await ctx.SmtpConfigurations.Select(x => x.Id).ToListAsync(),
+ ApiMethodIds: Array.Empty(),
+ IncludeDependencies: false,
+ SmsConfigurationIds: await ctx.SmsConfigurations.Select(x => x.Id).ToListAsync()));
+
+ await using (var scope = _provider.CreateAsyncScope())
+ {
+ var ctx = scope.ServiceProvider.GetRequiredService();
+ (await ctx.TemplateFolders.SingleAsync()).Name = "PUMPS";
+ (await ctx.SharedScripts.SingleAsync()).Name = "HELPER";
+ (await ctx.ExternalSystemDefinitions.SingleAsync()).Name = "ERP";
+ (await ctx.SmtpConfigurations.SingleAsync()).Host = "MAIL.LOCAL";
+ (await ctx.SmsConfigurations.SingleAsync()).AccountSid = "ac123";
+ await ctx.SaveChangesAsync();
+ }
+
+ var preview = await PreviewAsync(sessionId);
+ string[] types = ["TemplateFolder", "SharedScript", "ExternalSystem", "SmtpConfiguration", "SmsConfiguration"];
+ foreach (var type in types)
+ {
+ var item = Assert.Single(preview.Items, i => i.EntityType == type);
+ Assert.NotEqual(ConflictKind.New, item.Kind);
+ }
+
+ var resolutions = preview.Items
+ .Where(i => i.EntityType is "TemplateFolder" or "SmtpConfiguration" or "SmsConfiguration")
+ .Select(i => new ImportResolution(i.EntityType, i.Name, ResolutionAction.Overwrite, null))
+ .Concat(preview.Items
+ .Where(i => i.EntityType is not ("TemplateFolder" or "SmtpConfiguration" or "SmsConfiguration"))
+ .Select(i => new ImportResolution(i.EntityType, i.Name, ResolutionAction.Skip, null)))
+ .ToList();
+ await using (var scope = _provider.CreateAsyncScope())
+ {
+ await scope.ServiceProvider.GetRequiredService()
+ .ApplyAsync(sessionId, resolutions, user: "bob");
+ }
+
+ await using (var scope = _provider.CreateAsyncScope())
+ {
+ var ctx = scope.ServiceProvider.GetRequiredService();
+ Assert.Equal(1, await ctx.TemplateFolders.CountAsync());
+ Assert.Equal(1, await ctx.SmtpConfigurations.CountAsync());
+ Assert.Equal(1, await ctx.SmsConfigurations.CountAsync());
+ }
+ }
+
+ ///
+ /// A derived template carries its composition slot name; a bundled template of that name
+ /// targets a BASE template, so a target whose only row of that name is derived must see
+ /// the bundled template as New, and applying it must add a base template rather than
+ /// overwrite the derived one.
+ ///
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task A_derived_template_sharing_the_bundled_name_is_not_the_import_target()
+ {
+ await using (var scope = _provider.CreateAsyncScope())
+ {
+ var ctx = scope.ServiceProvider.GetRequiredService();
+ ctx.Templates.Add(new Template("Motor"));
+ await ctx.SaveChangesAsync();
+ }
+
+ var sessionId = await ExportAndLoadAsync(async ctx => new ExportSelection(
+ TemplateIds: await ctx.Templates.Select(x => x.Id).ToListAsync(),
+ SharedScriptIds: Array.Empty(),
+ ExternalSystemIds: Array.Empty(),
+ DatabaseConnectionIds: Array.Empty(),
+ NotificationListIds: Array.Empty(),
+ SmtpConfigurationIds: Array.Empty(),
+ ApiMethodIds: Array.Empty(),
+ IncludeDependencies: false));
+
+ // The target's only "Motor" is a derived slot template.
+ await using (var scope = _provider.CreateAsyncScope())
+ {
+ var ctx = scope.ServiceProvider.GetRequiredService();
+ (await ctx.Templates.SingleAsync()).IsDerived = true;
+ await ctx.SaveChangesAsync();
+ }
+
+ var preview = await PreviewAsync(sessionId);
+ Assert.Equal(ConflictKind.New, Assert.Single(preview.Items, i => i.EntityType == "Template").Kind);
+
+ await using (var scope = _provider.CreateAsyncScope())
+ {
+ await scope.ServiceProvider.GetRequiredService().ApplyAsync(
+ sessionId,
+ [new ImportResolution("Template", "Motor", ResolutionAction.Overwrite, null)],
+ user: "bob");
+ }
+
+ await using (var scope = _provider.CreateAsyncScope())
+ {
+ var ctx = scope.ServiceProvider.GetRequiredService();
+ var motors = await ctx.Templates.Where(t => t.Name == "Motor").ToListAsync();
+ Assert.Equal(2, motors.Count);
+ Assert.Single(motors, t => !t.IsDerived);
+ }
+ }
}