feat(gateway): add SparseArrayExpander for default-fill partial array writes

This commit is contained in:
Joseph Doherty
2026-06-18 02:52:33 -04:00
parent 52cd0da9f5
commit 34a99c783b
2 changed files with 474 additions and 0 deletions
@@ -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;
/// <summary>
/// Expands a client-supplied sparse array write (<see cref="MxSparseArray"/>) into a
/// full, default-filled <see cref="MxArray"/> in place.
/// </summary>
/// <remarks>
/// MXAccess has no partial-array write primitive: a write replaces the whole array.
/// Clients that only care about a few indices send an <see cref="MxSparseArray"/> 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 <c>Integer</c> element type the worker's COM-array converter (see
/// <c>VariantConverter.ConvertArray</c>) 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
/// <see cref="MxValue.KindOneofCase.Int64Value"/> the whole array is emitted as
/// <see cref="MxArray.Int64Values"/>; otherwise it is emitted as
/// <see cref="MxArray.Int32Values"/> (matching a default Integer array).
/// </remarks>
internal static class SparseArrayExpander
{
/// <summary>
/// Replaces <paramref name="value"/>'s <see cref="MxValue.SparseArrayValue"/> with an
/// equivalent full <see cref="MxValue.ArrayValue"/>. If <paramref name="value"/> is not
/// a sparse array this is a no-op, so callers may invoke it unconditionally.
/// </summary>
/// <param name="value">The value to expand in place.</param>
/// <exception cref="RpcException">
/// <see cref="StatusCode.InvalidArgument"/> 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.
/// </exception>
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<uint> 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<MxSparseElement> 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<MxSparseElement> 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));
}
@@ -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<RpcException>(() => 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<RpcException>(() => 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<RpcException>(() => 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<RpcException>(() => 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<RpcException>(() => 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);
}
}