From 5abc222c725b551e461f6a7338120381ed24753b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 28 May 2026 15:31:56 -0400 Subject: [PATCH] galaxy: add by-name and by-path indexes to GalaxyHierarchyIndex --- .../Galaxy/GalaxyBrowseProjector.cs | 8 +--- .../Galaxy/GalaxyHierarchyIndex.cs | 28 +++++++++++-- .../Galaxy/GalaxyHierarchyProjector.cs | 16 ++++---- .../Galaxy/GalaxyHierarchyIndexTests.cs | 41 +++++++++++++++++++ 4 files changed, 76 insertions(+), 17 deletions(-) diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseProjector.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseProjector.cs index 2d69391..d6a5135 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseProjector.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseProjector.cs @@ -80,9 +80,7 @@ public static class GalaxyBrowseProjector return request.ParentGobjectId; case BrowseChildrenRequest.ParentOneofCase.ParentTagName: { - GalaxyObjectView? match = entry.Index.ObjectViews.FirstOrDefault( - view => string.Equals(view.Object.TagName, request.ParentTagName, StringComparison.OrdinalIgnoreCase)); - if (match is null) + if (!entry.Index.ObjectViewsByTagName.TryGetValue(request.ParentTagName, out GalaxyObjectView? match)) { throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found.")); } @@ -90,9 +88,7 @@ public static class GalaxyBrowseProjector } case BrowseChildrenRequest.ParentOneofCase.ParentContainedPath: { - GalaxyObjectView? match = entry.Index.ObjectViews.FirstOrDefault( - view => string.Equals(view.ContainedPath, request.ParentContainedPath, StringComparison.OrdinalIgnoreCase)); - if (match is null) + if (!entry.Index.ObjectViewsByContainedPath.TryGetValue(request.ParentContainedPath, out GalaxyObjectView? match)) { throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found.")); } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyIndex.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyIndex.cs index 5378a61..f71e818 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyIndex.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyIndex.cs @@ -8,12 +8,16 @@ public sealed class GalaxyHierarchyIndex IReadOnlyList objectViews, IReadOnlyDictionary objectViewsById, IReadOnlyDictionary tagsByAddress, - IReadOnlyDictionary> childrenByParent) + IReadOnlyDictionary> childrenByParent, + IReadOnlyDictionary objectViewsByTagName, + IReadOnlyDictionary objectViewsByContainedPath) { ObjectViews = objectViews; ObjectViewsById = objectViewsById; TagsByAddress = tagsByAddress; ChildrenByParent = childrenByParent; + ObjectViewsByTagName = objectViewsByTagName; + ObjectViewsByContainedPath = objectViewsByContainedPath; } /// Gets an empty Galaxy hierarchy index. @@ -21,7 +25,9 @@ public sealed class GalaxyHierarchyIndex Array.Empty(), new Dictionary(), new Dictionary(StringComparer.OrdinalIgnoreCase), - new Dictionary>()); + new Dictionary>(), + new Dictionary(StringComparer.OrdinalIgnoreCase), + new Dictionary(StringComparer.OrdinalIgnoreCase)); /// Gets the object views. public IReadOnlyList ObjectViews { get; } @@ -35,6 +41,12 @@ public sealed class GalaxyHierarchyIndex /// Gets direct children grouped by parent gobject id. Root objects (no parent, or self-parented) live under key 0. Each list is sorted areas-first, then by display name (OrdinalIgnoreCase). public IReadOnlyDictionary> ChildrenByParent { get; } + /// Gets object views indexed by (OrdinalIgnoreCase). Lets browse/discover handlers resolve parents/roots by tag name in O(1) instead of scanning . + public IReadOnlyDictionary ObjectViewsByTagName { get; } + + /// Gets object views indexed by contained path (OrdinalIgnoreCase). Lets browse/discover handlers resolve parents/roots by path in O(1) instead of scanning . + public IReadOnlyDictionary ObjectViewsByContainedPath { get; } + /// Builds a Galaxy hierarchy index from the given objects. /// The Galaxy objects to index. /// A new Galaxy hierarchy index. @@ -54,6 +66,8 @@ public sealed class GalaxyHierarchyIndex List views = new(objects.Count); Dictionary viewsById = new(); Dictionary tagsByAddress = new(StringComparer.OrdinalIgnoreCase); + Dictionary viewsByTagName = new(StringComparer.OrdinalIgnoreCase); + Dictionary viewsByContainedPath = new(StringComparer.OrdinalIgnoreCase); foreach (GalaxyObject obj in objects) { @@ -66,6 +80,12 @@ public sealed class GalaxyHierarchyIndex if (!string.IsNullOrWhiteSpace(obj.TagName)) { tagsByAddress.TryAdd(obj.TagName, new GalaxyTagLookup(obj, Attribute: null, path)); + viewsByTagName.TryAdd(obj.TagName, view); + } + + if (!string.IsNullOrWhiteSpace(path)) + { + viewsByContainedPath.TryAdd(path, view); } foreach (GalaxyAttribute attribute in obj.Attributes) @@ -109,7 +129,9 @@ public sealed class GalaxyHierarchyIndex views, viewsById, tagsByAddress, - readOnlyChildren); + readOnlyChildren, + viewsByTagName, + viewsByContainedPath); } private static string BuildContainedPath( diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyProjector.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyProjector.cs index e4449ac..d74df56 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyProjector.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyHierarchyProjector.cs @@ -103,7 +103,7 @@ public static class GalaxyHierarchyProjector // ResolveRoot can throw RpcException(NotFound); run it before consulting the // memo so a bad root surfaces consistently regardless of cache state. IReadOnlyList views = entry.Index.ObjectViews; - GalaxyObjectView? root = ResolveRoot(request, views); + GalaxyObjectView? root = ResolveRoot(request, entry.Index); ConcurrentDictionary> memo = FilteredViewCache.GetValue(entry, static _ => new ConcurrentDictionary>(StringComparer.Ordinal)); @@ -176,17 +176,17 @@ public static class GalaxyHierarchyProjector private static GalaxyObjectView? ResolveRoot( DiscoverHierarchyRequest request, - IReadOnlyList views) + GalaxyHierarchyIndex index) { GalaxyObjectView? root = request.RootCase switch { DiscoverHierarchyRequest.RootOneofCase.None => null, - DiscoverHierarchyRequest.RootOneofCase.RootGobjectId => views.FirstOrDefault( - view => view.Object.GobjectId == request.RootGobjectId), - DiscoverHierarchyRequest.RootOneofCase.RootTagName => views.FirstOrDefault( - view => string.Equals(view.Object.TagName, request.RootTagName, StringComparison.OrdinalIgnoreCase)), - DiscoverHierarchyRequest.RootOneofCase.RootContainedPath => views.FirstOrDefault( - view => string.Equals(view.ContainedPath, request.RootContainedPath, StringComparison.OrdinalIgnoreCase)), + DiscoverHierarchyRequest.RootOneofCase.RootGobjectId => + index.ObjectViewsById.TryGetValue(request.RootGobjectId, out GalaxyObjectView? byId) ? byId : null, + DiscoverHierarchyRequest.RootOneofCase.RootTagName => + index.ObjectViewsByTagName.TryGetValue(request.RootTagName, out GalaxyObjectView? byTag) ? byTag : null, + DiscoverHierarchyRequest.RootOneofCase.RootContainedPath => + index.ObjectViewsByContainedPath.TryGetValue(request.RootContainedPath, out GalaxyObjectView? byPath) ? byPath : null, _ => null, }; diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyIndexTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyIndexTests.cs index 39f6c39..940b183 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyIndexTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyIndexTests.cs @@ -75,6 +75,47 @@ public sealed class GalaxyHierarchyIndexTests } } + /// Verifies is OrdinalIgnoreCase and supports O(1) lookups. + [Fact] + public void ObjectViewsByTagName_IsCaseInsensitive_AndLookupsAreO1() + { + GalaxyObject root = new() { GobjectId = 1, ParentGobjectId = 0, IsArea = true, ContainedName = "Plant", BrowseName = "Plant", TagName = "Plant" }; + GalaxyObject mixer = new() { GobjectId = 2, ParentGobjectId = 1, ContainedName = "Mixer_001", BrowseName = "Mixer_001", TagName = "Plant.Mixer_001" }; + + GalaxyHierarchyIndex index = GalaxyHierarchyIndex.Build([root, mixer]); + + Assert.True(index.ObjectViewsByTagName.TryGetValue("Plant.Mixer_001", out GalaxyObjectView? exact)); + Assert.NotNull(exact); + Assert.Equal(2, exact!.Object.GobjectId); + + // Case-insensitive lookup must hit the same entry. + Assert.True(index.ObjectViewsByTagName.TryGetValue("plant.mixer_001", out GalaxyObjectView? lower)); + Assert.NotNull(lower); + Assert.Same(exact, lower); + + Assert.False(index.ObjectViewsByTagName.ContainsKey("Plant.Missing")); + } + + /// Verifies is OrdinalIgnoreCase. + [Fact] + public void ObjectViewsByContainedPath_IsCaseInsensitive() + { + GalaxyObject root = new() { GobjectId = 1, ParentGobjectId = 0, IsArea = true, ContainedName = "Plant", BrowseName = "Plant", TagName = "Plant" }; + GalaxyObject lineA = new() { GobjectId = 2, ParentGobjectId = 1, IsArea = true, ContainedName = "Line_A", BrowseName = "Line_A", TagName = "Plant.Line_A" }; + + GalaxyHierarchyIndex index = GalaxyHierarchyIndex.Build([root, lineA]); + + Assert.True(index.ObjectViewsByContainedPath.TryGetValue("Plant/Line_A", out GalaxyObjectView? exact)); + Assert.NotNull(exact); + Assert.Equal(2, exact!.Object.GobjectId); + + Assert.True(index.ObjectViewsByContainedPath.TryGetValue("plant/line_a", out GalaxyObjectView? lower)); + Assert.NotNull(lower); + Assert.Same(exact, lower); + + Assert.False(index.ObjectViewsByContainedPath.ContainsKey("Plant/Missing")); + } + /// Verifies children sort areas-first, then by display name (case-insensitive). [Fact] public void ChildrenByParent_SortsAreasFirstThenByDisplayName()