From 3c130b8abc4147954f328bb6765ea8a9cd403df3 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Fri, 9 Oct 2026 21:36:31 +0200 Subject: [PATCH 1/3] Document how WQL arrays render: no trailing separator, 1-element and empty arrays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WinRM's XML carries no type information: a one-element array is identical to a scalar and an empty array is left out of the response (checked on Windows Server 2022, with both the SELECT * and the explicit-property response shapes). Only a CIM GetClass request could tell arrays apart, and it costs one more request per class and fails on 2008 R2 — not worth it to add a trailing "|". wmi-java drops its trailing separator instead, and the connectors that relied on it are fixed. Closes #201 Co-Authored-By: Claude Opus 5.5 --- src/main/java/org/metricshub/winrm/WqlRow.java | 5 +++++ src/site/markdown/wql.md | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/src/main/java/org/metricshub/winrm/WqlRow.java b/src/main/java/org/metricshub/winrm/WqlRow.java index 5c432f5..7516f26 100644 --- a/src/main/java/org/metricshub/winrm/WqlRow.java +++ b/src/main/java/org/metricshub/winrm/WqlRow.java @@ -27,6 +27,11 @@ /** * One row of a WQL query result: an immutable, ordered view of the instance properties. * Property lookup is case-insensitive, matching WMI semantics. + *

+ * A WMI array property is a single string, its elements joined with the client's array + * separator ({@code a|b}, no trailing separator). WinRM sends no type information: a + * one-element array reads like a scalar ({@code a}), and an empty array is left out of the + * response, so the row has no such property. */ public final class WqlRow { diff --git a/src/site/markdown/wql.md b/src/site/markdown/wql.md index efc9d73..2ee30d2 100644 --- a/src/site/markdown/wql.md +++ b/src/site/markdown/wql.md @@ -122,6 +122,10 @@ A WMI **array** property (`IPAddress` in `Win32_NetworkAdapterConfiguration`, `C `Win32_DiskDrive`, ...) comes back as a single string, its elements joined with `|`: `"192.0.2.10|fe80::1"`. The builder's `arraySeparator(String)` changes the separator. +There is no trailing separator, and WinRM sends no type information: a one-element array reads +like a scalar (`"fr-FR"` for `MUILanguages`), and an empty array is left out of the response, so +`string()` returns `null` for it. + ### Column order and case These rules apply to `WqlResult.columns()`; each row's `asMap()` keeps the server's order, and From 7ac378c6666d2d97af67619fd8e88fb14a98db54 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Sat, 10 Oct 2026 03:03:59 +0200 Subject: [PATCH 2/3] Keep a WQL row whose selected properties all came back empty WinRM leaves an empty or NULL array out of the response, so selecting only such a property yields instances with no property element, and the parser dropped them: SELECT IPSecPermitTCPPorts FROM Win32_NetworkAdapterConfiguration returned no rows on tc-win2022, where WMI returns one per adapter. Every instance is now a row (2 rows on that host), on which string() returns null for the property. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 ++++ .../java/org/metricshub/winrm/WqlRow.java | 4 ++-- .../metricshub/winrm/light/WsmanClient.java | 10 ++++---- src/site/markdown/wql.md | 5 ++-- .../winrm/light/WsmanClientParityTest.java | 23 +++++++++++++++++++ 5 files changed, 38 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2a0e3d..28ac455 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -185,6 +185,11 @@ Consequences: `WinRMClient.Builder.arraySeparator(String)` changes the separator. The `WqlRow` Javadoc now says what the code does: a WMI `NULL` is an empty string, `null` means "no such property". +- **A WQL row is no longer dropped when none of its selected properties came back** (#201). + WinRM leaves an empty or `NULL` array out of the response, so `SELECT IPSecPermitTCPPorts FROM + Win32_NetworkAdapterConfiguration` returned no rows at all, where WMI returns one per adapter. + Each instance is now a row, on which `string()` returns `null` for the missing property. + - **Streaming terminals now report protocol failures as `WinRMClientException`** (#188). `WqlRequest.stream()`, `CommandRequest.start()`, `RemoteFile.openStream()`/`openReader()`, `RemoteDirectoryListing.stream()` and the closing of a `RemoteProcess` let raw diff --git a/src/main/java/org/metricshub/winrm/WqlRow.java b/src/main/java/org/metricshub/winrm/WqlRow.java index 7516f26..7e8238d 100644 --- a/src/main/java/org/metricshub/winrm/WqlRow.java +++ b/src/main/java/org/metricshub/winrm/WqlRow.java @@ -30,8 +30,8 @@ *

* A WMI array property is a single string, its elements joined with the client's array * separator ({@code a|b}, no trailing separator). WinRM sends no type information: a - * one-element array reads like a scalar ({@code a}), and an empty array is left out of the - * response, so the row has no such property. + * one-element array reads like a scalar ({@code a}), and an empty or {@code NULL} array is left + * out of the response, so the row has no such property. */ public final class WqlRow { diff --git a/src/main/java/org/metricshub/winrm/light/WsmanClient.java b/src/main/java/org/metricshub/winrm/light/WsmanClient.java index 867dae9..788d4ef 100644 --- a/src/main/java/org/metricshub/winrm/light/WsmanClient.java +++ b/src/main/java/org/metricshub/winrm/light/WsmanClient.java @@ -1437,11 +1437,11 @@ private static void collectRows( value.append(arraySeparator).append(prop.getTextContent()); } } - if (!values.isEmpty()) { - final Map row = new LinkedHashMap<>(); - values.forEach((name, value) -> row.put(name, value.toString())); - rows.add(row); - } + // An instance with no property element is still a row: WinRM leaves out an empty (or + // NULL) array, so selecting only such a property yields empty instances + final Map row = new LinkedHashMap<>(); + values.forEach((name, value) -> row.put(name, value.toString())); + rows.add(row); } } } diff --git a/src/site/markdown/wql.md b/src/site/markdown/wql.md index 2ee30d2..571012a 100644 --- a/src/site/markdown/wql.md +++ b/src/site/markdown/wql.md @@ -123,8 +123,9 @@ A WMI **array** property (`IPAddress` in `Win32_NetworkAdapterConfiguration`, `C `"192.0.2.10|fe80::1"`. The builder's `arraySeparator(String)` changes the separator. There is no trailing separator, and WinRM sends no type information: a one-element array reads -like a scalar (`"fr-FR"` for `MUILanguages`), and an empty array is left out of the response, so -`string()` returns `null` for it. +like a scalar (`"fr-FR"` for `MUILanguages`), and an empty or `NULL` array is left out of the +response, so `string()` returns `null` for it. The instance is still a row, even when that array is +the only selected property. ### Column order and case diff --git a/src/test/java/org/metricshub/winrm/light/WsmanClientParityTest.java b/src/test/java/org/metricshub/winrm/light/WsmanClientParityTest.java index 2075504..acd893c 100644 --- a/src/test/java/org/metricshub/winrm/light/WsmanClientParityTest.java +++ b/src/test/java/org/metricshub/winrm/light/WsmanClientParityTest.java @@ -149,6 +149,29 @@ void arrayPropertyElementsAreJoinedWithTheSeparator() throws Exception { assertEquals("", rows.get(0).get("DefaultIPGateway")); } + @Test + void instanceWithNoPropertyElementIsStillARow() throws Exception { + // WinRM leaves out an empty array: selecting only such a property yields an empty fragment + final Document doc = parse( + "" + + "" + + "" + + "" + + "80" + + "" + + "" + ); + final List> rows = new ArrayList<>(); + WsmanClient.collectItems(doc, rows, "|"); + assertEquals(2, rows.size()); + assertEquals(Map.of(), rows.get(0)); + assertEquals("80", rows.get(1).get("IPSecPermitTCPPorts")); + } + @Test void wmiPropertyNamedItemsIsNotMistakenForTheWrapper() throws Exception { // A structured WMI property called Items (in the class's namespace) must not be read as a From 25672db31544f273866da5717fee1a7672d43fab Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Sat, 10 Oct 2026 03:15:31 +0200 Subject: [PATCH 3/3] Read the WQL column names of every row, not just the first one WinRM leaves out an empty or NULL array (checked again on tc-win2022: adapter 0's NULL DefaultIPGateway, IPAddress, ... are absent, not xsi:nil), so rows can carry different property sets. Taking the names from the first row only lost their case when that row was empty (and the legacy executor's case-sensitive lookup then missed the later values), and with SELECT * it hid IPAddress & co. from the columns of every row. The test fixture that showed a NULL array as xsi:nil now uses a scalar. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +++ .../java/org/metricshub/winrm/WmiHelper.java | 22 +++++++-------- src/site/markdown/wql.md | 4 +-- .../org/metricshub/winrm/WmiHelperTest.java | 27 +++++++++++++++++++ .../winrm/light/WsmanClientParityTest.java | 7 ++--- 5 files changed, 47 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28ac455..654ad3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -189,6 +189,9 @@ Consequences: WinRM leaves an empty or `NULL` array out of the response, so `SELECT IPSecPermitTCPPorts FROM Win32_NetworkAdapterConfiguration` returned no rows at all, where WMI returns one per adapter. Each instance is now a row, on which `string()` returns `null` for the missing property. + `WqlResult.columns()` now also reads the property names of every row, not just the first one: + with `SELECT * FROM Win32_NetworkAdapterConfiguration`, a first adapter without IP configuration + hid `IPAddress`, `DefaultIPGateway`, ... from the columns of all the rows. - **Streaming terminals now report protocol failures as `WinRMClientException`** (#188). `WqlRequest.stream()`, `CommandRequest.start()`, `RemoteFile.openStream()`/`openReader()`, diff --git a/src/main/java/org/metricshub/winrm/WmiHelper.java b/src/main/java/org/metricshub/winrm/WmiHelper.java index 03f4899..8b5fb75 100644 --- a/src/main/java/org/metricshub/winrm/WmiHelper.java +++ b/src/main/java/org/metricshub/winrm/WmiHelper.java @@ -20,7 +20,8 @@ * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ */ -import java.util.Arrays; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.TimeoutException; @@ -150,7 +151,7 @@ public static List extractPropertiesFromResult(final List extractPropertiesFromResult( return wqlQuery.getSelectedProperties(); } - // Extract the actual property names - final String[] resultPropertyArray = resultRows.get(0).keySet().toArray(new String[0]); + // Extract the actual property names from every row (lower case -> actual case): WinRM + // leaves out an empty or NULL array, so a row may lack properties that others have + final Map resultProperties = new LinkedHashMap<>(); + resultRows.forEach(row -> row.keySet().forEach(name -> resultProperties.putIfAbsent(name.toLowerCase(), name))); // First case: we don't have any specified properties in the WQL Query, so we just // return the properties from the result set in alphabetical order if (wqlQuery.getSelectedProperties().isEmpty()) { - Arrays.sort(resultPropertyArray, String.CASE_INSENSITIVE_ORDER); - return Arrays.asList(resultPropertyArray); + final List properties = new ArrayList<>(resultProperties.values()); + properties.sort(String.CASE_INSENSITIVE_ORDER); + return properties; } // Create a new list based on queryPropertyArray (with its order), but with the values - // from resultPropertyArray + // from resultProperties final List queryProperties = wqlQuery.getSelectedProperties(); - final Map resultProperties = Arrays - .asList(resultPropertyArray) - .stream() - .collect(Collectors.toMap(String::toLowerCase, property -> property)); return queryProperties .stream() .map(property -> resultProperties.getOrDefault(property.toLowerCase(), property)) diff --git a/src/site/markdown/wql.md b/src/site/markdown/wql.md index 571012a..87cc705 100644 --- a/src/site/markdown/wql.md +++ b/src/site/markdown/wql.md @@ -124,8 +124,8 @@ A WMI **array** property (`IPAddress` in `Win32_NetworkAdapterConfiguration`, `C There is no trailing separator, and WinRM sends no type information: a one-element array reads like a scalar (`"fr-FR"` for `MUILanguages`), and an empty or `NULL` array is left out of the -response, so `string()` returns `null` for it. The instance is still a row, even when that array is -the only selected property. +response, so `string()` returns `null` for it (unlike a scalar `NULL`, an empty string). The +instance is still a row, even when that array is the only selected property. ### Column order and case diff --git a/src/test/java/org/metricshub/winrm/WmiHelperTest.java b/src/test/java/org/metricshub/winrm/WmiHelperTest.java index 1c7091f..f69deaf 100644 --- a/src/test/java/org/metricshub/winrm/WmiHelperTest.java +++ b/src/test/java/org/metricshub/winrm/WmiHelperTest.java @@ -19,13 +19,40 @@ * limitations under the License. * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ */ +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.List; +import java.util.Map; import org.junit.jupiter.api.Test; class WmiHelperTest { + @Test + void extractPropertiesFromResultReadsTheNamesOfEveryRow() { + // WinRM leaves out an empty or NULL array: the first row may lack properties others have + final List> rows = List.of( + Map.of("Index", "0"), + Map.of("Index", "1", "IPAddress", "192.0.2.10") + ); + assertEquals( + List.of("Index", "IPAddress"), + WmiHelper.extractPropertiesFromResult(rows, "SELECT index, ipaddress FROM Win32_NetworkAdapterConfiguration") + ); + assertEquals( + List.of("Index", "IPAddress"), + WmiHelper.extractPropertiesFromResult(rows, "SELECT * FROM Win32_NetworkAdapterConfiguration") + ); + assertEquals( + List.of("IPAddress"), + WmiHelper.extractPropertiesFromResult( + List.of(Map.of(), Map.of("IPAddress", "192.0.2.10")), + "SELECT ipaddress FROM Win32_NetworkAdapterConfiguration" + ) + ); + } + @Test void isValidWqlAcceptsSingleLineQueries() { assertTrue(WmiHelper.isValidWql("SELECT * FROM Win32_Service")); diff --git a/src/test/java/org/metricshub/winrm/light/WsmanClientParityTest.java b/src/test/java/org/metricshub/winrm/light/WsmanClientParityTest.java index acd893c..e619d98 100644 --- a/src/test/java/org/metricshub/winrm/light/WsmanClientParityTest.java +++ b/src/test/java/org/metricshub/winrm/light/WsmanClientParityTest.java @@ -122,7 +122,8 @@ void itemsCollectedFromBothNamespaceVariants() throws Exception { @Test void arrayPropertyElementsAreJoinedWithTheSeparator() throws Exception { - // WS-Management sends a WMI array as sibling elements sharing one name; a WMI NULL is xsi:nil. + // WS-Management sends a WMI array as sibling elements sharing one name; a scalar NULL is xsi:nil + // (an empty or NULL array is left out). final Document doc = parse( "" + "192.0.2.10" + "eth0" + "fe80::1" + - "" + + "" + "" + "" + "" @@ -146,7 +147,7 @@ void arrayPropertyElementsAreJoinedWithTheSeparator() throws Exception { assertEquals(1, rows.size()); assertEquals("192.0.2.10, fe80::1", rows.get(0).get("IPAddress")); assertEquals("eth0", rows.get(0).get("Caption")); - assertEquals("", rows.get(0).get("DefaultIPGateway")); + assertEquals("", rows.get(0).get("DNSDomain")); } @Test