diff --git a/CHANGELOG.md b/CHANGELOG.md index c2a0e3d..654ad3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -185,6 +185,14 @@ 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. + `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()`, `RemoteDirectoryListing.stream()` and the closing of a `RemoteProcess` let raw 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/main/java/org/metricshub/winrm/WqlRow.java b/src/main/java/org/metricshub/winrm/WqlRow.java index 5c432f5..7e8238d 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 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 efc9d73..87cc705 100644 --- a/src/site/markdown/wql.md +++ b/src/site/markdown/wql.md @@ -122,6 +122,11 @@ 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 or `NULL` array is left out of the +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 These rules apply to `WqlResult.columns()`; each row's `asMap()` keeps the server's order, and 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 2075504..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,30 @@ 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 + 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