From 34a99c783bf667f8a81b3e2997fad2f7aa44db29 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 18 Jun 2026 02:52:33 -0400 Subject: [PATCH] feat(gateway): add SparseArrayExpander for default-fill partial array writes --- .../Sessions/SparseArrayExpander.cs | 279 ++++++++++++++++++ .../Sessions/SparseArrayExpanderTests.cs | 195 ++++++++++++ 2 files changed, 474 insertions(+) create mode 100644 src/ZB.MOM.WW.MxGateway.Server/Sessions/SparseArrayExpander.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/SparseArrayExpanderTests.cs diff --git a/src/ZB.MOM.WW.MxGateway.Server/Sessions/SparseArrayExpander.cs b/src/ZB.MOM.WW.MxGateway.Server/Sessions/SparseArrayExpander.cs new file mode 100644 index 0000000..890ff0b --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Sessions/SparseArrayExpander.cs @@ -0,0 +1,279 @@ +using Google.Protobuf.WellKnownTypes; +using Grpc.Core; +using ZB.MOM.WW.MxGateway.Contracts.Proto; + +namespace ZB.MOM.WW.MxGateway.Server.Sessions; + +/// +/// Expands a client-supplied sparse array write () into a +/// full, default-filled in place. +/// +/// +/// MXAccess has no partial-array write primitive: a write replaces the whole array. +/// Clients that only care about a few indices send an with +/// the total length plus the indices they want to set; this expander materializes the +/// full array so the worker can do an ordinary whole-array COM write. Indices the client +/// did not mention are reset to the element type's default (they are NOT preserved from +/// the live value); this is intentional, because the gateway cannot read-modify-write +/// without racing the provider. +/// +/// For the MXAccess Integer element type the worker's COM-array converter (see +/// VariantConverter.ConvertArray) chooses between a 32-bit and 64-bit sub-array +/// based on the CLR element type. The sparse value carries no CLR array, so this expander +/// mirrors that choice by inspecting the supplied element value kinds: if any element is an +/// the whole array is emitted as +/// ; otherwise it is emitted as +/// (matching a default Integer array). +/// +internal static class SparseArrayExpander +{ + /// + /// Replaces 's with an + /// equivalent full . If is not + /// a sparse array this is a no-op, so callers may invoke it unconditionally. + /// + /// The value to expand in place. + /// + /// when the sparse payload is invalid: zero + /// total length, an index at or beyond the total length, a duplicate index, an + /// unsupported element type, or an element value whose kind does not match the declared + /// element type. + /// + public static void Expand(MxValue value) + { + ArgumentNullException.ThrowIfNull(value); + + if (value.KindCase != MxValue.KindOneofCase.SparseArrayValue) + { + return; + } + + MxSparseArray sparse = value.SparseArrayValue; + MxDataType elementType = sparse.ElementDataType; + uint totalLength = sparse.TotalLength; + + if (totalLength == 0) + { + throw Invalid("Sparse array total_length must be greater than zero."); + } + + if (!IsSupportedElementType(elementType)) + { + throw Invalid($"Sparse array element_data_type '{elementType}' is not a supported scalar element type."); + } + + int length = checked((int)totalLength); + HashSet seenIndices = new(); + + foreach (MxSparseElement element in sparse.Elements) + { + if (element.Index >= totalLength) + { + throw Invalid( + $"Sparse array index {element.Index} is out of range for total_length {totalLength}."); + } + + if (!seenIndices.Add(element.Index)) + { + throw Invalid($"Sparse array has a duplicate index {element.Index}."); + } + + ValidateElementKind(elementType, element); + } + + MxArray array = BuildArray(elementType, length, sparse.Elements); + array.ElementDataType = elementType; + array.Dimensions.Add(totalLength); + + // Assigning ArrayValue switches the oneof and clears SparseArrayValue. + value.ArrayValue = array; + } + + private static MxArray BuildArray( + MxDataType elementType, + int length, + IReadOnlyList elements) + { + MxArray array = new(); + + switch (elementType) + { + case MxDataType.Boolean: + { + BoolArray values = new(); + for (int i = 0; i < length; i++) + { + values.Values.Add(false); + } + + foreach (MxSparseElement element in elements) + { + values.Values[(int)element.Index] = element.Value.BoolValue; + } + + array.BoolValues = values; + break; + } + + case MxDataType.Integer when UsesInt64(elements): + { + Int64Array values = new(); + for (int i = 0; i < length; i++) + { + values.Values.Add(0L); + } + + foreach (MxSparseElement element in elements) + { + values.Values[(int)element.Index] = ReadInt64(element.Value); + } + + array.Int64Values = values; + break; + } + + case MxDataType.Integer: + { + Int32Array values = new(); + for (int i = 0; i < length; i++) + { + values.Values.Add(0); + } + + foreach (MxSparseElement element in elements) + { + values.Values[(int)element.Index] = element.Value.Int32Value; + } + + array.Int32Values = values; + break; + } + + case MxDataType.Float: + { + FloatArray values = new(); + for (int i = 0; i < length; i++) + { + values.Values.Add(0f); + } + + foreach (MxSparseElement element in elements) + { + values.Values[(int)element.Index] = element.Value.FloatValue; + } + + array.FloatValues = values; + break; + } + + case MxDataType.Double: + { + DoubleArray values = new(); + for (int i = 0; i < length; i++) + { + values.Values.Add(0d); + } + + foreach (MxSparseElement element in elements) + { + values.Values[(int)element.Index] = element.Value.DoubleValue; + } + + array.DoubleValues = values; + break; + } + + case MxDataType.String: + { + StringArray values = new(); + for (int i = 0; i < length; i++) + { + values.Values.Add(string.Empty); + } + + foreach (MxSparseElement element in elements) + { + values.Values[(int)element.Index] = element.Value.StringValue; + } + + array.StringValues = values; + break; + } + + case MxDataType.Time: + { + TimestampArray values = new(); + for (int i = 0; i < length; i++) + { + values.Values.Add(new Timestamp { Seconds = 0, Nanos = 0 }); + } + + foreach (MxSparseElement element in elements) + { + values.Values[(int)element.Index] = element.Value.TimestampValue; + } + + array.TimestampValues = values; + break; + } + + default: + // Unreachable: IsSupportedElementType gates the element type before BuildArray. + throw Invalid($"Sparse array element_data_type '{elementType}' is not supported."); + } + + return array; + } + + private static bool IsSupportedElementType(MxDataType elementType) => elementType switch + { + MxDataType.Boolean => true, + MxDataType.Integer => true, + MxDataType.Float => true, + MxDataType.Double => true, + MxDataType.String => true, + MxDataType.Time => true, + _ => false, + }; + + private static void ValidateElementKind(MxDataType elementType, MxSparseElement element) + { + MxValue.KindOneofCase kind = element.Value?.KindCase ?? MxValue.KindOneofCase.None; + + bool matches = elementType switch + { + MxDataType.Boolean => kind == MxValue.KindOneofCase.BoolValue, + MxDataType.Integer => kind is MxValue.KindOneofCase.Int32Value or MxValue.KindOneofCase.Int64Value, + MxDataType.Float => kind == MxValue.KindOneofCase.FloatValue, + MxDataType.Double => kind == MxValue.KindOneofCase.DoubleValue, + MxDataType.String => kind == MxValue.KindOneofCase.StringValue, + MxDataType.Time => kind == MxValue.KindOneofCase.TimestampValue, + _ => false, + }; + + if (!matches) + { + throw Invalid( + $"Sparse array element at index {element.Index} has value kind '{kind}' which does not match element_data_type '{elementType}'."); + } + } + + private static bool UsesInt64(IReadOnlyList elements) + { + foreach (MxSparseElement element in elements) + { + if (element.Value.KindCase == MxValue.KindOneofCase.Int64Value) + { + return true; + } + } + + return false; + } + + private static long ReadInt64(MxValue value) => + value.KindCase == MxValue.KindOneofCase.Int64Value ? value.Int64Value : value.Int32Value; + + private static RpcException Invalid(string message) => + new(new Status(StatusCode.InvalidArgument, message)); +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/SparseArrayExpanderTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/SparseArrayExpanderTests.cs new file mode 100644 index 0000000..a329ad4 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Sessions/SparseArrayExpanderTests.cs @@ -0,0 +1,195 @@ +using Google.Protobuf.WellKnownTypes; +using Grpc.Core; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Server.Sessions; + +namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Sessions; + +public sealed class SparseArrayExpanderTests +{ + private static MxValue SparseValue( + MxDataType elementType, + uint totalLength, + params (uint Index, MxValue Value)[] elements) + { + MxSparseArray sparse = new() + { + ElementDataType = elementType, + TotalLength = totalLength, + }; + + foreach ((uint index, MxValue value) in elements) + { + sparse.Elements.Add(new MxSparseElement { Index = index, Value = value }); + } + + return new MxValue { SparseArrayValue = sparse }; + } + + [Fact] + public void Expand_Int32_FillsDefaultsAndSetsElement() + { + MxValue value = SparseValue( + MxDataType.Integer, + 4, + (1, new MxValue { Int32Value = 7 })); + + SparseArrayExpander.Expand(value); + + Assert.Equal(MxValue.KindOneofCase.ArrayValue, value.KindCase); + Assert.Equal(MxDataType.Integer, value.ArrayValue.ElementDataType); + Assert.Equal(new uint[] { 4 }, value.ArrayValue.Dimensions); + Assert.Equal(MxArray.ValuesOneofCase.Int32Values, value.ArrayValue.ValuesCase); + Assert.Equal(new[] { 0, 7, 0, 0 }, value.ArrayValue.Int32Values.Values); + } + + [Fact] + public void Expand_Boolean_EmptyElements_AllDefaultFalse() + { + MxValue value = SparseValue(MxDataType.Boolean, 3); + + SparseArrayExpander.Expand(value); + + Assert.Equal(MxArray.ValuesOneofCase.BoolValues, value.ArrayValue.ValuesCase); + Assert.Equal(new[] { false, false, false }, value.ArrayValue.BoolValues.Values); + } + + [Fact] + public void Expand_ZeroTotalLength_Throws() + { + MxValue value = SparseValue(MxDataType.Integer, 0); + + RpcException ex = Assert.Throws(() => SparseArrayExpander.Expand(value)); + Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode); + } + + [Fact] + public void Expand_IndexOutOfRange_Throws() + { + MxValue value = SparseValue( + MxDataType.Integer, + 2, + (5, new MxValue { Int32Value = 1 })); + + RpcException ex = Assert.Throws(() => SparseArrayExpander.Expand(value)); + Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode); + } + + [Fact] + public void Expand_DuplicateIndex_Throws() + { + MxValue value = SparseValue( + MxDataType.Integer, + 4, + (1, new MxValue { Int32Value = 1 }), + (1, new MxValue { Int32Value = 2 })); + + RpcException ex = Assert.Throws(() => SparseArrayExpander.Expand(value)); + Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode); + } + + [Fact] + public void Expand_UnsupportedElementType_Throws() + { + MxValue value = SparseValue(MxDataType.Unspecified, 2); + + RpcException ex = Assert.Throws(() => SparseArrayExpander.Expand(value)); + Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode); + } + + [Fact] + public void Expand_ElementValueKindMismatch_Throws() + { + MxValue value = SparseValue( + MxDataType.Integer, + 2, + (0, new MxValue { StringValue = "nope" })); + + RpcException ex = Assert.Throws(() => SparseArrayExpander.Expand(value)); + Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode); + } + + [Fact] + public void Expand_String_FillsEmptyStringDefault() + { + MxValue value = SparseValue( + MxDataType.String, + 2, + (0, new MxValue { StringValue = "a" })); + + SparseArrayExpander.Expand(value); + + Assert.Equal(MxArray.ValuesOneofCase.StringValues, value.ArrayValue.ValuesCase); + Assert.Equal(new[] { "a", string.Empty }, value.ArrayValue.StringValues.Values); + } + + [Fact] + public void Expand_Time_FillsEpochDefault() + { + MxValue value = SparseValue( + MxDataType.Time, + 2, + (1, new MxValue { TimestampValue = new Timestamp { Seconds = 5 } })); + + SparseArrayExpander.Expand(value); + + Assert.Equal(MxArray.ValuesOneofCase.TimestampValues, value.ArrayValue.ValuesCase); + Assert.Equal(2, value.ArrayValue.TimestampValues.Values.Count); + Assert.Equal(0, value.ArrayValue.TimestampValues.Values[0].Seconds); + Assert.Equal(0, value.ArrayValue.TimestampValues.Values[0].Nanos); + Assert.Equal(5, value.ArrayValue.TimestampValues.Values[1].Seconds); + } + + [Fact] + public void Expand_Double_HappyPath() + { + MxValue value = SparseValue( + MxDataType.Double, + 3, + (2, new MxValue { DoubleValue = 1.5 })); + + SparseArrayExpander.Expand(value); + + Assert.Equal(MxArray.ValuesOneofCase.DoubleValues, value.ArrayValue.ValuesCase); + Assert.Equal(new[] { 0d, 0d, 1.5 }, value.ArrayValue.DoubleValues.Values); + } + + [Fact] + public void Expand_Float_HappyPath() + { + MxValue value = SparseValue( + MxDataType.Float, + 2, + (0, new MxValue { FloatValue = 2.5f })); + + SparseArrayExpander.Expand(value); + + Assert.Equal(MxArray.ValuesOneofCase.FloatValues, value.ArrayValue.ValuesCase); + Assert.Equal(new[] { 2.5f, 0f }, value.ArrayValue.FloatValues.Values); + } + + [Fact] + public void Expand_Int64_WhenElementIsInt64() + { + MxValue value = SparseValue( + MxDataType.Integer, + 3, + (2, new MxValue { Int64Value = 9_000_000_000L })); + + SparseArrayExpander.Expand(value); + + Assert.Equal(MxArray.ValuesOneofCase.Int64Values, value.ArrayValue.ValuesCase); + Assert.Equal(new[] { 0L, 0L, 9_000_000_000L }, value.ArrayValue.Int64Values.Values); + } + + [Fact] + public void Expand_NonSparseValue_NoOps() + { + MxValue value = new() { Int32Value = 42 }; + + SparseArrayExpander.Expand(value); + + Assert.Equal(MxValue.KindOneofCase.Int32Value, value.KindCase); + Assert.Equal(42, value.Int32Value); + } +}