From 02a374259b10835fac804389383702ec28490646 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 20 Aug 2026 15:54:27 -0400 Subject: [PATCH] =?UTF-8?q?feat(cli):=20instance=20bind-module=20=E2=80=94?= =?UTF-8?q?=20rebind=20one=20composed=20module's=20members=20to=20a=20new?= =?UTF-8?q?=20object=20path=20via=20GetInstance=20=E2=86=92=20SetConnectio?= =?UTF-8?q?nBindings=20upsert;=20--dry-run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Commands/InstanceCommands.cs | 163 ++++++++++++++++++ .../InstanceArgumentParsingTests.cs | 96 +++++++++++ 2 files changed, 259 insertions(+) diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/InstanceCommands.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/InstanceCommands.cs index 5e59db00..9c4494dc 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/InstanceCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/InstanceCommands.cs @@ -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 urlOption, Option formatOption, Option usernameOption, Option passwordOption) + { + var idOption = new Option("--id") { Description = "Instance ID", Required = true }; + var moduleOption = new Option("--module") + { + Description = "Module (canonical-name prefix), e.g. RightMESReceiver; use '(instance)' for the instance's direct attributes", + Required = true + }; + var connOption = new Option("--connection") { Description = "Data connection ID", Required = true }; + var objectOption = new Option("--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("--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 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; + } + + /// + /// Pure core of instance bind-module: selects the existing bindings that belong + /// to (canonical names starting with {module}., or + /// dot-less names for (instance)) and rewrites each override to + /// {objectPath}.{memberTail} on . An empty + /// clears the override. Only the module's rows are + /// returned, so the upsert leaves every other module untouched. + /// + /// The instance's current bindings (from GetInstance). + /// Module prefix, or (instance) / . for direct attributes. + /// Data connection to bind the module's members to. + /// New object path; empty/whitespace clears the overrides. + /// The module's rewritten bindings, or null on failure. + /// Descriptive error listing the available modules, or null on success. + /// True when at least one binding matched the module; false otherwise. + internal static bool BuildModuleRewrite( + IReadOnlyList existing, + string module, + int connectionId, + string? objectPath, + out List? 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; + } + + /// + /// Reads the connectionBindings array out of a GetInstance response body + /// (camelCase, as the management API emits it) into typed bindings. A missing or + /// null array yields an empty list; malformed JSON throws + /// for the caller to report. + /// + /// The GetInstance success body. + /// The instance's current bindings. + internal static List ParseExistingBindings(string json) + { + var result = new List(); + 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; + } + /// /// Parses the --bindings argument — a JSON array of binding entries — into a /// typed list. Each entry is either a two-element diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/InstanceArgumentParsingTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/InstanceArgumentParsingTests.cs index 2a3ccb04..36cab3be 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/InstanceArgumentParsingTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/InstanceArgumentParsingTests.cs @@ -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("--url"), new Option("--format"), + new Option("--username"), new Option("--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); + } }