mxaccesscli: support bulk array writes via <arrayAttr>[]
WriteCommand now accepts multiple positional values when the tag reference ends with '[]', bundling them into a strongly-typed array (string[], int[], bool[], etc.) before passing to MxAccess.Write. The CLR marshals the array to a COM SAFEARRAY of the matching VARTYPE, which is the shape MxAccess expects for an array attribute. Verified live on a 50-slot String[] (MESReceiver_001.MoveInPartNumbers): write 50 distinct strings A1..A50 -> ok, MxCategoryOk read [] -> ['A1','A2', ..., 'A50'] Plus a guardrail: passing multiple values without the '[]' suffix exits 2 with a clear error so a typo can't accidentally write only the first element of an indexed reference. Critical finding documented in docs/usage.md: **a bulk write resizes the array to the count provided.** Writing 25 values into a 50-slot array leaves the array at 25 elements; the trailing 25 are deallocated, not zero-filled. Verified by 50 -> 25 -> 50 round-trip on the same attribute. Discover the runtime length via 'mxa read <attr>[]' or the configured length via grdb's attributes.sql array_dimension column. Type matrix in docs/usage.md updated: - Bulk array via '[]' - read ✅ + write ✅ - Bare reference (no brackets) - read ❌ + write ❌ - Element via '[N]' - unchanged ValueCoercion.cs: adds CoerceArray(IReadOnlyList<string>, typeHint) that produces strongly-typed arrays. Default element type is inferred from the first value when --type is unspecified. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -131,8 +131,8 @@ Verified end-to-end against the live `ZB` galaxy (System Platform 2017 Express,
|
|||||||
| `MxQualifiedStruct` (14) | – | – | – | Access via dotted member names: `<obj>.<struct>.<field>`. |
|
| `MxQualifiedStruct` (14) | – | – | – | Access via dotted member names: `<obj>.<struct>.<field>`. |
|
||||||
| `MxInternationalizedString` (15) | ❓ | ❓ | (likely string) | No accessible instance. |
|
| `MxInternationalizedString` (15) | ❓ | ❓ | (likely string) | No accessible instance. |
|
||||||
| `MxBigString` (16) | ❓ | ❓ | JSON string | No accessible instance. |
|
| `MxBigString` (16) | ❓ | ❓ | JSON string | No accessible instance. |
|
||||||
| **Array (any type), bulk read** | ✅ | – | JSON array of element type | Reference syntax `<obj>.<arrayAttr>[]` — **empty square brackets**. Verified on `MESReceiver_001.MoveInPartNumbers[]` (String[50]) and `MoveOutWorkOrderNumbers[]`. Returns the entire array as a single value with `MxCategoryOk`. |
|
| **Array (any type), bulk read/write via `[]`** | ✅ | ✅ | JSON array of element type | Reference syntax `<obj>.<arrayAttr>[]` — **empty square brackets**. Read returns the entire array as a single value. Write takes one positional value per element (`mxa write '<obj>.<arr>[]' v1 v2 v3 ...`). **A bulk write resizes the array to the count provided** (verified: 50 → 25 → 50 round-trip on `MoveInPartNumbers`). |
|
||||||
| **Array (bare reference)** | ❌ | – | — | The plain `<obj>.<arrayAttr>` (no brackets) returns `MxCategoryCommunicationError, Detail=1003`. Use `[]` instead. |
|
| **Array (bare reference)** | ❌ | ❌ | — | The plain `<obj>.<arrayAttr>` (no brackets) returns `MxCategoryCommunicationError, Detail=1003`. Always use `[]` for bulk operations. |
|
||||||
| **Array element by index** | ✅ | ✅ | scalar of element type | Reference syntax `<obj>.<arrayAttr>[<n>]`. **1-based**, runs from `[1]` to `[NumElements]`. `[0]` is invalid. |
|
| **Array element by index** | ✅ | ✅ | scalar of element type | Reference syntax `<obj>.<arrayAttr>[<n>]`. **1-based**, runs from `[1]` to `[NumElements]`. `[0]` is invalid. |
|
||||||
|
|
||||||
Legend: ✅ verified live, ⚠️ wiring present but no live instance to write, ❓ wiring present but no live instance found, ❌ not supported by MxAccess at this layer, – not applicable.
|
Legend: ✅ verified live, ⚠️ wiring present but no live instance to write, ❓ wiring present but no live instance found, ❌ not supported by MxAccess at this layer, – not applicable.
|
||||||
@@ -191,6 +191,27 @@ mxa read 'MESReceiver_001.MoveInPartNumbers[2]' --llm-json
|
|||||||
|
|
||||||
Indices are **1-based**: `[1]` is the first element, `[NumElements]` is the last. `[0]` is invalid. Single-element reads are also writeable: `mxa write '<obj>.<attr>[N]' <value>`.
|
Indices are **1-based**: `[1]` is the first element, `[NumElements]` is the last. `[0]` is invalid. Single-element reads are also writeable: `mxa write '<obj>.<attr>[N]' <value>`.
|
||||||
|
|
||||||
|
### Whole array write — also via `[]`
|
||||||
|
|
||||||
|
Pass one positional value per element after the tag. The CLI bundles them into a strongly-typed array (`string[]`, `int[]`, `bool[]`, …) before writing.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Write a 50-element string array
|
||||||
|
mxa write 'MESReceiver_001.MoveInPartNumbers[]' \
|
||||||
|
"" "11111" "" "" "" "" "" "" "" "" \
|
||||||
|
"" "" "" "" "" "15" "" "" "" "" \
|
||||||
|
"" "" "" "" "" "" "" "" "" "" \
|
||||||
|
"" "" "" "" "" "" "" "" "" "" \
|
||||||
|
"" "" "" "" "" "" "" "" "" ""
|
||||||
|
|
||||||
|
# Write a typed array
|
||||||
|
mxa write 'SomeObj.SomeFloats[]' 1.0 2.5 3.14 --type float
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ **A bulk write resizes the array to the count provided.** If the configured `array_dimension` is 50 and you supply 25 values, after the write `mxa read '...[]'` returns **25** elements, not 50. The trailing slots are deallocated, not zero-filled. Always supply the full element count when you want to preserve the array's logical size — fetch the current count via `mxa read '...[]' --llm-json` first, or read it from `array_dimension` in [`../../grdb/queries/attributes.sql`](../../grdb/queries/attributes.sql).
|
||||||
|
>
|
||||||
|
> Mixing scalar / array forms is guarded: passing multiple values without `[]` exits 2 with a clear error message.
|
||||||
|
|
||||||
### What does *not* work
|
### What does *not* work
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -198,14 +219,14 @@ mxa read 'MESReceiver_001.MoveInPartNumbers' # bare ref, no brackets
|
|||||||
# → MxCategoryCommunicationError, Detail=1003
|
# → MxCategoryCommunicationError, Detail=1003
|
||||||
```
|
```
|
||||||
|
|
||||||
The plain reference (no `[]`, no `[N]`) is rejected by the proxy. Always include the brackets — empty for whole-array, indexed for element.
|
The plain reference (no `[]`, no `[N]`) is rejected by the proxy on both read and write. Always include the brackets — empty for whole-array, indexed for element.
|
||||||
|
|
||||||
### Discovering array length
|
### Discovering array length
|
||||||
|
|
||||||
The CLI doesn't (yet) auto-discover element count. Two ways to find it:
|
The CLI doesn't (yet) auto-discover element count. Two ways to find it:
|
||||||
|
|
||||||
1. Read with `[]` and count the returned values.
|
1. Read with `[]` and count the returned values (this is the **runtime** length, which may have been resized by a previous bulk write).
|
||||||
2. Query the Galaxy Repository's [`../../grdb/queries/attributes.sql`](../../grdb/queries/attributes.sql) — the `array_dimension` column reports the configured size from the template.
|
2. Query the Galaxy Repository's [`../../grdb/queries/attributes.sql`](../../grdb/queries/attributes.sql) — the `array_dimension` column reports the **configured** size from the template at deploy time.
|
||||||
|
|
||||||
## Picking a tag for a smoke test
|
## Picking a tag for a smoke test
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using CliFx;
|
using CliFx;
|
||||||
using CliFx.Attributes;
|
using CliFx.Attributes;
|
||||||
@@ -9,16 +11,16 @@ using MxAccess.Cli.Output;
|
|||||||
|
|
||||||
namespace MxAccess.Cli.Commands
|
namespace MxAccess.Cli.Commands
|
||||||
{
|
{
|
||||||
[Command("write", Description = "Write a value to a tag and wait for OnWriteComplete.")]
|
[Command("write", Description = "Write a scalar or array value to a tag and wait for OnWriteComplete.")]
|
||||||
public sealed class WriteCommand : ICommand
|
public sealed class WriteCommand : ICommand
|
||||||
{
|
{
|
||||||
[CommandParameter(0, Name = "tag", Description = "Tag reference to write to.")]
|
[CommandParameter(0, Name = "tag", Description = "Tag reference to write to. Use '<obj>.<attr>[]' to write a whole array.")]
|
||||||
public string Tag { get; init; }
|
public string Tag { get; init; }
|
||||||
|
|
||||||
[CommandParameter(1, Name = "value", Description = "Value to write. Inferred as bool / int / double / string unless --type is set.")]
|
[CommandParameter(1, Name = "values", Description = "Value(s) to write. One value for a scalar tag; multiple values for an array tag (the count must match the configured array dimension). Inferred as bool / int / double / string unless --type is set.")]
|
||||||
public string RawValue { get; init; }
|
public IReadOnlyList<string> Values { get; init; }
|
||||||
|
|
||||||
[CommandOption("type", Description = "Force the .NET type used for the value: bool, byte, short, int, long, float, double, string, datetime.")]
|
[CommandOption("type", Description = "Force the .NET type used for the value(s): bool, byte, short, int, long, float, double, string, datetime.")]
|
||||||
public string TypeHint { get; init; }
|
public string TypeHint { get; init; }
|
||||||
|
|
||||||
[CommandOption("timeout", 't', Description = "Seconds to wait for OnWriteComplete (and for the initial OnDataChange resolving the type).")]
|
[CommandOption("timeout", 't', Description = "Seconds to wait for OnWriteComplete (and for the initial OnDataChange resolving the type).")]
|
||||||
@@ -37,19 +39,32 @@ namespace MxAccess.Cli.Commands
|
|||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(Tag))
|
if (string.IsNullOrWhiteSpace(Tag))
|
||||||
throw new CommandException("Tag reference is required.", 2);
|
throw new CommandException("Tag reference is required.", 2);
|
||||||
if (RawValue == null)
|
if (Values == null || Values.Count == 0)
|
||||||
throw new CommandException("Value is required.", 2);
|
throw new CommandException("At least one value is required.", 2);
|
||||||
if (TimeoutSeconds <= 0)
|
if (TimeoutSeconds <= 0)
|
||||||
throw new CommandException("--timeout must be positive.", 2);
|
throw new CommandException("--timeout must be positive.", 2);
|
||||||
|
|
||||||
var coerced = ValueCoercion.Coerce(RawValue, TypeHint);
|
// Decide scalar vs. array. The tag form `<obj>.<attr>[]` is the
|
||||||
|
// explicit array reference for whole-array writes (matches the
|
||||||
|
// read-side bracket convention). Multiple values without `[]`
|
||||||
|
// is an error: an indexed `[N]` reference takes one value.
|
||||||
|
bool isArrayWrite = Tag.EndsWith("[]", StringComparison.Ordinal);
|
||||||
|
if (!isArrayWrite && Values.Count > 1)
|
||||||
|
throw new CommandException(
|
||||||
|
"Multiple values supplied for a non-array reference. Use '<tag>[]' to write a whole array, or supply a single value for an indexed / scalar tag.", 2);
|
||||||
|
|
||||||
|
object coerced = isArrayWrite
|
||||||
|
? ValueCoercion.CoerceArray(Values, TypeHint)
|
||||||
|
: ValueCoercion.Coerce(Values[0], TypeHint);
|
||||||
|
|
||||||
var query = new
|
var query = new
|
||||||
{
|
{
|
||||||
command = "write",
|
command = "write",
|
||||||
tag = Tag,
|
tag = Tag,
|
||||||
|
array = isArrayWrite,
|
||||||
value = coerced,
|
value = coerced,
|
||||||
type = string.IsNullOrEmpty(TypeHint) ? coerced.GetType().Name : TypeHint,
|
type = string.IsNullOrEmpty(TypeHint) ? coerced.GetType().Name : TypeHint,
|
||||||
|
count = Values.Count,
|
||||||
timeout_s = TimeoutSeconds,
|
timeout_s = TimeoutSeconds,
|
||||||
user_id = UserId,
|
user_id = UserId,
|
||||||
client = ClientName,
|
client = ClientName,
|
||||||
@@ -61,17 +76,29 @@ namespace MxAccess.Cli.Commands
|
|||||||
{
|
{
|
||||||
item = session.AddItem(Tag);
|
item = session.AddItem(Tag);
|
||||||
|
|
||||||
// Advise + wait for first OnDataChange to ensure the proxy has the
|
// Advise + wait for first OnDataChange to ensure the proxy has
|
||||||
// attribute type / data quality resolved. Calling Write before
|
// the attribute type / data quality resolved. Without this the
|
||||||
// resolution returns ArgumentException "Value does not fall within
|
// first Write() throws ArgumentException "Value does not fall
|
||||||
// the expected range".
|
// within the expected range" because the proxy doesn't yet
|
||||||
|
// know the destination type.
|
||||||
|
//
|
||||||
|
// Caveat: a bare-array reference (no brackets) will return
|
||||||
|
// MxCategoryCommunicationError, Detail=1003 here — same as on
|
||||||
|
// a `read` of that form. Tag the user-facing error so the
|
||||||
|
// failure mode is recognizable.
|
||||||
item.Advise();
|
item.Advise();
|
||||||
var resolveTimeout = TimeSpan.FromSeconds(TimeoutSeconds);
|
var resolveTimeout = TimeSpan.FromSeconds(TimeoutSeconds);
|
||||||
if (!session.WaitForUpdate(
|
if (!session.WaitForUpdate(
|
||||||
u => u.Kind == MxUpdateKind.DataChange && u.ItemHandle == item.Handle,
|
u => u.Kind == MxUpdateKind.DataChange && u.ItemHandle == item.Handle,
|
||||||
resolveTimeout, out _))
|
resolveTimeout, out var resolveUpdate))
|
||||||
{
|
{
|
||||||
EmitFailure(console, query, "timeout-resolving-type");
|
EmitFailure(console, query, "timeout-resolving-type", Array.Empty<MxStatusInfo>());
|
||||||
|
Environment.ExitCode = 1;
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
if (!resolveUpdate.IsOk)
|
||||||
|
{
|
||||||
|
EmitFailure(console, query, "type-resolution-failed", resolveUpdate.Statuses);
|
||||||
Environment.ExitCode = 1;
|
Environment.ExitCode = 1;
|
||||||
return default;
|
return default;
|
||||||
}
|
}
|
||||||
@@ -111,12 +138,15 @@ namespace MxAccess.Cli.Commands
|
|||||||
}
|
}
|
||||||
else if (ok)
|
else if (ok)
|
||||||
{
|
{
|
||||||
console.Output.WriteLine($"[OK ] write {Tag} = {coerced}");
|
var rendered = isArrayWrite
|
||||||
|
? $"[{string.Join(", ", Values)}] ({Values.Count} elements)"
|
||||||
|
: Values[0];
|
||||||
|
console.Output.WriteLine($"[OK ] write {Tag} = {rendered}");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var err = (string)((dynamic)results[0]).error ?? "unknown";
|
var err = (string)((dynamic)results[0]).error ?? "unknown";
|
||||||
console.Error.WriteLine($"[ERR] write {Tag} = {coerced}: {err}");
|
console.Error.WriteLine($"[ERR] write {Tag}: {err}");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ok) Environment.ExitCode = 1;
|
if (!ok) Environment.ExitCode = 1;
|
||||||
@@ -128,12 +158,12 @@ namespace MxAccess.Cli.Commands
|
|||||||
return default;
|
return default;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void EmitFailure(IConsole console, object query, string error)
|
private void EmitFailure(IConsole console, object query, string error, MxStatusInfo[] statuses)
|
||||||
{
|
{
|
||||||
if (LlmJson)
|
if (LlmJson)
|
||||||
{
|
{
|
||||||
Envelope.Write(console, query, ok: false,
|
Envelope.Write(console, query, ok: false,
|
||||||
results: new object[] { new { tag = Tag, ok = false, error, statuses = Array.Empty<MxStatusInfo>() } });
|
results: new object[] { new { tag = Tag, ok = false, error, statuses } });
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
|
||||||
namespace MxAccess.Cli.Mx
|
namespace MxAccess.Cli.Mx
|
||||||
@@ -38,6 +39,64 @@ namespace MxAccess.Cli.Mx
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a strongly-typed array (`bool[]`, `int[]`, `string[]`, …) from
|
||||||
|
/// a list of raw string values. The CLR marshals strongly-typed arrays
|
||||||
|
/// to a COM SAFEARRAY of the matching VARTYPE, which is what MxAccess
|
||||||
|
/// expects for an array attribute write.
|
||||||
|
public static object CoerceArray(IReadOnlyList<string> values, string typeHint)
|
||||||
|
{
|
||||||
|
if (values == null) throw new ArgumentNullException(nameof(values));
|
||||||
|
|
||||||
|
// Default array element type when --type isn't specified: peek at
|
||||||
|
// the first value's inferred type and assume the rest match. For
|
||||||
|
// an empty list, fall back to string[].
|
||||||
|
string effectiveHint = typeHint;
|
||||||
|
if (string.IsNullOrEmpty(effectiveHint))
|
||||||
|
{
|
||||||
|
if (values.Count == 0) effectiveHint = "string";
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var first = InferAndCoerce(values[0]);
|
||||||
|
effectiveHint = first switch
|
||||||
|
{
|
||||||
|
bool _ => "bool",
|
||||||
|
int _ => "int",
|
||||||
|
long _ => "long",
|
||||||
|
double _ => "double",
|
||||||
|
_ => "string",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (effectiveHint.Trim().ToLowerInvariant())
|
||||||
|
{
|
||||||
|
case "bool": return Convert<bool>(values, s => ParseBool(s));
|
||||||
|
case "byte": return Convert<byte>(values, s => byte.Parse(s, CultureInfo.InvariantCulture));
|
||||||
|
case "short": return Convert<short>(values, s => short.Parse(s, CultureInfo.InvariantCulture));
|
||||||
|
case "int":
|
||||||
|
case "int32": return Convert<int>(values, s => int.Parse(s, CultureInfo.InvariantCulture));
|
||||||
|
case "long":
|
||||||
|
case "int64": return Convert<long>(values, s => long.Parse(s, CultureInfo.InvariantCulture));
|
||||||
|
case "float":
|
||||||
|
case "single": return Convert<float>(values, s => float.Parse(s, CultureInfo.InvariantCulture));
|
||||||
|
case "double": return Convert<double>(values, s => double.Parse(s, CultureInfo.InvariantCulture));
|
||||||
|
case "string": return Convert<string>(values, s => s);
|
||||||
|
case "time":
|
||||||
|
case "datetime":
|
||||||
|
return Convert<DateTime>(values, s => DateTime.Parse(s, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal));
|
||||||
|
default:
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"Unknown --type '{effectiveHint}'. Supported: bool, byte, short, int, long, float, double, string, datetime.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static T[] Convert<T>(IReadOnlyList<string> raw, Func<string, T> parse)
|
||||||
|
{
|
||||||
|
var arr = new T[raw.Count];
|
||||||
|
for (int i = 0; i < raw.Count; i++) arr[i] = parse(raw[i]);
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
|
||||||
private static object InferAndCoerce(string raw)
|
private static object InferAndCoerce(string raw)
|
||||||
{
|
{
|
||||||
if (ParseBool(raw, out var b)) return b;
|
if (ParseBool(raw, out var b)) return b;
|
||||||
|
|||||||
Reference in New Issue
Block a user