refactor(commons): consolidate List element-type/coercion into AttributeValueCodec; InstanceActor + CLI reuse it (#93)

This commit is contained in:
Joseph Doherty
2026-06-19 02:03:09 -04:00
parent 2935c41bf7
commit 47f5ca687c
4 changed files with 120 additions and 57 deletions
@@ -1,6 +1,8 @@
using System.CommandLine;
using System.CommandLine.Parsing;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
@@ -239,9 +241,16 @@ public static class TemplateCommands
internal const string ElementTypeOptionDescription =
"Element scalar type for a List attribute (String, Int32, Float, Double, Boolean, DateTime). Required when --data-type is List.";
/// <summary>The element scalar types permitted for a List attribute (matches the Management API).</summary>
/// <summary>
/// The element scalar types permitted for a List attribute — derived from the
/// single source of truth, <see cref="AttributeValueCodec.IsValidElementType"/>,
/// so the CLI never drifts from the codec/Management API.
/// </summary>
private static readonly string[] ValidElementScalars =
{ "String", "Int32", "Float", "Double", "Boolean", "DateTime" };
Enum.GetValues<DataType>()
.Where(AttributeValueCodec.IsValidElementType)
.Select(t => t.ToString())
.ToArray();
/// <summary>
/// Validates the <c>--data-type</c> / <c>--element-type</c> combination client-side so
@@ -268,7 +277,8 @@ public static class TemplateCommands
return false;
}
if (!ValidElementScalars.Contains(elementType!.Trim(), StringComparer.OrdinalIgnoreCase))
if (!Enum.TryParse<DataType>(elementType!.Trim(), ignoreCase: true, out var parsed)
|| !AttributeValueCodec.IsValidElementType(parsed))
{
error = $"Invalid --element-type '{elementType}'. Valid List element scalars are: "
+ string.Join(", ", ValidElementScalars) + ".";
@@ -64,7 +64,13 @@ public static class AttributeValueCodec
_ => el.GetRawText() // number/bool → "10" / "1.5" / "true"
};
private static Type ElementClrType(DataType t) => t switch
/// <summary>
/// The CLR element type backing a <see cref="DataType.List"/> of the given
/// element scalar — the single source of truth for the List element CLR
/// mapping. Throws <see cref="FormatException"/> for an unsupported element
/// type (see <see cref="IsValidElementType"/>).
/// </summary>
public static Type ElementClrType(DataType t) => t switch
{
DataType.String => typeof(string),
DataType.Int32 => typeof(int),
@@ -75,6 +81,49 @@ public static class AttributeValueCodec
_ => throw new FormatException($"Unsupported list element type '{t}'.")
};
/// <summary>
/// Coerces a live CLR enumerable (e.g. an OPC UA array) into a typed
/// <c>List&lt;<see cref="ElementClrType"/>&gt;</c> for the given element type,
/// converting each element with invariant culture (round-trip parse for
/// DateTime). Strings are parsed; other CLR types are converted via
/// <see cref="Convert"/>. This is the object-input counterpart to the
/// string-input parsing in <see cref="Decode"/>: callers holding decoded JSON
/// strings go through <see cref="Decode"/>; callers holding a runtime
/// collection use this. Throws <see cref="FormatException"/> for an
/// unsupported element type and may throw on an element that cannot be
/// converted (the caller decides how to handle the failure).
/// </summary>
public static IList CoerceEnumerable(IEnumerable source, DataType elementType)
{
ArgumentNullException.ThrowIfNull(source);
var clrType = ElementClrType(elementType);
var list = (IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(clrType))!;
foreach (var element in source)
list.Add(CoerceElement(element, elementType));
return list;
}
private static object CoerceElement(object? element, DataType t)
{
if (element is null)
throw new FormatException("List elements may not be null.");
var c = CultureInfo.InvariantCulture;
return t switch
{
DataType.String => Convert.ToString(element, c)
?? throw new FormatException("Null string element."),
DataType.Int32 => element is string si ? int.Parse(si, c) : Convert.ToInt32(element, c),
DataType.Float => element is string sf ? float.Parse(sf, c) : Convert.ToSingle(element, c),
DataType.Double => element is string sd ? double.Parse(sd, c) : Convert.ToDouble(element, c),
DataType.Boolean => element is string sb ? bool.Parse(sb) : Convert.ToBoolean(element, c),
DataType.DateTime => element is string sdt
? DateTime.Parse(sdt, c, DateTimeStyles.RoundtripKind)
: Convert.ToDateTime(element, c),
_ => throw new FormatException($"Unsupported list element type '{t}'.")
};
}
private static object? ParseScalar(string? s, DataType t)
{
if (s is null) throw new FormatException("List elements may not be null.");
@@ -896,20 +896,12 @@ public class InstanceActor : ReceiveActor
try
{
// Construct the typed list INSIDE the try: although the six valid
// element types resolved by ListElementClrType cannot throw today,
// keeping ListElementClrType / MakeGenericType / CreateInstance inside
// the guarded block means any future change that introduces a throw
// here is caught and turned into a Bad-quality result rather than
// Coerce INSIDE the try: although the six valid element types cannot
// throw on construction today, keeping the (shared) codec coercion
// inside the guarded block means any future change that introduces a
// throw is caught and turned into a Bad-quality result rather than
// escaping into the actor and tripping supervision.
var clrType = ListElementClrType(elementType);
var list = (System.Collections.IList)Activator.CreateInstance(
typeof(List<>).MakeGenericType(clrType))!;
foreach (var element in enumerable)
list.Add(CoerceElement(element, elementType));
typedList = list;
typedList = AttributeValueCodec.CoerceEnumerable(enumerable, elementType);
return true;
}
catch (Exception ex)
@@ -922,46 +914,6 @@ public class InstanceActor : ReceiveActor
}
}
private static Type ListElementClrType(DataType t) => t switch
{
DataType.String => typeof(string),
DataType.Int32 => typeof(int),
DataType.Float => typeof(float),
DataType.Double => typeof(double),
DataType.Boolean => typeof(bool),
DataType.DateTime => typeof(DateTime),
_ => throw new FormatException($"Unsupported list element type '{t}'.")
};
private static object CoerceElement(object? element, DataType t)
{
if (element is null)
throw new FormatException("List elements may not be null.");
var culture = System.Globalization.CultureInfo.InvariantCulture;
return t switch
{
DataType.String => Convert.ToString(element, culture)
?? throw new FormatException("Null string element."),
DataType.Int32 => element is string si
? int.Parse(si, culture)
: Convert.ToInt32(element, culture),
DataType.Float => element is string sf
? float.Parse(sf, culture)
: Convert.ToSingle(element, culture),
DataType.Double => element is string sd
? double.Parse(sd, culture)
: Convert.ToDouble(element, culture),
DataType.Boolean => element is string sb
? bool.Parse(sb)
: Convert.ToBoolean(element, culture),
DataType.DateTime => element is string sdt
? DateTime.Parse(sdt, culture, System.Globalization.DateTimeStyles.RoundtripKind)
: Convert.ToDateTime(element, culture),
_ => throw new FormatException($"Unsupported list element type '{t}'.")
};
}
private void HandleConnectionQualityChanged(ConnectionQualityChanged qualityChanged)
{
_logger.LogWarning("Connection {Connection} quality changed to {Quality} for instance {Instance}",