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 nameOption = new Option<string>("--name") { Description = "Attribute name", 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 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" };
lockedOption.DefaultValueFactory = _ => false;
@@ -151,28 +152,39 @@ public static class TemplateCommands
addCmd.Add(valueOption);
addCmd.Add(descOption);
addCmd.Add(sourceOption);
addCmd.Add(elementTypeOption);
addCmd.Add(lockedOption);
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(
result, urlOption, formatOption, usernameOption, passwordOption,
new AddTemplateAttributeCommand(
BuildAddAttributeCommand(
result.GetValue(templateIdOption),
result.GetValue(nameOption)!,
result.GetValue(dataTypeOption)!,
dataType,
result.GetValue(valueOption),
result.GetValue(descOption),
result.GetValue(sourceOption),
result.GetValue(lockedOption)));
result.GetValue(lockedOption),
elementType));
});
group.Add(addCmd);
var updateIdOption = new Option<int>("--id") { Description = "Attribute ID", 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 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 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" };
updateLockedOption.DefaultValueFactory = _ => false;
@@ -183,19 +195,29 @@ public static class TemplateCommands
updateCmd.Add(updateValueOption);
updateCmd.Add(updateDescOption);
updateCmd.Add(updateSourceOption);
updateCmd.Add(updateElementTypeOption);
updateCmd.Add(updateLockedOption);
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(
result, urlOption, formatOption, usernameOption, passwordOption,
new UpdateTemplateAttributeCommand(
BuildUpdateAttributeCommand(
result.GetValue(updateIdOption),
result.GetValue(updateNameOption)!,
result.GetValue(updateDataTypeOption)!,
dataType,
result.GetValue(updateValueOption),
result.GetValue(updateDescOption),
result.GetValue(updateSourceOption),
result.GetValue(updateLockedOption)));
result.GetValue(updateLockedOption),
elementType));
});
group.Add(updateCmd);
@@ -213,6 +235,82 @@ public static class TemplateCommands
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)
{
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.
```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 |
|--------|----------|-------------|
| `--template-id` | yes | Template ID |
| `--name` | yes | Attribute name |
| `--data-type` | yes | Attribute data type (e.g. `Float`, `Int`, `String`, `Bool`) |
| `--default-value` | no | Default value |
| `--tag-path` | no | Data connection tag path |
| `--data-type` | yes | Attribute data type (`Boolean`, `Int32`, `Float`, `Double`, `String`, `DateTime`, `List`) |
| `--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 |
| `--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`
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
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 |
|--------|----------|-------------|
| `--template-id` | yes | Template ID |
| `--name` | yes | Attribute name to update |
| `--data-type` | no | Updated data type |
| `--default-value` | no | Updated default value |
| `--tag-path` | no | Updated tag path |
| `--id` | yes | Attribute ID |
| `--name` | yes | Attribute name |
| `--data-type` | yes | Attribute data type (`Boolean`, `Int32`, `Float`, `Double`, `String`, `DateTime`, `List`) |
| `--value` | no | Default value. For a `List` attribute, supply a JSON array (e.g. `'["WO-1","WO-2"]'`) |
| `--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`
@@ -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);
}
}