feat(cli): instance bind-module — rebind one composed module's members to a new object path via GetInstance → SetConnectionBindings upsert; --dry-run
This commit is contained in:
@@ -23,6 +23,7 @@ public static class InstanceCommands
|
||||
command.Add(BuildGet(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildCreate(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildSetBindings(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(CreateBindModuleCommand(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildSetOverrides(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildImportOverrides(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildAlarmOverride(urlOption, formatOption, usernameOption, passwordOption));
|
||||
@@ -86,6 +87,168 @@ public static class InstanceCommands
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command CreateBindModuleCommand(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Instance ID", Required = true };
|
||||
var moduleOption = new Option<string>("--module")
|
||||
{
|
||||
Description = "Module (canonical-name prefix), e.g. RightMESReceiver; use '(instance)' for the instance's direct attributes",
|
||||
Required = true
|
||||
};
|
||||
var connOption = new Option<int>("--connection") { Description = "Data connection ID", Required = true };
|
||||
var objectOption = new Option<string>("--object")
|
||||
{
|
||||
Description = "MxAccess/OPC object path the module's members hang off, e.g. MESReceiver_022 or Left_002.DelmiaReceiver; " +
|
||||
"an empty string clears the module's overrides (template defaults apply)",
|
||||
Required = true
|
||||
};
|
||||
var dryRunOption = new Option<bool>("--dry-run") { Description = "Print the resulting bindings without saving" };
|
||||
|
||||
var cmd = new Command("bind-module")
|
||||
{
|
||||
Description = "Rebind every member of one composed module to a new object path (upserts only that module's bindings; other modules are untouched)"
|
||||
};
|
||||
cmd.Add(idOption);
|
||||
cmd.Add(moduleOption);
|
||||
cmd.Add(connOption);
|
||||
cmd.Add(objectOption);
|
||||
cmd.Add(dryRunOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
var module = result.GetValue(moduleOption)!;
|
||||
var conn = result.GetValue(connOption);
|
||||
var obj = result.GetValue(objectOption);
|
||||
var dryRun = result.GetValue(dryRunOption);
|
||||
|
||||
// Step 1: fetch the instance. The success handler only captures the body so
|
||||
// the second round-trip can run as a proper await outside the callback.
|
||||
string? instanceJson = null;
|
||||
var exit = await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new GetInstanceCommand(id),
|
||||
onSuccess: json => { instanceJson = json; return 0; });
|
||||
if (exit != 0 || instanceJson is null)
|
||||
return exit == 0 ? 1 : exit;
|
||||
|
||||
List<ConnectionBinding> existing;
|
||||
try
|
||||
{
|
||||
existing = ParseExistingBindings(instanceJson);
|
||||
}
|
||||
catch (System.Text.Json.JsonException ex)
|
||||
{
|
||||
OutputFormatter.WriteError($"Could not read the instance's connection bindings: {ex.Message}", "INVALID_RESPONSE");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Step 2: rewrite just this module's rows client-side.
|
||||
if (!BuildModuleRewrite(existing, module, conn, obj, out var rewritten, out var error))
|
||||
{
|
||||
OutputFormatter.WriteError(error!, "UNKNOWN_MODULE");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (dryRun)
|
||||
{
|
||||
OutputFormatter.WriteJson(rewritten);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Step 3: SetConnectionBindings upserts by attribute name, so sending only the
|
||||
// module's rows leaves every other module's bindings as they were.
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new SetConnectionBindingsCommand(id, rewritten!));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure core of <c>instance bind-module</c>: selects the existing bindings that belong
|
||||
/// to <paramref name="module"/> (canonical names starting with <c>{module}.</c>, or
|
||||
/// dot-less names for <c>(instance)</c>) and rewrites each override to
|
||||
/// <c>{objectPath}.{memberTail}</c> on <paramref name="connectionId"/>. An empty
|
||||
/// <paramref name="objectPath"/> clears the override. Only the module's rows are
|
||||
/// returned, so the upsert leaves every other module untouched.
|
||||
/// </summary>
|
||||
/// <param name="existing">The instance's current bindings (from <c>GetInstance</c>).</param>
|
||||
/// <param name="module">Module prefix, or <c>(instance)</c> / <c>.</c> for direct attributes.</param>
|
||||
/// <param name="connectionId">Data connection to bind the module's members to.</param>
|
||||
/// <param name="objectPath">New object path; empty/whitespace clears the overrides.</param>
|
||||
/// <param name="rewritten">The module's rewritten bindings, or null on failure.</param>
|
||||
/// <param name="error">Descriptive error listing the available modules, or null on success.</param>
|
||||
/// <returns>True when at least one binding matched the module; false otherwise.</returns>
|
||||
internal static bool BuildModuleRewrite(
|
||||
IReadOnlyList<ConnectionBinding> existing,
|
||||
string module,
|
||||
int connectionId,
|
||||
string? objectPath,
|
||||
out List<ConnectionBinding>? rewritten,
|
||||
out string? error)
|
||||
{
|
||||
rewritten = null;
|
||||
error = null;
|
||||
var isInstance = module is "(instance)" or ".";
|
||||
var prefix = module + ".";
|
||||
var obj = (objectPath ?? string.Empty).Trim().TrimEnd('.');
|
||||
|
||||
var rows = existing
|
||||
.Where(b => isInstance
|
||||
? !b.AttributeName.Contains('.')
|
||||
: b.AttributeName.StartsWith(prefix, StringComparison.Ordinal))
|
||||
.Select(b =>
|
||||
{
|
||||
var tail = isInstance ? b.AttributeName : b.AttributeName[prefix.Length..];
|
||||
return new ConnectionBinding(b.AttributeName, connectionId, obj.Length == 0 ? null : obj + "." + tail);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
if (rows.Count == 0)
|
||||
{
|
||||
var available = existing
|
||||
.Select(b => b.AttributeName.Contains('.') ? b.AttributeName[..b.AttributeName.IndexOf('.')] : "(instance)")
|
||||
.Distinct()
|
||||
.OrderBy(s => s, StringComparer.Ordinal);
|
||||
error = $"Instance has no bindings for module '{module}'. Available: {string.Join(", ", available)}. " +
|
||||
"A module with no bindings yet must be seeded with 'instance set-bindings' (or bound once in the Central UI).";
|
||||
return false;
|
||||
}
|
||||
|
||||
rewritten = rows;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the <c>connectionBindings</c> array out of a <c>GetInstance</c> response body
|
||||
/// (camelCase, as the management API emits it) into typed bindings. A missing or
|
||||
/// <c>null</c> array yields an empty list; malformed JSON throws
|
||||
/// <see cref="System.Text.Json.JsonException"/> for the caller to report.
|
||||
/// </summary>
|
||||
/// <param name="json">The <c>GetInstance</c> success body.</param>
|
||||
/// <returns>The instance's current bindings.</returns>
|
||||
internal static List<ConnectionBinding> ParseExistingBindings(string json)
|
||||
{
|
||||
var result = new List<ConnectionBinding>();
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(json);
|
||||
if (doc.RootElement.ValueKind != System.Text.Json.JsonValueKind.Object
|
||||
|| !doc.RootElement.TryGetProperty("connectionBindings", out var array)
|
||||
|| array.ValueKind != System.Text.Json.JsonValueKind.Array)
|
||||
return result;
|
||||
|
||||
foreach (var item in array.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind != System.Text.Json.JsonValueKind.Object) continue;
|
||||
if (!item.TryGetProperty("attributeName", out var nameEl) || nameEl.ValueKind != System.Text.Json.JsonValueKind.String) continue;
|
||||
if (!item.TryGetProperty("dataConnectionId", out var connEl) || !connEl.TryGetInt32(out var connectionId)) continue;
|
||||
string? overrideRef = null;
|
||||
if (item.TryGetProperty("dataSourceReferenceOverride", out var ovEl) && ovEl.ValueKind == System.Text.Json.JsonValueKind.String)
|
||||
overrideRef = ovEl.GetString();
|
||||
result.Add(new ConnectionBinding(nameEl.GetString()!, connectionId, overrideRef));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the <c>--bindings</c> argument — a JSON array of binding entries — into a
|
||||
/// typed list. Each entry is either a two-element
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.CommandLine;
|
||||
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
|
||||
@@ -148,4 +149,99 @@ public class InstanceArgumentParsingTests
|
||||
Assert.Null(overrides);
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
|
||||
// --- instance bind-module (grouped connection bindings, 2026-08-20) ---
|
||||
|
||||
[Fact]
|
||||
public void BuildModuleRewrite_RewritesOnlyTheModule_AndKeepsConnection()
|
||||
{
|
||||
var existing = new[]
|
||||
{
|
||||
new ConnectionBinding("RightMESReceiver.MoveInFlag", 1, "MESReceiver_024.MoveInFlag"),
|
||||
new ConnectionBinding("RightMESReceiver.ScanStateCmd", 1, "MESReceiver_024.ScanStateCmd"),
|
||||
new ConnectionBinding("LeftMESReceiver.MoveInFlag", 1, "MESReceiver_023.MoveInFlag"),
|
||||
};
|
||||
var ok = InstanceCommands.BuildModuleRewrite(existing, "RightMESReceiver", connectionId: 1, objectPath: "MESReceiver_022", out var rewritten, out var error);
|
||||
Assert.True(ok, error);
|
||||
Assert.Equal(2, rewritten!.Count);
|
||||
Assert.All(rewritten, b => Assert.StartsWith("MESReceiver_022.", b.DataSourceReferenceOverride!));
|
||||
Assert.Contains(rewritten, b => b.AttributeName == "RightMESReceiver.MoveInFlag" && b.DataSourceReferenceOverride == "MESReceiver_022.MoveInFlag");
|
||||
Assert.DoesNotContain(rewritten, b => b.AttributeName.StartsWith("Left"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildModuleRewrite_InstanceGroup_MatchesDotlessNames()
|
||||
{
|
||||
var existing = new[] { new ConnectionBinding("ChlorineFlow", 1, "Z28061.ChlorineFlow"), new ConnectionBinding("M.A", 1, "O.A") };
|
||||
Assert.True(InstanceCommands.BuildModuleRewrite(existing, "(instance)", 1, "Z28063", out var r, out _));
|
||||
var b = Assert.Single(r!);
|
||||
Assert.Equal("Z28063.ChlorineFlow", b.DataSourceReferenceOverride);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildModuleRewrite_EmptyObject_ClearsOverride()
|
||||
{
|
||||
var existing = new[] { new ConnectionBinding("M.A", 1, "O.A") };
|
||||
Assert.True(InstanceCommands.BuildModuleRewrite(existing, "M", 2, "", out var r, out _));
|
||||
var b = Assert.Single(r!);
|
||||
Assert.Null(b.DataSourceReferenceOverride);
|
||||
Assert.Equal(2, b.DataConnectionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildModuleRewrite_TrailingDotOnObject_IsTrimmed()
|
||||
{
|
||||
var existing = new[] { new ConnectionBinding("M.A", 1, "O.A") };
|
||||
Assert.True(InstanceCommands.BuildModuleRewrite(existing, "M", 1, " X. ", out var r, out _));
|
||||
Assert.Equal("X.A", Assert.Single(r!).DataSourceReferenceOverride);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildModuleRewrite_UnknownModule_ListsAvailable()
|
||||
{
|
||||
var existing = new[] { new ConnectionBinding("M.A", 1, "O.A"), new ConnectionBinding("N.B", 1, "O.B"), new ConnectionBinding("Direct", 1, null) };
|
||||
Assert.False(InstanceCommands.BuildModuleRewrite(existing, "Q", 1, "X", out var rewritten, out var error));
|
||||
Assert.Null(rewritten);
|
||||
Assert.Contains("M", error);
|
||||
Assert.Contains("N", error);
|
||||
Assert.Contains("(instance)", error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseExistingBindings_ReadsCamelCaseInstanceJson()
|
||||
{
|
||||
const string json = """
|
||||
{"id":7,"uniqueName":"X","connectionBindings":[
|
||||
{"id":1,"instanceId":7,"attributeName":"M.A","dataConnectionId":3,"dataSourceReferenceOverride":"O.A"},
|
||||
{"id":2,"instanceId":7,"attributeName":"Direct","dataConnectionId":4,"dataSourceReferenceOverride":null},
|
||||
{"id":3,"instanceId":7,"attributeName":"NoOverrideProp","dataConnectionId":5}
|
||||
]}
|
||||
""";
|
||||
var bindings = InstanceCommands.ParseExistingBindings(json);
|
||||
Assert.Equal(3, bindings.Count);
|
||||
Assert.Equal(new ConnectionBinding("M.A", 3, "O.A"), bindings[0]);
|
||||
Assert.Equal(new ConnectionBinding("Direct", 4), bindings[1]);
|
||||
Assert.Equal(new ConnectionBinding("NoOverrideProp", 5), bindings[2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseExistingBindings_MissingArray_ReturnsEmpty()
|
||||
{
|
||||
Assert.Empty(InstanceCommands.ParseExistingBindings("""{"id":7}"""));
|
||||
Assert.Empty(InstanceCommands.ParseExistingBindings("""{"id":7,"connectionBindings":null}"""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BindModule_ParsesOptions()
|
||||
{
|
||||
var instance = InstanceCommands.Build(
|
||||
new Option<string>("--url"), new Option<string>("--format"),
|
||||
new Option<string>("--username"), new Option<string>("--password"));
|
||||
var bindModule = instance.Subcommands.Single(c => c.Name == "bind-module");
|
||||
var result = bindModule.Parse(["--id", "1", "--module", "RightMESReceiver", "--connection", "1", "--object", "MESReceiver_022", "--dry-run"]);
|
||||
Assert.Empty(result.Errors);
|
||||
|
||||
var missingObject = bindModule.Parse(["--id", "1", "--module", "M", "--connection", "1"]);
|
||||
Assert.NotEmpty(missingObject.Errors);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user