diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ISiteRepository.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ISiteRepository.cs index ee779405..b2a3e4b1 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ISiteRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ISiteRepository.cs @@ -90,6 +90,18 @@ public interface ISiteRepository /// A task that resolves to the distinct referencing entities (empty when none). Task> GetInstancesReferencingDataConnectionAsync(int dataConnectionId, CancellationToken cancellationToken = default); + /// + /// Retrieves a site's instances with their + /// eagerly loaded. Purpose-built + /// for the move-data-connection guard, which must scan source-site instance + /// overrides for name-based connection references without paying the eager-load + /// cost on the hot-path . + /// + /// The site primary key to filter by. + /// Cancellation token. + /// A task that resolves to the site's entities with overrides loaded (empty when none). + Task> GetInstancesWithNativeAlarmOverridesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default); + /// Saves all pending changes to the database. /// Cancellation token. /// A task that resolves to the number of state entries written to the database. diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SiteRepository.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SiteRepository.cs index ad5d9680..b9bcfde9 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SiteRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/SiteRepository.cs @@ -132,9 +132,15 @@ public class SiteRepository : ISiteRepository { return await _dbContext.Instances .Where(i => i.SiteId == siteId) - .Include(i => i.ConnectionBindings) + .ToListAsync(cancellationToken); + } + + /// + public async Task> GetInstancesWithNativeAlarmOverridesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default) + { + return await _dbContext.Instances + .Where(i => i.SiteId == siteId) .Include(i => i.NativeAlarmSourceOverrides) - .AsSplitQuery() .ToListAsync(cancellationToken); } diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs index 646080ae..eb2bad7d 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs @@ -1426,17 +1426,40 @@ public class ManagementActor : ReceiveActor // instances may override that name // (InstanceNativeAlarmSourceOverride.ConnectionNameOverride). Moving a // connection changes which physical connection that name resolves to on - // the source site, so any such reference to the moving name is a blocker. - // Detect and report only — never auto-rewrite. + // the SOURCE site, so any such reference *on the source site* to the + // moving name is a blocker. Detect and report only — never auto-rewrite. + // + // Scoping: templates are site-agnostic and ConnectionName resolves + // against the DEPLOYING site's connection pool at flatten time, so the + // template scan is restricted to templates actually instantiated on the + // SOURCE site. A template referencing the same connection NAME but only + // instantiated on another site is NOT affected by this move (that site + // keeps its own connection of the same name), so a global template scan + // would be a false positive that over-blocks legitimate moves whenever + // connection names are reused across sites (the common case). + // + // Comparison is StringComparison.Ordinal to match the + // flattening/deployment pipeline (FlatteningService / FlatteningPipeline + // resolve connection names case-sensitively) — block exactly the + // references the runtime would actually fail to resolve. var referenceBlockers = new List(); + // Source-site instances with their native-alarm-source overrides eagerly + // loaded (purpose-built load so the hot-path GetInstancesBySiteIdAsync + // stays lean). Reused below for both the override scan and to derive the + // set of templates to scan. + var sourceInstances = await repo.GetInstancesWithNativeAlarmOverridesBySiteIdAsync(sourceSiteId); + + // Only templates instantiated on the SOURCE site can be broken by the move. var templateRepo = sp.GetRequiredService(); - var templates = await templateRepo.GetAllTemplatesAsync(); - foreach (var template in templates) + var sourceTemplateIds = sourceInstances.Select(i => i.TemplateId).Distinct(); + foreach (var templateId in sourceTemplateIds) { + var template = await templateRepo.GetTemplateByIdAsync(templateId); + if (template is null) continue; foreach (var source in template.NativeAlarmSources) { - if (string.Equals(source.ConnectionName, conn.Name, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(source.ConnectionName, conn.Name, StringComparison.Ordinal)) { referenceBlockers.Add( $"template '{template.Name}' native-alarm-source '{source.Name}'"); @@ -1446,12 +1469,11 @@ public class ManagementActor : ReceiveActor // Instance-level overrides on the SOURCE site that name this connection // would orphan once the connection leaves the site. - var sourceInstances = await repo.GetInstancesBySiteIdAsync(sourceSiteId); foreach (var instance in sourceInstances) { foreach (var ovr in instance.NativeAlarmSourceOverrides) { - if (string.Equals(ovr.ConnectionNameOverride, conn.Name, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(ovr.ConnectionNameOverride, conn.Name, StringComparison.Ordinal)) { referenceBlockers.Add( $"instance '{instance.UniqueName}' (ID {instance.Id}) native-alarm-source override '{ovr.SourceCanonicalName}'"); diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs index 8c08d030..c659afaa 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs @@ -776,6 +776,76 @@ public class SiteRepositoryTests : IDisposable Assert.Single(instances); } + [Fact] + public async Task GetInstancesReferencingDataConnection_NoBindings_ReturnsEmpty() + { + var site = new Site("Site1", "S-001"); + var template = new Template("T1"); + _context.Sites.Add(site); + _context.Templates.Add(template); + await _context.SaveChangesAsync(); + + var conn = new DataConnection("Conn1", "OpcUa", site.Id); + _context.DataConnections.Add(conn); + _context.Instances.Add(new Instance("I1") { SiteId = site.Id, TemplateId = template.Id }); + await _context.SaveChangesAsync(); + + var result = await _repository.GetInstancesReferencingDataConnectionAsync(conn.Id); + Assert.Empty(result); + } + + [Fact] + public async Task GetInstancesReferencingDataConnection_OneBinding_ReturnsThatInstance() + { + var site = new Site("Site1", "S-001"); + var template = new Template("T1"); + _context.Sites.Add(site); + _context.Templates.Add(template); + await _context.SaveChangesAsync(); + + var conn = new DataConnection("Conn1", "OpcUa", site.Id); + _context.DataConnections.Add(conn); + var instance = new Instance("I1") { SiteId = site.Id, TemplateId = template.Id }; + _context.Instances.Add(instance); + await _context.SaveChangesAsync(); + + _context.InstanceConnectionBindings.Add( + new InstanceConnectionBinding("Attr1") { InstanceId = instance.Id, DataConnectionId = conn.Id }); + await _context.SaveChangesAsync(); + + var result = await _repository.GetInstancesReferencingDataConnectionAsync(conn.Id); + Assert.Single(result); + Assert.Equal(instance.Id, result[0].Id); + } + + [Fact] + public async Task GetInstancesReferencingDataConnection_TwoBindingsSameInstance_ReturnsOne() + { + var site = new Site("Site1", "S-001"); + var template = new Template("T1"); + _context.Sites.Add(site); + _context.Templates.Add(template); + await _context.SaveChangesAsync(); + + var conn = new DataConnection("Conn1", "OpcUa", site.Id); + _context.DataConnections.Add(conn); + var instance = new Instance("I1") { SiteId = site.Id, TemplateId = template.Id }; + _context.Instances.Add(instance); + await _context.SaveChangesAsync(); + + // Two distinct bindings (different attributes) on the SAME instance both + // reference the connection -> the result must be deduped to one instance. + _context.InstanceConnectionBindings.Add( + new InstanceConnectionBinding("Attr1") { InstanceId = instance.Id, DataConnectionId = conn.Id }); + _context.InstanceConnectionBindings.Add( + new InstanceConnectionBinding("Attr2") { InstanceId = instance.Id, DataConnectionId = conn.Id }); + await _context.SaveChangesAsync(); + + var result = await _repository.GetInstancesReferencingDataConnectionAsync(conn.Id); + Assert.Single(result); + Assert.Equal(instance.Id, result[0].Id); + } + [Fact] public void Constructor_NullContext_Throws() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs index 42bc003e..9ab96d9d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs @@ -2104,18 +2104,14 @@ public class ManagementActorTests : TestKit, IDisposable // No bindings reference the connection -> move allowed. siteRepo.GetInstancesReferencingDataConnectionAsync(5, Arg.Any()) .Returns(new List()); - // No native-alarm-source overrides on the source site. - siteRepo.GetInstancesBySiteIdAsync(1, Arg.Any()) + // No native-alarm-source overrides on the source site (purpose-built load). + siteRepo.GetInstancesWithNativeAlarmOverridesBySiteIdAsync(1, Arg.Any()) .Returns(new List()); siteRepo.UpdateDataConnectionAsync(Arg.Any(), Arg.Any()) .Returns(Task.CompletedTask); siteRepo.SaveChangesAsync(Arg.Any()).Returns(1); _services.AddScoped(_ => siteRepo); - // No templates reference the connection by name. - _templateRepo.GetAllTemplatesAsync(Arg.Any()) - .Returns(new List