feat(gateway): add SparseArrayExpander for default-fill partial array writes
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user