diff --git a/docs/user/ppl/cmd/graphlookup.md b/docs/user/ppl/cmd/graphlookup.md index d1a4a589608..00754263c8c 100644 --- a/docs/user/ppl/cmd/graphlookup.md +++ b/docs/user/ppl/cmd/graphlookup.md @@ -93,19 +93,19 @@ source = employees The query returns the following results: ```text -+--------+----------+----+---------------------+ -| name | reportsTo| id | reportingHierarchy | -+--------+----------+----+---------------------+ -| Dev | Eliot | 1 | [{Eliot, Ron, 2}] | -| Eliot | Ron | 2 | [{Ron, Andrew, 3}] | -| Ron | Andrew | 3 | [{Andrew, null, 4}] | -| Andrew | null | 4 | [] | -| Asya | Ron | 5 | [{Ron, Andrew, 3}] | -| Dan | Andrew | 6 | [{Andrew, null, 4}] | -+--------+----------+----+---------------------+ ++--------+----------+----+-----------------------------------------------+ +| name | reportsTo| id | reportingHierarchy | ++--------+----------+----+-----------------------------------------------+ +| Dev | Eliot | 1 | [{name:Eliot, reportsTo:Ron, id:2}] | +| Eliot | Ron | 2 | [{name:Ron, reportsTo:Andrew, id:3}] | +| Ron | Andrew | 3 | [{name:Andrew, reportsTo:null, id:4}] | +| Andrew | null | 4 | [] | +| Asya | Ron | 5 | [{name:Ron, reportsTo:Andrew, id:3}] | +| Dan | Andrew | 6 | [{name:Andrew, reportsTo:null, id:4}] | ++--------+----------+----+-----------------------------------------------+ ``` -For Dev, the traversal starts with `reportsTo="Eliot"`, finds the Eliot record, and returns it in the `reportingHierarchy` array. +Each element in the `reportingHierarchy` array is a struct with named fields from the lookup index. For Dev, the traversal starts with `reportsTo="Eliot"`, finds the Eliot record, and returns it in the `reportingHierarchy` array. ## Example 2: Employee Hierarchy with Depth Tracking @@ -123,19 +123,19 @@ source = employees The query returns the following results: ```text -+--------+----------+----+------------------------+ -| name | reportsTo| id | reportingHierarchy | -+--------+----------+----+------------------------+ -| Dev | Eliot | 1 | [{Eliot, Ron, 2, 0}] | -| Eliot | Ron | 2 | [{Ron, Andrew, 3, 0}] | -| Ron | Andrew | 3 | [{Andrew, null, 4, 0}] | -| Andrew | null | 4 | [] | -| Asya | Ron | 5 | [{Ron, Andrew, 3, 0}] | -| Dan | Andrew | 6 | [{Andrew, null, 4, 0}] | -+--------+----------+----+------------------------+ ++--------+----------+----+------------------------------------------------------+ +| name | reportsTo| id | reportingHierarchy | ++--------+----------+----+------------------------------------------------------+ +| Dev | Eliot | 1 | [{name:Eliot, reportsTo:Ron, id:2, level:0}] | +| Eliot | Ron | 2 | [{name:Ron, reportsTo:Andrew, id:3, level:0}] | +| Ron | Andrew | 3 | [{name:Andrew, reportsTo:null, id:4, level:0}] | +| Andrew | null | 4 | [] | +| Asya | Ron | 5 | [{name:Ron, reportsTo:Andrew, id:3, level:0}] | +| Dan | Andrew | 6 | [{name:Andrew, reportsTo:null, id:4, level:0}] | ++--------+----------+----+------------------------------------------------------+ ``` -The depth field `level` is appended to each document in the result array. A value of `0` indicates the first level of matches. +The depth field `level` is added to each struct in the result array. A value of `0` indicates the first level of matches. ## Example 3: Limited Depth Traversal @@ -153,16 +153,16 @@ source = employees The query returns the following results: ```text -+--------+----------+----+--------------------------------------+ -| name | reportsTo| id | reportingHierarchy | -+--------+----------+----+--------------------------------------+ -| Dev | Eliot | 1 | [{Eliot, Ron, 2}, {Ron, Andrew, 3}] | -| Eliot | Ron | 2 | [{Ron, Andrew, 3}, {Andrew, null, 4}]| -| Ron | Andrew | 3 | [{Andrew, null, 4}] | -| Andrew | null | 4 | [] | -| Asya | Ron | 5 | [{Ron, Andrew, 3}, {Andrew, null, 4}]| -| Dan | Andrew | 6 | [{Andrew, null, 4}] | -+--------+----------+----+--------------------------------------+ ++--------+----------+----+---------------------------------------------------------------------------------+ +| name | reportsTo| id | reportingHierarchy | ++--------+----------+----+---------------------------------------------------------------------------------+ +| Dev | Eliot | 1 | [{name:Eliot, reportsTo:Ron, id:2}, {name:Ron, reportsTo:Andrew, id:3}] | +| Eliot | Ron | 2 | [{name:Ron, reportsTo:Andrew, id:3}, {name:Andrew, reportsTo:null, id:4}] | +| Ron | Andrew | 3 | [{name:Andrew, reportsTo:null, id:4}] | +| Andrew | null | 4 | [] | +| Asya | Ron | 5 | [{name:Ron, reportsTo:Andrew, id:3}, {name:Andrew, reportsTo:null, id:4}] | +| Dan | Andrew | 6 | [{name:Andrew, reportsTo:null, id:4}] | ++--------+----------+----+---------------------------------------------------------------------------------+ ``` With `maxDepth=1`, the traversal goes two levels deep (depth 0 and depth 1). @@ -192,15 +192,15 @@ source = airports The query returns the following results: ```text -+---------+------------+---------------------+ -| airport | connects | reachableAirports | -+---------+------------+---------------------+ -| JFK | [BOS, ORD] | [{JFK, [BOS, ORD]}] | -| BOS | [JFK, PWM] | [{BOS, [JFK, PWM]}] | -| ORD | [JFK] | [{ORD, [JFK]}] | -| PWM | [BOS, LHR] | [{PWM, [BOS, LHR]}] | -| LHR | [PWM] | [{LHR, [PWM]}] | -+---------+------------+---------------------+ ++---------+------------+-----------------------------------------------+ +| airport | connects | reachableAirports | ++---------+------------+-----------------------------------------------+ +| JFK | [BOS, ORD] | [{airport:JFK, connects:[BOS, ORD]}] | +| BOS | [JFK, PWM] | [{airport:BOS, connects:[JFK, PWM]}] | +| ORD | [JFK] | [{airport:ORD, connects:[JFK]}] | +| PWM | [BOS, LHR] | [{airport:PWM, connects:[BOS, LHR]}] | +| LHR | [PWM] | [{airport:LHR, connects:[PWM]}] | ++---------+------------+-----------------------------------------------+ ``` ## Example 5: Cross-Index Graph Lookup @@ -226,13 +226,13 @@ source = travelers The query returns the following results: ```text -+-------+----------------+---------------------+ -| name | nearestAirport | reachableAirports | -+-------+----------------+---------------------+ -| Dev | JFK | [{JFK, [BOS, ORD]}] | -| Eliot | JFK | [{JFK, [BOS, ORD]}] | -| Jeff | BOS | [{BOS, [JFK, PWM]}] | -+-------+----------------+---------------------+ ++-------+----------------+-----------------------------------------------+ +| name | nearestAirport | reachableAirports | ++-------+----------------+-----------------------------------------------+ +| Dev | JFK | [{airport:JFK, connects:[BOS, ORD]}] | +| Eliot | JFK | [{airport:JFK, connects:[BOS, ORD]}] | +| Jeff | BOS | [{airport:BOS, connects:[JFK, PWM]}] | ++-------+----------------+-----------------------------------------------+ ``` ## Example 6: Bidirectional Traversal @@ -251,11 +251,11 @@ source = employees The query returns the following results: ```text -+------+----------+----+------------------------------------------------+ -| name | reportsTo| id | connections | -+------+----------+----+------------------------------------------------+ -| Ron | Andrew | 3 | [{Ron, Andrew, 3}, {Andrew, null, 4}, {Dan, Andrew, 6}] | -+------+----------+----+------------------------------------------------+ ++------+----------+----+-----------------------------------------------------------------------------------------------------+ +| name | reportsTo| id | connections | ++------+----------+----+-----------------------------------------------------------------------------------------------------+ +| Ron | Andrew | 3 | [{name:Ron, reportsTo:Andrew, id:3}, {name:Andrew, reportsTo:null, id:4}, {name:Dan, reportsTo:Andrew, id:6}] | ++------+----------+----+-----------------------------------------------------------------------------------------------------+ ``` With bidirectional traversal, Ron's connections include: @@ -294,17 +294,17 @@ source = travelers **Normal mode** (default): Each traveler gets their own list of reachable airports ```text -| name | nearestAirport | reachableAirports | -|-------|----------------|-------------------| -| Dev | JFK | [JFK, BOS, ORD] | -| Jeff | BOS | [BOS, JFK, PWM] | +| name | nearestAirport | reachableAirports | +|-------|----------------|--------------------------------------| +| Dev | JFK | [{airport:JFK, connects:[BOS, ORD]}] | +| Jeff | BOS | [{airport:BOS, connects:[JFK, PWM]}] | ``` **Batch mode**: A single row with all travelers and all reachable airports combined ```text -| travelers | reachableAirports | -|----------------------------------------|-----------------------------| -| [{Dev, JFK}, {Jeff, BOS}] | [JFK, BOS, ORD, PWM, ...] | +| travelers | reachableAirports | +|--------------------------------------------------------------------|-------------------------------------------------------------| +| [{name:Dev, nearestAirport:JFK}, {name:Jeff, nearestAirport:BOS}] | [{airport:JFK, connects:[BOS, ORD]}, {airport:BOS, ...}] | ``` ## Array Field Handling diff --git a/integ-test/src/test/java/org/opensearch/sql/calcite/remote/CalcitePPLGraphLookupIT.java b/integ-test/src/test/java/org/opensearch/sql/calcite/remote/CalcitePPLGraphLookupIT.java index 08a084d5e4d..3d2b6ee5b0b 100644 --- a/integ-test/src/test/java/org/opensearch/sql/calcite/remote/CalcitePPLGraphLookupIT.java +++ b/integ-test/src/test/java/org/opensearch/sql/calcite/remote/CalcitePPLGraphLookupIT.java @@ -14,9 +14,10 @@ import static org.opensearch.sql.util.MatcherUtils.verifySchema; import java.io.IOException; -import java.util.Arrays; import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import org.json.JSONObject; import org.junit.Test; import org.opensearch.sql.ppl.PPLIntegTestCase; @@ -49,6 +50,16 @@ public void init() throws Exception { loadIndex(Index.GRAPH_AIRPORTS); } + /** Null-safe map helper since {@code Map.of()} rejects null values. */ + @SuppressWarnings("unchecked") + private static Map mapOf(Object... keysAndValues) { + LinkedHashMap map = new LinkedHashMap<>(); + for (int i = 0; i < keysAndValues.length; i += 2) { + map.put((String) keysAndValues[i], keysAndValues[i + 1]); + } + return map; + } + // ==================== Employee Hierarchy Tests ==================== /** Test 1: Basic employee hierarchy traversal. Find all managers in the reporting chain. */ @@ -72,12 +83,12 @@ public void testEmployeeHierarchyBasicTraversal() throws IOException { schema("reportingHierarchy", "array")); verifyDataRows( result, - rows("Dev", "Eliot", 1, List.of(List.of("Eliot", "Ron", 2))), - rows("Eliot", "Ron", 2, List.of(List.of("Ron", "Andrew", 3))), - rows("Ron", "Andrew", 3, List.of(Arrays.asList("Andrew", null, 4))), + rows("Dev", "Eliot", 1, List.of(Map.of("name", "Eliot", "reportsTo", "Ron", "id", 2))), + rows("Eliot", "Ron", 2, List.of(Map.of("name", "Ron", "reportsTo", "Andrew", "id", 3))), + rows("Ron", "Andrew", 3, List.of(mapOf("name", "Andrew", "reportsTo", null, "id", 4))), rows("Andrew", null, 4, Collections.emptyList()), - rows("Asya", "Ron", 5, List.of(List.of("Ron", "Andrew", 3))), - rows("Dan", "Andrew", 6, List.of(Arrays.asList("Andrew", null, 4)))); + rows("Asya", "Ron", 5, List.of(Map.of("name", "Ron", "reportsTo", "Andrew", "id", 3))), + rows("Dan", "Andrew", 6, List.of(mapOf("name", "Andrew", "reportsTo", null, "id", 4)))); } /** Test 2: Employee hierarchy traversal with depth field. */ @@ -102,12 +113,32 @@ public void testEmployeeHierarchyWithDepthField() throws IOException { schema("reportingHierarchy", "array")); verifyDataRows( result, - rows("Dev", "Eliot", 1, List.of(List.of("Eliot", "Ron", 2, 0))), - rows("Eliot", "Ron", 2, List.of(List.of("Ron", "Andrew", 3, 0))), - rows("Ron", "Andrew", 3, List.of(Arrays.asList("Andrew", null, 4, 0))), + rows( + "Dev", + "Eliot", + 1, + List.of(mapOf("name", "Eliot", "reportsTo", "Ron", "id", 2, "level", 0))), + rows( + "Eliot", + "Ron", + 2, + List.of(mapOf("name", "Ron", "reportsTo", "Andrew", "id", 3, "level", 0))), + rows( + "Ron", + "Andrew", + 3, + List.of(mapOf("name", "Andrew", "reportsTo", null, "id", 4, "level", 0))), rows("Andrew", null, 4, Collections.emptyList()), - rows("Asya", "Ron", 5, List.of(List.of("Ron", "Andrew", 3, 0))), - rows("Dan", "Andrew", 6, List.of(Arrays.asList("Andrew", null, 4, 0)))); + rows( + "Asya", + "Ron", + 5, + List.of(mapOf("name", "Ron", "reportsTo", "Andrew", "id", 3, "level", 0))), + rows( + "Dan", + "Andrew", + 6, + List.of(mapOf("name", "Andrew", "reportsTo", null, "id", 4, "level", 0)))); } /** Test 3: Employee hierarchy with maxDepth=1 (allows 2 levels of traversal). */ @@ -132,20 +163,30 @@ public void testEmployeeHierarchyWithMaxDepth() throws IOException { schema("reportingHierarchy", "array")); verifyDataRows( result, - rows("Dev", "Eliot", 1, List.of(List.of("Eliot", "Ron", 2), List.of("Ron", "Andrew", 3))), + rows( + "Dev", + "Eliot", + 1, + List.of( + Map.of("name", "Eliot", "reportsTo", "Ron", "id", 2), + Map.of("name", "Ron", "reportsTo", "Andrew", "id", 3))), rows( "Eliot", "Ron", 2, - List.of(List.of("Ron", "Andrew", 3), Arrays.asList("Andrew", null, 4))), - rows("Ron", "Andrew", 3, List.of(Arrays.asList("Andrew", null, 4))), + List.of( + Map.of("name", "Ron", "reportsTo", "Andrew", "id", 3), + mapOf("name", "Andrew", "reportsTo", null, "id", 4))), + rows("Ron", "Andrew", 3, List.of(mapOf("name", "Andrew", "reportsTo", null, "id", 4))), rows("Andrew", null, 4, Collections.emptyList()), rows( "Asya", "Ron", 5, - List.of(List.of("Ron", "Andrew", 3), Arrays.asList("Andrew", null, 4))), - rows("Dan", "Andrew", 6, List.of(Arrays.asList("Andrew", null, 4)))); + List.of( + Map.of("name", "Ron", "reportsTo", "Andrew", "id", 3), + mapOf("name", "Andrew", "reportsTo", null, "id", 4))), + rows("Dan", "Andrew", 6, List.of(mapOf("name", "Andrew", "reportsTo", null, "id", 4)))); } /** Test 4: Query Dev's complete reporting chain: Dev->Eliot->Ron->Andrew */ @@ -168,7 +209,9 @@ public void testEmployeeHierarchyForSpecificEmployee() throws IOException { schema("reportsTo", "string"), schema("id", "int"), schema("reportingHierarchy", "array")); - verifyDataRows(result, rows("Dev", "Eliot", 1, List.of(List.of("Eliot", "Ron", 2)))); + verifyDataRows( + result, + rows("Dev", "Eliot", 1, List.of(Map.of("name", "Eliot", "reportsTo", "Ron", "id", 2)))); } // ==================== Airport Connections Tests ==================== @@ -194,11 +237,20 @@ public void testAirportConnections() throws IOException { schema("reachableAirports", "array")); verifyDataRows( result, - rows("JFK", List.of("BOS", "ORD"), List.of(List.of("JFK", List.of("BOS", "ORD")))), - rows("BOS", List.of("JFK", "PWM"), List.of(List.of("BOS", List.of("JFK", "PWM")))), - rows("ORD", List.of("JFK"), List.of(List.of("ORD", List.of("JFK")))), - rows("PWM", List.of("BOS", "LHR"), List.of(List.of("PWM", List.of("BOS", "LHR")))), - rows("LHR", List.of("PWM"), List.of(List.of("LHR", List.of("PWM"))))); + rows( + "JFK", + List.of("BOS", "ORD"), + List.of(Map.of("airport", "JFK", "connects", List.of("BOS", "ORD")))), + rows( + "BOS", + List.of("JFK", "PWM"), + List.of(Map.of("airport", "BOS", "connects", List.of("JFK", "PWM")))), + rows("ORD", List.of("JFK"), List.of(Map.of("airport", "ORD", "connects", List.of("JFK")))), + rows( + "PWM", + List.of("BOS", "LHR"), + List.of(Map.of("airport", "PWM", "connects", List.of("BOS", "LHR")))), + rows("LHR", List.of("PWM"), List.of(Map.of("airport", "LHR", "connects", List.of("PWM"))))); } /** Test 6: Find airports reachable from JFK within maxDepth=1. */ @@ -227,7 +279,9 @@ public void testAirportConnectionsWithMaxDepth() throws IOException { rows( "JFK", List.of("BOS", "ORD"), - List.of(List.of("JFK", List.of("BOS", "ORD")), List.of("BOS", List.of("JFK", "PWM"))))); + List.of( + Map.of("airport", "JFK", "connects", List.of("BOS", "ORD")), + Map.of("airport", "BOS", "connects", List.of("JFK", "PWM"))))); } /** Test 7: Find airports with default depth(=0) and start value of list */ @@ -251,7 +305,11 @@ public void testAirportConnectionsWithDepthField() throws IOException { schema("reachableAirports", "array")); verifyDataRows( result, - rows("JFK", List.of("BOS", "ORD"), List.of(List.of("BOS", List.of("JFK", "PWM"), 0)))); + rows( + "JFK", + List.of("BOS", "ORD"), + List.of( + mapOf("airport", "BOS", "connects", List.of("JFK", "PWM"), "numConnections", 0)))); } /** @@ -277,9 +335,9 @@ public void testTravelersReachableAirports() throws IOException { schema("reachableAirports", "array")); verifyDataRows( result, - rows("Dev", "JFK", List.of(List.of("JFK", List.of("BOS", "ORD")))), - rows("Eliot", "JFK", List.of(List.of("JFK", List.of("BOS", "ORD")))), - rows("Jeff", "BOS", List.of(List.of("BOS", List.of("JFK", "PWM"))))); + rows("Dev", "JFK", List.of(Map.of("airport", "JFK", "connects", List.of("BOS", "ORD")))), + rows("Eliot", "JFK", List.of(Map.of("airport", "JFK", "connects", List.of("BOS", "ORD")))), + rows("Jeff", "BOS", List.of(Map.of("airport", "BOS", "connects", List.of("JFK", "PWM"))))); } /** @@ -305,7 +363,12 @@ public void testTravelerReachableAirportsWithDepthField() throws IOException { schema("name", "string"), schema("nearestAirport", "string"), schema("reachableAirports", "array")); - verifyDataRows(result, rows("Dev", "JFK", List.of(List.of("JFK", List.of("BOS", "ORD"), 0)))); + verifyDataRows( + result, + rows( + "Dev", + "JFK", + List.of(mapOf("airport", "JFK", "connects", List.of("BOS", "ORD"), "hops", 0)))); } /** @@ -338,9 +401,9 @@ public void testTravelerReachableAirportsWithMaxDepth() throws IOException { "Jeff", "BOS", List.of( - List.of("BOS", List.of("JFK", "PWM")), - List.of("JFK", List.of("BOS", "ORD")), - List.of("PWM", List.of("BOS", "LHR"))))); + Map.of("airport", "BOS", "connects", List.of("JFK", "PWM")), + Map.of("airport", "JFK", "connects", List.of("BOS", "ORD")), + Map.of("airport", "PWM", "connects", List.of("BOS", "LHR"))))); } // ==================== Bidirectional Traversal Tests ==================== @@ -372,9 +435,9 @@ public void testBidirectionalEmployeeHierarchy() throws IOException { "Andrew", 3, List.of( - List.of("Ron", "Andrew", 3), - Arrays.asList("Andrew", null, 4), - List.of("Dan", "Andrew", 6)))); + Map.of("name", "Ron", "reportsTo", "Andrew", "id", 3), + mapOf("name", "Andrew", "reportsTo", null, "id", 4), + Map.of("name", "Dan", "reportsTo", "Andrew", "id", 6)))); } /** @@ -404,7 +467,9 @@ public void testBidirectionalAirportConnections() throws IOException { rows( "ORD", List.of("JFK"), - List.of(List.of("JFK", List.of("BOS", "ORD")), List.of("BOS", List.of("JFK", "PWM"))))); + List.of( + Map.of("airport", "JFK", "connects", List.of("BOS", "ORD")), + Map.of("airport", "BOS", "connects", List.of("JFK", "PWM"))))); } // ==================== Filter Tests ==================== @@ -444,10 +509,10 @@ public void testEmployeeHierarchyWithFilter() throws IOException { result, rows("Dev", "Eliot", 1, Collections.emptyList()), rows("Eliot", "Ron", 2, Collections.emptyList()), - rows("Ron", "Andrew", 3, List.of(Arrays.asList("Andrew", null, 4))), + rows("Ron", "Andrew", 3, List.of(mapOf("name", "Andrew", "reportsTo", null, "id", 4))), rows("Andrew", null, 4, Collections.emptyList()), rows("Asya", "Ron", 5, Collections.emptyList()), - rows("Dan", "Andrew", 6, List.of(Arrays.asList("Andrew", null, 4)))); + rows("Dan", "Andrew", 6, List.of(mapOf("name", "Andrew", "reportsTo", null, "id", 4)))); } /** @@ -507,7 +572,13 @@ public void testEmployeeHierarchyWithFilterAndMaxDepth() throws IOException { // -> then Ron.reportsTo=Andrew -> Andrew(id=4) is filtered out -> stops verifyDataRows( result, - rows("Dev", "Eliot", 1, List.of(List.of("Eliot", "Ron", 2), List.of("Ron", "Andrew", 3)))); + rows( + "Dev", + "Eliot", + 1, + List.of( + Map.of("name", "Eliot", "reportsTo", "Ron", "id", 2), + Map.of("name", "Ron", "reportsTo", "Andrew", "id", 3)))); } // ==================== Edge Cases ==================== @@ -600,12 +671,12 @@ public void testGraphLookupWithFieldsProjection() throws IOException { verifySchema(result, schema("name", "string"), schema("reportingHierarchy", "array")); verifyDataRows( result, - rows("Dev", List.of(List.of("Eliot", "Ron", 2))), - rows("Eliot", List.of(List.of("Ron", "Andrew", 3))), - rows("Ron", List.of(Arrays.asList("Andrew", null, 4))), + rows("Dev", List.of(Map.of("name", "Eliot", "reportsTo", "Ron", "id", 2))), + rows("Eliot", List.of(Map.of("name", "Ron", "reportsTo", "Andrew", "id", 3))), + rows("Ron", List.of(mapOf("name", "Andrew", "reportsTo", null, "id", 4))), rows("Andrew", Collections.emptyList()), - rows("Asya", List.of(List.of("Ron", "Andrew", 3))), - rows("Dan", List.of(Arrays.asList("Andrew", null, 4)))); + rows("Asya", List.of(Map.of("name", "Ron", "reportsTo", "Andrew", "id", 3))), + rows("Dan", List.of(mapOf("name", "Andrew", "reportsTo", null, "id", 4)))); } // ==================== Batch Mode Tests ==================== @@ -637,8 +708,12 @@ public void testBatchModeEmployeeHierarchy() throws IOException { verifyDataRows( result, rows( - List.of(List.of("Dev", "Eliot", 1), List.of("Asya", "Ron", 5)), - List.of(List.of("Ron", "Andrew", 3, 0), Arrays.asList("Andrew", null, 4, 1)))); + List.of( + Map.of("name", "Dev", "reportsTo", "Eliot", "id", 1), + Map.of("name", "Asya", "reportsTo", "Ron", "id", 5)), + List.of( + mapOf("name", "Ron", "reportsTo", "Andrew", "id", 3, "depth", 0), + mapOf("name", "Andrew", "reportsTo", null, "id", 4, "depth", 1)))); } /** @@ -669,11 +744,14 @@ public void testBatchModeTravelersAirports() throws IOException { verifyDataRows( result, rows( - List.of(List.of("Dev", "JFK"), List.of("Eliot", "JFK"), List.of("Jeff", "BOS")), List.of( - List.of("JFK", List.of("BOS", "ORD"), 0), - List.of("BOS", List.of("JFK", "PWM"), 0), - List.of("PWM", List.of("BOS", "LHR"), 1)))); + Map.of("name", "Dev", "nearestAirport", "JFK"), + Map.of("name", "Eliot", "nearestAirport", "JFK"), + Map.of("name", "Jeff", "nearestAirport", "BOS")), + List.of( + mapOf("airport", "JFK", "connects", List.of("BOS", "ORD"), "depth", 0), + mapOf("airport", "BOS", "connects", List.of("JFK", "PWM"), "depth", 0), + mapOf("airport", "PWM", "connects", List.of("BOS", "LHR"), "depth", 1)))); } /** @@ -702,12 +780,14 @@ public void testBatchModeBidirectional() throws IOException { verifyDataRows( result, rows( - List.of(List.of("Dev", "Eliot", 1), List.of("Dan", "Andrew", 6)), List.of( - List.of("Dev", "Eliot", 1, 0), - List.of("Eliot", "Ron", 2, 0), - Arrays.asList("Andrew", null, 4, 0), - List.of("Dan", "Andrew", 6, 0), - List.of("Asya", "Ron", 5, 1)))); + Map.of("name", "Dev", "reportsTo", "Eliot", "id", 1), + Map.of("name", "Dan", "reportsTo", "Andrew", "id", 6)), + List.of( + mapOf("name", "Dev", "reportsTo", "Eliot", "id", 1, "depth", 0), + mapOf("name", "Eliot", "reportsTo", "Ron", "id", 2, "depth", 0), + mapOf("name", "Andrew", "reportsTo", null, "id", 4, "depth", 0), + mapOf("name", "Dan", "reportsTo", "Andrew", "id", 6, "depth", 0), + mapOf("name", "Asya", "reportsTo", "Ron", "id", 5, "depth", 1)))); } } diff --git a/opensearch/src/main/java/org/opensearch/sql/opensearch/executor/OpenSearchExecutionEngine.java b/opensearch/src/main/java/org/opensearch/sql/opensearch/executor/OpenSearchExecutionEngine.java index aeebac549e5..32b7891d344 100644 --- a/opensearch/src/main/java/org/opensearch/sql/opensearch/executor/OpenSearchExecutionEngine.java +++ b/opensearch/src/main/java/org/opensearch/sql/opensearch/executor/OpenSearchExecutionEngine.java @@ -229,33 +229,48 @@ public void execute( } /** - * Process values recursively, handling geo points and nested maps. Geo points are converted to - * OpenSearchExprGeoPointValue. Maps are recursively processed to handle nested structures. + * Process values recursively, handling geo points, nested maps, structs and arrays. When a {@link + * RelDataType} is provided, struct values (StructImpl) are converted to Maps keyed by field + * names, preserving field-name information in the JSON output. + * + * @param value The raw value from the JDBC result set + * @param type The Calcite type metadata for this value, or null if unavailable */ - private static Object processValue(Object value) throws SQLException { + @SuppressWarnings("unchecked") + private static Object processValue(Object value, RelDataType type) throws SQLException { if (value == null) { return null; } - if (value instanceof Point) { - Point point = (Point) value; + if (value instanceof Point point) { return new OpenSearchExprGeoPointValue(point.getY(), point.getX()); } if (value instanceof Map) { Map map = (Map) value; Map convertedMap = new HashMap<>(); for (Map.Entry entry : map.entrySet()) { - convertedMap.put(entry.getKey(), processValue(entry.getValue())); + convertedMap.put(entry.getKey(), processValue(entry.getValue(), null)); } return convertedMap; } - if (value instanceof StructImpl) { - return Arrays.asList(((StructImpl) value).getAttributes()); + if (value instanceof StructImpl structImpl) { + Object[] attrs = structImpl.getAttributes(); + if (type != null && type.getSqlTypeName() == SqlTypeName.ROW) { + List fields = type.getFieldList(); + Map map = new LinkedHashMap<>(); + for (int i = 0; i < fields.size() && i < attrs.length; i++) { + map.put(fields.get(i).getName(), processValue(attrs[i], fields.get(i).getType())); + } + return map; + } + return Arrays.asList(attrs); } if (value instanceof List) { List list = (List) value; + RelDataType componentType = + (type != null && type.getComponentType() != null) ? type.getComponentType() : null; List convertedList = new ArrayList<>(); for (Object item : list) { - convertedList.add(processValue(item)); + convertedList.add(processValue(item, componentType)); } return convertedList; } @@ -278,7 +293,7 @@ private QueryResponse buildResultSet( for (int i = 1; i <= columnCount; i++) { String columnName = metaData.getColumnName(i); Object value = resultSet.getObject(columnName); - Object converted = processValue(value); + Object converted = processValue(value, fieldTypes.get(i - 1)); ExprValue exprValue = ExprValueUtils.fromObjectValue(converted); row.put(columnName, exprValue); }