feat(cli): --element-type and JSON --value for List attributes

This commit is contained in:
Joseph Doherty
2026-06-16 16:18:08 -04:00
parent 0164f8a0d6
commit 85db4571b2
3 changed files with 307 additions and 19 deletions
@@ -138,9 +138,10 @@ public static class TemplateCommands
var templateIdOption = new Option<int>("--template-id") { Description = "Template ID", Required = true }; var templateIdOption = new Option<int>("--template-id") { Description = "Template ID", Required = true };
var nameOption = new Option<string>("--name") { Description = "Attribute name", Required = true }; var nameOption = new Option<string>("--name") { Description = "Attribute name", Required = true };
var dataTypeOption = new Option<string>("--data-type") { Description = "Data type", Required = true }; var dataTypeOption = new Option<string>("--data-type") { Description = "Data type", Required = true };
var valueOption = new Option<string?>("--value") { Description = "Default value" }; var valueOption = new Option<string?>("--value") { Description = "Default value. For a List attribute, supply a JSON array (e.g. '[\"WO-1\",\"WO-2\"]')." };
var descOption = new Option<string?>("--description") { Description = "Description" }; var descOption = new Option<string?>("--description") { Description = "Description" };
var sourceOption = new Option<string?>("--data-source") { Description = "Data source reference" }; var sourceOption = new Option<string?>("--data-source") { Description = "Data source reference" };
var elementTypeOption = new Option<string?>("--element-type") { Description = ElementTypeOptionDescription };
var lockedOption = new Option<bool>("--locked") { Description = "Lock status" }; var lockedOption = new Option<bool>("--locked") { Description = "Lock status" };
lockedOption.DefaultValueFactory = _ => false; lockedOption.DefaultValueFactory = _ => false;
@@ -151,28 +152,39 @@ public static class TemplateCommands
addCmd.Add(valueOption); addCmd.Add(valueOption);
addCmd.Add(descOption); addCmd.Add(descOption);
addCmd.Add(sourceOption); addCmd.Add(sourceOption);
addCmd.Add(elementTypeOption);
addCmd.Add(lockedOption); addCmd.Add(lockedOption);
addCmd.SetAction(async (ParseResult result) => addCmd.SetAction(async (ParseResult result) =>
{ {
var dataType = result.GetValue(dataTypeOption)!;
var elementType = result.GetValue(elementTypeOption);
if (!TryValidateElementType(dataType, elementType, out var error))
{
OutputFormatter.WriteError(error!, "INVALID_ARGUMENT");
return 1;
}
return await CommandHelpers.ExecuteCommandAsync( return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, result, urlOption, formatOption, usernameOption, passwordOption,
new AddTemplateAttributeCommand( BuildAddAttributeCommand(
result.GetValue(templateIdOption), result.GetValue(templateIdOption),
result.GetValue(nameOption)!, result.GetValue(nameOption)!,
result.GetValue(dataTypeOption)!, dataType,
result.GetValue(valueOption), result.GetValue(valueOption),
result.GetValue(descOption), result.GetValue(descOption),
result.GetValue(sourceOption), result.GetValue(sourceOption),
result.GetValue(lockedOption))); result.GetValue(lockedOption),
elementType));
}); });
group.Add(addCmd); group.Add(addCmd);
var updateIdOption = new Option<int>("--id") { Description = "Attribute ID", Required = true }; var updateIdOption = new Option<int>("--id") { Description = "Attribute ID", Required = true };
var updateNameOption = new Option<string>("--name") { Description = "Attribute name", Required = true }; var updateNameOption = new Option<string>("--name") { Description = "Attribute name", Required = true };
var updateDataTypeOption = new Option<string>("--data-type") { Description = "Data type", Required = true }; var updateDataTypeOption = new Option<string>("--data-type") { Description = "Data type", Required = true };
var updateValueOption = new Option<string?>("--value") { Description = "Default value" }; var updateValueOption = new Option<string?>("--value") { Description = "Default value. For a List attribute, supply a JSON array (e.g. '[\"WO-1\",\"WO-2\"]')." };
var updateDescOption = new Option<string?>("--description") { Description = "Description" }; var updateDescOption = new Option<string?>("--description") { Description = "Description" };
var updateSourceOption = new Option<string?>("--data-source") { Description = "Data source reference" }; var updateSourceOption = new Option<string?>("--data-source") { Description = "Data source reference" };
var updateElementTypeOption = new Option<string?>("--element-type") { Description = ElementTypeOptionDescription };
var updateLockedOption = new Option<bool>("--locked") { Description = "Lock status" }; var updateLockedOption = new Option<bool>("--locked") { Description = "Lock status" };
updateLockedOption.DefaultValueFactory = _ => false; updateLockedOption.DefaultValueFactory = _ => false;
@@ -183,19 +195,29 @@ public static class TemplateCommands
updateCmd.Add(updateValueOption); updateCmd.Add(updateValueOption);
updateCmd.Add(updateDescOption); updateCmd.Add(updateDescOption);
updateCmd.Add(updateSourceOption); updateCmd.Add(updateSourceOption);
updateCmd.Add(updateElementTypeOption);
updateCmd.Add(updateLockedOption); updateCmd.Add(updateLockedOption);
updateCmd.SetAction(async (ParseResult result) => updateCmd.SetAction(async (ParseResult result) =>
{ {
var dataType = result.GetValue(updateDataTypeOption)!;
var elementType = result.GetValue(updateElementTypeOption);
if (!TryValidateElementType(dataType, elementType, out var error))
{
OutputFormatter.WriteError(error!, "INVALID_ARGUMENT");
return 1;
}
return await CommandHelpers.ExecuteCommandAsync( return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, result, urlOption, formatOption, usernameOption, passwordOption,
new UpdateTemplateAttributeCommand( BuildUpdateAttributeCommand(
result.GetValue(updateIdOption), result.GetValue(updateIdOption),
result.GetValue(updateNameOption)!, result.GetValue(updateNameOption)!,
result.GetValue(updateDataTypeOption)!, dataType,
result.GetValue(updateValueOption), result.GetValue(updateValueOption),
result.GetValue(updateDescOption), result.GetValue(updateDescOption),
result.GetValue(updateSourceOption), result.GetValue(updateSourceOption),
result.GetValue(updateLockedOption))); result.GetValue(updateLockedOption),
elementType));
}); });
group.Add(updateCmd); group.Add(updateCmd);
@@ -213,6 +235,82 @@ public static class TemplateCommands
return group; return group;
} }
/// <summary>Shared description for the <c>--element-type</c> option on attribute add/update.</summary>
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>
private static readonly string[] ValidElementScalars =
{ "String", "Int32", "Float", "Double", "Boolean", "DateTime" };
/// <summary>
/// Validates the <c>--data-type</c> / <c>--element-type</c> combination client-side so
/// the CLI fails fast with a clear message before contacting the Management API (the
/// server validates independently). A List attribute requires a valid element scalar;
/// a non-List attribute must not carry an element type. Comparison is case-insensitive.
/// </summary>
/// <param name="dataType">The raw <c>--data-type</c> value.</param>
/// <param name="elementType">The raw <c>--element-type</c> value, or null if absent.</param>
/// <param name="error">A descriptive error message when validation fails; otherwise null.</param>
/// <returns><c>true</c> when the combination is valid; otherwise <c>false</c>.</returns>
internal static bool TryValidateElementType(string dataType, string? elementType, out string? error)
{
error = null;
var isList = string.Equals(dataType, "List", StringComparison.OrdinalIgnoreCase);
var hasElementType = !string.IsNullOrWhiteSpace(elementType);
if (isList)
{
if (!hasElementType)
{
error = "--element-type is required when --data-type is List "
+ "(one of: String, Int32, Float, Double, Boolean, DateTime).";
return false;
}
if (!ValidElementScalars.Contains(elementType!.Trim(), StringComparer.OrdinalIgnoreCase))
{
error = $"Invalid --element-type '{elementType}'. Valid List element scalars are: "
+ string.Join(", ", ValidElementScalars) + ".";
return false;
}
return true;
}
if (hasElementType)
{
error = "--element-type is only valid when --data-type is List.";
return false;
}
return true;
}
/// <summary>
/// Builds the <see cref="AddTemplateAttributeCommand"/> payload sent to the Management API.
/// The raw <paramref name="value"/> string is forwarded unchanged — for a List attribute it
/// is a JSON array, which the API/codec parses; the CLI does not reshape it.
/// </summary>
internal static AddTemplateAttributeCommand BuildAddAttributeCommand(
int templateId, string name, string dataType, string? value,
string? description, string? dataSource, bool isLocked, string? elementType)
=> new(templateId, name, dataType, value, description, dataSource, isLocked, NormalizeElementType(elementType));
/// <summary>
/// Builds the <see cref="UpdateTemplateAttributeCommand"/> payload sent to the Management API.
/// The raw <paramref name="value"/> string is forwarded unchanged — for a List attribute it
/// is a JSON array, which the API/codec parses; the CLI does not reshape it.
/// </summary>
internal static UpdateTemplateAttributeCommand BuildUpdateAttributeCommand(
int attributeId, string name, string dataType, string? value,
string? description, string? dataSource, bool isLocked, string? elementType)
=> new(attributeId, name, dataType, value, description, dataSource, isLocked, NormalizeElementType(elementType));
/// <summary>Trims a non-empty element type; an empty/whitespace value becomes null (no element type).</summary>
private static string? NormalizeElementType(string? elementType)
=> string.IsNullOrWhiteSpace(elementType) ? null : elementType.Trim();
private static Command BuildAlarm(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption) private static Command BuildAlarm(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{ {
var group = new Command("alarm") { Description = "Manage template alarms" }; var group = new Command("alarm") { Description = "Manage template alarms" };
+29 -11
View File
@@ -164,32 +164,50 @@ scadabridge --url <url> template validate --id <int>
Add an attribute to a template. Add an attribute to a template.
```sh ```sh
scadabridge --url <url> template attribute add --template-id <int> --name <string> --data-type <string> [--default-value <string>] [--tag-path <string>] scadabridge --url <url> template attribute add --template-id <int> --name <string> --data-type <string> [--value <string>] [--element-type <string>] [--description <string>] [--data-source <string>] [--locked]
``` ```
| Option | Required | Description | | Option | Required | Description |
|--------|----------|-------------| |--------|----------|-------------|
| `--template-id` | yes | Template ID | | `--template-id` | yes | Template ID |
| `--name` | yes | Attribute name | | `--name` | yes | Attribute name |
| `--data-type` | yes | Attribute data type (e.g. `Float`, `Int`, `String`, `Bool`) | | `--data-type` | yes | Attribute data type (`Boolean`, `Int32`, `Float`, `Double`, `String`, `DateTime`, `List`) |
| `--default-value` | no | Default value | | `--value` | no | Default value. For a `List` attribute, supply a JSON array (e.g. `'["WO-1","WO-2"]'`); the raw string is forwarded to the API, which parses it |
| `--tag-path` | no | Data connection tag path | | `--element-type` | no | Element scalar type for a `List` attribute (`String`, `Int32`, `Float`, `Double`, `Boolean`, `DateTime`). **Required when `--data-type` is `List`**; must be omitted otherwise |
| `--description` | no | Description |
| `--data-source` | no | Data source reference |
| `--locked` | no | Lock the attribute in derived templates |
**List example** — add a multi-value String attribute with two default elements:
```sh
scadabridge --url <url> template attribute add --template-id 7 --name WorkOrders \
--data-type List --element-type String --value '["WO-1","WO-2"]'
```
The CLI validates the data-type / element-type combination locally before calling the
API: `--data-type List` requires a valid `--element-type`, and `--element-type` may only
be supplied when `--data-type` is `List`. The Management API re-validates server-side.
#### `template attribute update` #### `template attribute update`
Update an attribute on a template. Update an attribute on a template. An update **replaces** the whole entity — every
required field below must be supplied with its post-update value, even if unchanged.
```sh ```sh
scadabridge --url <url> template attribute update --template-id <int> --name <string> [--data-type <string>] [--default-value <string>] [--tag-path <string>] scadabridge --url <url> template attribute update --id <int> --name <string> --data-type <string> [--value <string>] [--element-type <string>] [--description <string>] [--data-source <string>] [--locked]
``` ```
| Option | Required | Description | | Option | Required | Description |
|--------|----------|-------------| |--------|----------|-------------|
| `--template-id` | yes | Template ID | | `--id` | yes | Attribute ID |
| `--name` | yes | Attribute name to update | | `--name` | yes | Attribute name |
| `--data-type` | no | Updated data type | | `--data-type` | yes | Attribute data type (`Boolean`, `Int32`, `Float`, `Double`, `String`, `DateTime`, `List`) |
| `--default-value` | no | Updated default value | | `--value` | no | Default value. For a `List` attribute, supply a JSON array (e.g. `'["WO-1","WO-2"]'`) |
| `--tag-path` | no | Updated tag path | | `--element-type` | no | Element scalar type for a `List` attribute (`String`, `Int32`, `Float`, `Double`, `Boolean`, `DateTime`). **Required when `--data-type` is `List`**; must be omitted otherwise |
| `--description` | no | Description |
| `--data-source` | no | Data source reference |
| `--locked` | no | Lock the attribute in derived templates |
#### `template attribute delete` #### `template attribute delete`
@@ -0,0 +1,172 @@
using System.CommandLine;
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
/// <summary>
/// MV-11: the <c>template attribute add</c> / <c>update</c> commands must support
/// structured multi-value (List) attributes — a new <c>--element-type</c> option,
/// a JSON-array <c>--value</c>, client-side element-type validation, and
/// <see cref="AddTemplateAttributeCommand.ElementDataType"/> /
/// <see cref="UpdateTemplateAttributeCommand.ElementDataType"/> wired into the
/// payload sent to the Management API.
/// </summary>
public class TemplateAttributeListTests
{
private static readonly Option<string> Url = new("--url") { Recursive = true };
private static readonly Option<string> Username = new("--username") { Recursive = true };
private static readonly Option<string> Password = new("--password") { Recursive = true };
private static readonly Option<string> Format = CliOptions.CreateFormatOption();
private static Command AttributeGroup()
=> TemplateCommands.Build(Url, Format, Username, Password)
.Subcommands.Single(c => c.Name == "attribute");
// ---- option surface ----
[Fact]
public void AttributeAdd_HasElementTypeOption()
{
var add = AttributeGroup().Subcommands.Single(c => c.Name == "add");
Assert.Contains("--element-type", add.Options.Select(o => o.Name));
}
[Fact]
public void AttributeUpdate_HasElementTypeOption()
{
var update = AttributeGroup().Subcommands.Single(c => c.Name == "update");
Assert.Contains("--element-type", update.Options.Select(o => o.Name));
}
// ---- client-side element-type validation (both directions) ----
[Theory]
[InlineData("String")]
[InlineData("Int32")]
[InlineData("Float")]
[InlineData("Double")]
[InlineData("Boolean")]
[InlineData("DateTime")]
public void ValidateElementType_ListWithValidScalar_Ok(string elementType)
{
var ok = TemplateCommands.TryValidateElementType("List", elementType, out var error);
Assert.True(ok);
Assert.Null(error);
}
[Fact]
public void ValidateElementType_ListWithValidScalar_CaseInsensitive()
{
var ok = TemplateCommands.TryValidateElementType("List", "string", out var error);
Assert.True(ok);
Assert.Null(error);
}
[Fact]
public void ValidateElementType_ListWithoutElementType_Error()
{
var ok = TemplateCommands.TryValidateElementType("List", null, out var error);
Assert.False(ok);
Assert.NotNull(error);
}
[Fact]
public void ValidateElementType_ListWithBlankElementType_Error()
{
var ok = TemplateCommands.TryValidateElementType("List", " ", out var error);
Assert.False(ok);
Assert.NotNull(error);
}
[Fact]
public void ValidateElementType_ListWithInvalidScalar_Error()
{
var ok = TemplateCommands.TryValidateElementType("List", "List", out var error);
Assert.False(ok);
Assert.NotNull(error);
}
[Fact]
public void ValidateElementType_ListWithBinaryScalar_Error()
{
// Binary is a DataType but not a permitted List element scalar.
var ok = TemplateCommands.TryValidateElementType("List", "Binary", out var error);
Assert.False(ok);
Assert.NotNull(error);
}
[Fact]
public void ValidateElementType_ScalarWithElementType_Error()
{
var ok = TemplateCommands.TryValidateElementType("String", "Int32", out var error);
Assert.False(ok);
Assert.NotNull(error);
}
[Fact]
public void ValidateElementType_ScalarWithoutElementType_Ok()
{
var ok = TemplateCommands.TryValidateElementType("Float", null, out var error);
Assert.True(ok);
Assert.Null(error);
}
// ---- payload wiring: the raw JSON value + ElementDataType flow into the command ----
[Fact]
public void BuildAddCommand_ListAttribute_CarriesElementTypeAndRawJsonValue()
{
var cmd = TemplateCommands.BuildAddAttributeCommand(
templateId: 7,
name: "WorkOrders",
dataType: "List",
value: """["WO-1","WO-2"]""",
description: null,
dataSource: null,
isLocked: false,
elementType: "String");
Assert.Equal(7, cmd.TemplateId);
Assert.Equal("List", cmd.DataType);
Assert.Equal("String", cmd.ElementDataType);
// The CLI forwards the raw JSON string unchanged — the API/codec parses it.
Assert.Equal("""["WO-1","WO-2"]""", cmd.Value);
}
[Fact]
public void BuildUpdateCommand_ListAttribute_CarriesElementTypeAndRawJsonValue()
{
var cmd = TemplateCommands.BuildUpdateAttributeCommand(
attributeId: 42,
name: "WorkOrders",
dataType: "List",
value: """["A","B"]""",
description: null,
dataSource: null,
isLocked: false,
elementType: "Int32");
Assert.Equal(42, cmd.AttributeId);
Assert.Equal("List", cmd.DataType);
Assert.Equal("Int32", cmd.ElementDataType);
Assert.Equal("""["A","B"]""", cmd.Value);
}
[Fact]
public void BuildAddCommand_ScalarAttribute_LeavesElementTypeNull()
{
var cmd = TemplateCommands.BuildAddAttributeCommand(
templateId: 1,
name: "Speed",
dataType: "Float",
value: "0",
description: null,
dataSource: null,
isLocked: false,
elementType: null);
Assert.Null(cmd.ElementDataType);
Assert.Equal("Float", cmd.DataType);
}
}