diff --git a/src/ZB.MOM.WW.MxGateway.Server/Sessions/ArrayAddressNormalizer.cs b/src/ZB.MOM.WW.MxGateway.Server/Sessions/ArrayAddressNormalizer.cs new file mode 100644 index 0000000..9dfc0cd --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Sessions/ArrayAddressNormalizer.cs @@ -0,0 +1,43 @@ +using ZB.MOM.WW.MxGateway.Server.Galaxy; + +namespace ZB.MOM.WW.MxGateway.Server.Sessions; + +/// +/// Rewrites a bare MXAccess attribute address to its writable array form by appending the +/// trailing [] suffix when Galaxy Repository metadata reports the attribute as an array. +/// MXAccess requires the [] suffix on the AddItem address for an array attribute to be +/// writable; the bare name registers a read-only-ish handle. This is best-effort: when metadata +/// is cold, the address is unknown, or the attribute is not an array, the address is returned +/// unchanged and no exception is thrown. +/// +public sealed class ArrayAddressNormalizer(IGalaxyHierarchyCache cache) +{ + private const string ArraySuffix = "[]"; + + /// + /// Returns with a trailing [] appended when Galaxy metadata + /// reports it as an array attribute; otherwise returns it unchanged. Never throws. + /// + /// The MXAccess attribute address to normalize. + /// The normalized address, or the original address when no rewrite applies. + public string Normalize(string address) + { + if (string.IsNullOrWhiteSpace(address)) + { + return address; + } + + if (address.EndsWith(ArraySuffix, StringComparison.Ordinal)) + { + return address; + } + + // Galaxy SQL keys array attributes by their suffixed FullTagReference (e.g. "Obj.Arr[]"), + // so probe for the suffixed form to decide whether the bare name is an array. + string suffixed = address + ArraySuffix; + return cache.Current.Index.TagsByAddress.TryGetValue(suffixed, out GalaxyTagLookup? lookup) + && lookup.Attribute?.IsArray == true + ? suffixed + : address; + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/ArrayAddressNormalizerTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/ArrayAddressNormalizerTests.cs new file mode 100644 index 0000000..ad34fcb --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/ArrayAddressNormalizerTests.cs @@ -0,0 +1,105 @@ +using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; +using ZB.MOM.WW.MxGateway.Server.Galaxy; +using ZB.MOM.WW.MxGateway.Server.Sessions; + +namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Sessions; + +public sealed class ArrayAddressNormalizerTests +{ + /// Verifies a bare array attribute name gains the trailing array suffix. + [Fact] + public void Normalize_BareArrayName_AppendsArraySuffix() + { + ArrayAddressNormalizer normalizer = CreateNormalizer(); + + Assert.Equal("Obj.Arr[]", normalizer.Normalize("Obj.Arr")); + } + + /// Verifies an already-suffixed address is returned unchanged. + [Fact] + public void Normalize_AlreadySuffixed_ReturnsUnchanged() + { + ArrayAddressNormalizer normalizer = CreateNormalizer(); + + Assert.Equal("Obj.Arr[]", normalizer.Normalize("Obj.Arr[]")); + } + + /// Verifies a scalar attribute is returned unchanged. + [Fact] + public void Normalize_ScalarAttribute_ReturnsUnchanged() + { + ArrayAddressNormalizer normalizer = CreateNormalizer(); + + Assert.Equal("Obj.Scalar", normalizer.Normalize("Obj.Scalar")); + } + + /// Verifies an address absent from the cache is returned unchanged. + [Fact] + public void Normalize_UnknownAddress_ReturnsUnchanged() + { + ArrayAddressNormalizer normalizer = CreateNormalizer(); + + Assert.Equal("Obj.Unknown", normalizer.Normalize("Obj.Unknown")); + } + + /// Verifies null, empty, and whitespace addresses are returned unchanged. + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Normalize_BlankAddress_ReturnsUnchanged(string address) + { + ArrayAddressNormalizer normalizer = CreateNormalizer(); + + Assert.Equal(address, normalizer.Normalize(address)); + } + + private static ArrayAddressNormalizer CreateNormalizer() + { + IReadOnlyList objects = + [ + new GalaxyObject + { + GobjectId = 1, + TagName = "Obj", + ContainedName = "Obj", + Attributes = + { + new GalaxyAttribute + { + AttributeName = "Arr", + // Galaxy SQL already appends "[]" to array attribute references. + FullTagReference = "Obj.Arr[]", + IsArray = true, + }, + new GalaxyAttribute + { + AttributeName = "Scalar", + FullTagReference = "Obj.Scalar", + IsArray = false, + }, + }, + }, + ]; + + GalaxyHierarchyCacheEntry entry = GalaxyHierarchyCacheEntry.Empty with + { + Status = GalaxyCacheStatus.Healthy, + Objects = objects, + Index = GalaxyHierarchyIndex.Build(objects), + }; + + return new ArrayAddressNormalizer(new StubGalaxyHierarchyCache(entry)); + } + + private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache + { + /// Gets the current cache entry. + public GalaxyHierarchyCacheEntry Current { get; } = current; + + /// + public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + /// + public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } +}