diff --git a/core/src/main/java/org/opensearch/sql/ast/tree/GraphLookup.java b/core/src/main/java/org/opensearch/sql/ast/tree/GraphLookup.java index f29285560f9..f1457a8a540 100644 --- a/core/src/main/java/org/opensearch/sql/ast/tree/GraphLookup.java +++ b/core/src/main/java/org/opensearch/sql/ast/tree/GraphLookup.java @@ -45,8 +45,11 @@ public enum Direction { /** Target table for graph traversal lookup. */ private final UnresolvedPlan fromTable; - /** Field in sourceTable to start with. */ - private final Field startField; + /** Field in sourceTable to start with (piped mode). Null when using literal start values. */ + private @Nullable final Field startField; + + /** Literal start values for top-level graphlookup (mutually exclusive with startField). */ + private @Nullable final List startValues; /** Field in fromTable that represents the outgoing edge. */ private final Field fromField; diff --git a/core/src/main/java/org/opensearch/sql/calcite/CalciteRelNodeVisitor.java b/core/src/main/java/org/opensearch/sql/calcite/CalciteRelNodeVisitor.java index 489c933953f..fa73d1a8772 100644 --- a/core/src/main/java/org/opensearch/sql/calcite/CalciteRelNodeVisitor.java +++ b/core/src/main/java/org/opensearch/sql/calcite/CalciteRelNodeVisitor.java @@ -2692,18 +2692,43 @@ public RelNode visitAddColTotals(AddColTotals node, CalcitePlanContext context) @Override public RelNode visitGraphLookup(GraphLookup node, CalcitePlanContext context) { - // 1. Visit source (child) table - visitChildren(node, context); RelBuilder builder = context.relBuilder; - // TODO: Limit the number of source rows to 100 for now, make it configurable. - builder.limit(0, 100); - if (node.isBatchMode()) { - tryToRemoveMetaFields(context, true); + + List startValuesForCalcite = null; + String startFieldName; + if (node.getStartValues() != null) { + // Literal start mode: create empty LogicalValues as dummy source (BiRel needs two inputs) + // And will ignore the previous pipe then. + RelDataType dummyType = + builder + .getTypeFactory() + .createStructType( + List.of(builder.getTypeFactory().createSqlType(SqlTypeName.VARCHAR)), + List.of("_dummy")); + builder.values(dummyType); + startFieldName = null; + startValuesForCalcite = new ArrayList<>(); + for (var lit : node.getStartValues()) { + startValuesForCalcite.add(lit.getValue()); + } + } else { + if (node.getChild().isEmpty()) { + throw new SemanticCheckException( + "Field reference start requires a piped source." + + " Use literal start values (e.g. start='value') for top-level graphLookup."); + } + // Piped mode: visit source child + visitChildren(node, context); + // TODO: Limit the number of source rows to 100 for now, make it configurable. + builder.limit(0, 100); + if (node.isBatchMode()) { + tryToRemoveMetaFields(context, true); + } + startFieldName = node.getStartField().getField().toString(); } RelNode sourceTable = builder.build(); // 2. Extract parameters - String startFieldName = node.getStartField().getField().toString(); String fromFieldName = node.getFromField().getField().toString(); String toFieldName = node.getToField().getField().toString(); String outputFieldName = node.getAs().getField().toString(); @@ -2736,6 +2761,7 @@ public RelNode visitGraphLookup(GraphLookup node, CalcitePlanContext context) { sourceTable, lookupTable, startFieldName, + startValuesForCalcite, fromFieldName, toFieldName, outputFieldName, diff --git a/core/src/main/java/org/opensearch/sql/calcite/plan/rel/GraphLookup.java b/core/src/main/java/org/opensearch/sql/calcite/plan/rel/GraphLookup.java index 02ed97faf0c..8410664bc8d 100644 --- a/core/src/main/java/org/opensearch/sql/calcite/plan/rel/GraphLookup.java +++ b/core/src/main/java/org/opensearch/sql/calcite/plan/rel/GraphLookup.java @@ -40,7 +40,8 @@ public abstract class GraphLookup extends BiRel { // TODO: use RexInputRef instead of String for there fields - protected final String startField; // Field in source table (start entities) + @Nullable protected final String startField; // Field in source table (start entities) + @Nullable protected final List startValues; // Literal start values (top-level mode) protected final String fromField; // Field in lookup table (edge source) protected final String toField; // Field in lookup table (edge target) protected final String outputField; // Name of output array field @@ -63,7 +64,8 @@ public abstract class GraphLookup extends BiRel { * @param traitSet Trait set * @param source Source table RelNode * @param lookup Lookup table RelNode - * @param startField Field name for start entities + * @param startField Field name for start entities (null in literal start mode) + * @param startValues Literal start values for top-level graphLookup (null in piped mode) * @param fromField Field name for outgoing edges * @param toField Field name for incoming edges * @param outputField Name of the output array field @@ -81,7 +83,8 @@ protected GraphLookup( RelTraitSet traitSet, RelNode source, RelNode lookup, - String startField, + @Nullable String startField, + @Nullable List startValues, String fromField, String toField, String outputField, @@ -94,6 +97,7 @@ protected GraphLookup( @Nullable RexNode filter) { super(cluster, traitSet, source, lookup); this.startField = startField; + this.startValues = startValues; this.fromField = fromField; this.toField = toField; this.outputField = outputField; @@ -124,7 +128,19 @@ protected RelDataType deriveRowType() { if (outputRowType == null) { RelDataTypeFactory.Builder builder = getCluster().getTypeFactory().builder(); - if (batchMode) { + if (startValues != null) { + // Literal start mode: Output = just [outputField: ARRAY] + RelDataType lookupRowType = getLookup().getRowType(); + if (this.depthField != null) { + final RelDataTypeFactory.Builder lookupBuilder = getCluster().getTypeFactory().builder(); + lookupBuilder.addAll(lookupRowType.getFieldList()); + RelDataType depthType = getCluster().getTypeFactory().createSqlType(SqlTypeName.INTEGER); + lookupBuilder.add(this.depthField, depthType); + lookupRowType = lookupBuilder.build(); + } + RelDataType arrayType = getCluster().getTypeFactory().createArrayType(lookupRowType, -1); + builder.add(outputField, arrayType); + } else if (batchMode) { // Batch mode: Output = [Array, Array] // First field: aggregated source rows as array RelDataType sourceRowType = getSource().getRowType(); @@ -172,7 +188,7 @@ protected RelDataType deriveRowType() { @Override public double estimateRowCount(RelMetadataQuery mq) { // Batch mode aggregates all source rows into a single output row - return batchMode ? 1 : getSource().estimateRowCount(mq); + return (startValues != null || batchMode) ? 1 : getSource().estimateRowCount(mq); } @Override @@ -184,6 +200,7 @@ public RelWriter explainTerms(RelWriter pw) { .item("depthField", depthField) .item("maxDepth", maxDepth) .item("bidirectional", bidirectional) + .itemIf("startValues", startValues, startValues != null) .itemIf("supportArray", supportArray, supportArray) .itemIf("batchMode", batchMode, batchMode) .itemIf("usePIT", usePIT, usePIT) diff --git a/core/src/main/java/org/opensearch/sql/calcite/plan/rel/LogicalGraphLookup.java b/core/src/main/java/org/opensearch/sql/calcite/plan/rel/LogicalGraphLookup.java index 94db3689f8c..98ea7301168 100644 --- a/core/src/main/java/org/opensearch/sql/calcite/plan/rel/LogicalGraphLookup.java +++ b/core/src/main/java/org/opensearch/sql/calcite/plan/rel/LogicalGraphLookup.java @@ -21,31 +21,13 @@ @Getter public class LogicalGraphLookup extends GraphLookup { - /** - * Creates a LogicalGraphLookup. - * - * @param cluster Cluster - * @param traitSet Trait set - * @param source Source table RelNode - * @param lookup Lookup table RelNode - * @param startField Field name for start entities - * @param fromField Field name for outgoing edges - * @param toField Field name for incoming edges - * @param outputField Name of the output array field - * @param depthField Name of the depth field - * @param maxDepth Maximum traversal depth (-1 for unlimited) - * @param bidirectional Whether to traverse edges in both directions - * @param supportArray Whether to support array-typed fields - * @param batchMode Whether to batch all source start values into a single unified BFS - * @param usePIT Whether to use PIT (Point In Time) search for complete results - * @param filter Optional filter condition for lookup table documents - */ protected LogicalGraphLookup( RelOptCluster cluster, RelTraitSet traitSet, RelNode source, RelNode lookup, - String startField, + @Nullable String startField, + @Nullable List startValues, String fromField, String toField, String outputField, @@ -62,6 +44,7 @@ protected LogicalGraphLookup( source, lookup, startField, + startValues, fromField, toField, outputField, @@ -74,28 +57,11 @@ protected LogicalGraphLookup( filter); } - /** - * Creates a LogicalGraphLookup with Convention.NONE. - * - * @param source Source table RelNode - * @param lookup Lookup table RelNode - * @param startField Field name for start entities - * @param fromField Field name for outgoing edges - * @param toField Field name for incoming edges - * @param outputField Name of the output array field - * @param depthField Named of the output depth field - * @param maxDepth Maximum traversal depth (-1 for unlimited) - * @param bidirectional Whether to traverse edges in both directions - * @param supportArray Whether to support array-typed fields - * @param batchMode Whether to batch all source start values into a single unified BFS - * @param usePIT Whether to use PIT (Point In Time) search for complete results - * @param filter Optional filter condition for lookup table documents - * @return A new LogicalGraphLookup instance - */ public static LogicalGraphLookup create( RelNode source, RelNode lookup, - String startField, + @Nullable String startField, + @Nullable List startValues, String fromField, String toField, String outputField, @@ -114,6 +80,7 @@ public static LogicalGraphLookup create( source, lookup, startField, + startValues, fromField, toField, outputField, @@ -134,6 +101,7 @@ public RelNode copy(RelTraitSet traitSet, List inputs) { inputs.get(0), inputs.get(1), startField, + startValues, fromField, toField, outputField, diff --git a/docs/user/ppl/cmd/graphlookup.md b/docs/user/ppl/cmd/graphlookup.md index 00754263c8c..94e0cf3968d 100644 --- a/docs/user/ppl/cmd/graphlookup.md +++ b/docs/user/ppl/cmd/graphlookup.md @@ -5,10 +5,14 @@ The `graphLookup` command performs recursive graph traversal on a collection usi ## Syntax -The `graphLookup` command has the following syntax: +```syntax +source = | graphLookup start= edge= [maxDepth=] [depthField=] [supportArray=(true | false)] [batchMode=(true | false)] [usePIT=(true | false)] [filter=()] as +``` + +`graphLookup` can be used as the first command (without `source`): ```syntax -graphLookup start= edge= [maxDepth=] [depthField=] [supportArray=(true | false)] [batchMode=(true | false)] [usePIT=(true | false)] [filter=()] as +graphLookup start= edge= [maxDepth=] [depthField=] [usePIT=(true | false)] [filter=()] as ``` The following are examples of the `graphLookup` command syntax: @@ -21,24 +25,27 @@ source = employees | graphLookup employees start=reportsTo edge=reportsTo<->name source = travelers | graphLookup airports start=nearestAirport edge=connects-->airport supportArray=true as reachableAirports source = airports | graphLookup airports start=airport edge=connects-->airport supportArray=true as reachableAirports source = employees | graphLookup employees start=reportsTo edge=reportsTo-->name filter=(status = 'active' AND age > 18) as reportingHierarchy +graphLookup employees start='Eliot' edge=reportsTo-->name as reportingHierarchy +graphLookup employees start='Eliot', 'Andrew' edge=reportsTo-->name as reportingHierarchy +graphLookup employees start='Eliot' edge=reportsTo-->name maxDepth=1 depthField=level as reportingHierarchy ``` ## Parameters The `graphLookup` command supports the following parameters. -| Parameter | Required/Optional | Description | -|---|---|---| -| `` | Required | The name of the index to perform the graph traversal on. Can be the same as the source index for self-referential graphs. | -| `start=` | Required | The field in the source documents whose value is used to initiate the recursive search. The value of this field is matched against `toField` in the lookup index. Supports both single values and array values as starting points. | -| `edge=` | Required | Defines the traversal path between nodes, specifying the connection fields and the direction of traversal. See [Edge Sub-parameters](#edge-sub-parameters) below. | -| `maxDepth=` | Optional | The maximum recursion depth (number of hops). Default is `0`. A value of `0` returns only direct connections to the start values. A value of `1` returns the initial matches plus one additional recursive step, and so on. | -| `depthField=` | Optional | The name of the field added to each traversed document to indicate its recursion depth. If not specified, no depth field is added. Depth starts at `0` for the first level of matches. | -| `supportArray=(true \| false)` | Optional | When `true`, disables early visited-node filter pushdown to OpenSearch. Default is `false`. Set to `true` when `fromField` or `toField` contains array values to ensure correct traversal behavior. See [Array Field Handling](#array-field-handling) for details. | -| `batchMode=(true \| false)` | Optional | When `true`, collects all start values from all source rows and performs a single unified BFS traversal. Default is `false`. The output changes to two arrays: `[Array, Array]`. See [Batch Mode](#batch-mode) for details. | -| `usePIT=(true \| false)` | Optional | When `true`, enables Point In Time (PIT) search for the lookup index, allowing paginated retrieval of complete results without the `max_result_window` size limit. Default is `false`. See [PIT Search](#pit-search) for details. | -| `filter=()` | Optional | A filter condition that restricts which lookup index documents participate in the graph traversal. Only documents matching the condition are considered as candidates during BFS. Parentheses around the condition are required. Example: `filter=(status = 'active' AND age > 18)`. | -| `as ` | Required | The name of the output array field that will contain all documents discovered during the graph traversal. | +| Parameter | Required/Optional | Description | +|---|---|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `` | Required | The name of the index to perform the graph traversal on. Can be the same as the source index for self-referential graphs. | +| `start=` | Required | The starting point for the BFS traversal. The `startExpression` can be a **field reference** (e.g., `start=reportsTo`) from the previous pipe, a **literal value** (e.g., `start='Eliot'`), or a **literal list** (e.g., `start='Eliot', 'Andrew'`). When a field reference is used, the value of that field in each source row initiates the traversal. When literal values are used, they seed the BFS directly. The start value is matched against `toField` in the lookup index. | +| `edge=` | Required | Defines the traversal path between nodes, specifying the connection fields and the direction of traversal. See [Edge Sub-parameters](#edge-sub-parameters) below. | +| `maxDepth=` | Optional | The maximum recursion depth (number of hops). Default is `0`. A value of `0` returns only direct connections to the start values. A value of `1` returns the initial matches plus one additional recursive step, and so on. | +| `depthField=` | Optional | The name of the field added to each traversed document to indicate its recursion depth. If not specified, no depth field is added. Depth starts at `0` for the first level of matches. | +| `supportArray=(true \| false)` | Optional | When `true`, disables early visited-node filter pushdown to OpenSearch. Default is `false`. Set to `true` when `fromField` or `toField` contains array values to ensure correct traversal behavior. See [Array Field Handling](#array-field-handling) for details. | +| `batchMode=(true \| false)` | Optional | When `true`, collects all start values from all source rows and performs a single unified BFS traversal. Default is `false`. The output changes to two arrays: `[Array, Array]`. See [Batch Mode](#batch-mode) for details. | +| `usePIT=(true \| false)` | Optional | When `true`, enables Point In Time (PIT) search for the lookup index, allowing paginated retrieval of complete results without the `max_result_window` size limit. Default is `false`. See [PIT Search](#pit-search) for details. | +| `filter=()` | Optional | A filter condition that restricts which lookup index documents participate in the graph traversal. Only documents matching the condition are considered as candidates during BFS. Parentheses around the condition are required. Example: `filter=(status = 'active' AND age > 18)`. | +| `as ` | Required | The name of the output array field that will contain all documents discovered during the graph traversal. | ### Edge Sub-parameters @@ -354,6 +361,55 @@ source = employees The filter is applied at the OpenSearch query level, so it combines efficiently with the BFS traversal queries. At each BFS level, the query sent to OpenSearch is effectively: `bool { filter: [user_filter, bfs_terms_query] }`. +### When to Use as First Command + +When the starting points for graph traversal are known in advance, `graphLookup` can be used as the first command in a pipeline without `source`. In this case, `start` accepts literal values instead of a field reference. + +This is useful when: +- You want to explore the graph from specific known nodes +- You don't need source document fields in the output +- You want a quick lookup without creating a source query first + +**Single start value:** + +```ppl ignore +graphLookup employees + start='Eliot' + edge=reportsTo-->name + as reportingHierarchy +``` + +The query returns a single row containing the BFS results: + +```text ++---------------------------------------------------------------+ +| reportingHierarchy | ++---------------------------------------------------------------+ +| [{name:Eliot, reportsTo:Ron, id:2}, {name:Ron, ...}, ...] | ++---------------------------------------------------------------+ +``` + +**Multiple start values:** + +```ppl ignore +graphLookup employees + start='Eliot', 'Andrew' + edge=reportsTo-->name + as reportingHierarchy +``` + +All literal start values are combined into a single BFS traversal. The output is a single row with all discovered nodes. + +**With depth tracking:** + +```ppl ignore +graphLookup employees + start='Eliot' + edge=reportsTo-->name + depthField=level + as reportingHierarchy +``` + ## Limitations - The source input, which provides the starting point for the traversal, has a limitation of 100 documents to avoid performance issues. diff --git a/integ-test/src/test/java/org/opensearch/sql/calcite/CalciteNoPushdownIT.java b/integ-test/src/test/java/org/opensearch/sql/calcite/CalciteNoPushdownIT.java index 014091ec072..7303486ba59 100644 --- a/integ-test/src/test/java/org/opensearch/sql/calcite/CalciteNoPushdownIT.java +++ b/integ-test/src/test/java/org/opensearch/sql/calcite/CalciteNoPushdownIT.java @@ -112,6 +112,7 @@ CalciteMvCombineCommandIT.class, CalciteNoMvCommandIT.class, CalciteMvExpandCommandIT.class, + CalcitePPLGraphLookupIT.class, }) public class CalciteNoPushdownIT { private static boolean wasPushdownEnabled; diff --git a/integ-test/src/test/java/org/opensearch/sql/calcite/remote/CalciteExplainIT.java b/integ-test/src/test/java/org/opensearch/sql/calcite/remote/CalciteExplainIT.java index 8a7eec3dc3f..4c572768ead 100644 --- a/integ-test/src/test/java/org/opensearch/sql/calcite/remote/CalciteExplainIT.java +++ b/integ-test/src/test/java/org/opensearch/sql/calcite/remote/CalciteExplainIT.java @@ -12,6 +12,7 @@ import static org.opensearch.sql.legacy.TestsConstants.TEST_INDEX_BANK_WITH_NULL_VALUES; import static org.opensearch.sql.legacy.TestsConstants.TEST_INDEX_CASCADED_NESTED; import static org.opensearch.sql.legacy.TestsConstants.TEST_INDEX_DEEP_NESTED; +import static org.opensearch.sql.legacy.TestsConstants.TEST_INDEX_GRAPH_EMPLOYEES; import static org.opensearch.sql.legacy.TestsConstants.TEST_INDEX_LOGS; import static org.opensearch.sql.legacy.TestsConstants.TEST_INDEX_NESTED_SIMPLE; import static org.opensearch.sql.legacy.TestsConstants.TEST_INDEX_OTEL_LOGS; @@ -60,6 +61,7 @@ public void init() throws Exception { loadIndex(Index.DEEP_NESTED); loadIndex(Index.CASCADED_NESTED); loadIndex(Index.MVEXPAND_EDGE_CASES); + loadIndex(Index.GRAPH_EMPLOYEES); } @Override @@ -2820,6 +2822,31 @@ public void testHighlightWithFilterExplain() throws IOException { assertYamlEqualsIgnoreId(expected, result); } + @Test + public void testExplainGraphLookup() throws IOException { + enabledOnlyWhenPushdownIsEnabled(); + String query = + String.format( + "source=%s | graphLookup %s start=reportsTo edge=reportsTo-->name" + + " as reportingHierarchy", + TEST_INDEX_GRAPH_EMPLOYEES, TEST_INDEX_GRAPH_EMPLOYEES); + var result = explainQueryYaml(query); + String expected = loadExpectedPlan("explain_graphlookup.yaml"); + assertYamlEqualsIgnoreId(expected, result); + } + + @Test + public void testExplainGraphLookupTopLevel() throws IOException { + enabledOnlyWhenPushdownIsEnabled(); + String query = + String.format( + "graphLookup %s start='Eliot' edge=reportsTo-->name as reportingHierarchy", + TEST_INDEX_GRAPH_EMPLOYEES); + var result = explainQueryYaml(query); + String expected = loadExpectedPlan("explain_graphlookup_top_level.yaml"); + assertYamlEqualsIgnoreId(expected, result); + } + @Test public void testHighlightOsdObjectFormatExplain() throws IOException { // OSD sends highlight as a rich object with pre_tags, post_tags, fields, fragment_size 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 3d2b6ee5b0b..62e872be945 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 @@ -44,6 +44,9 @@ public class CalcitePPLGraphLookupIT extends PPLIntegTestCase { public void init() throws Exception { super.init(); enableCalcite(); + // Skip test if pushdown is disabled + // TODO: support no-pushdown config for graph lookup + enabledOnlyWhenPushdownIsEnabled(); loadIndex(Index.GRAPH_EMPLOYEES); loadIndex(Index.GRAPH_TRAVELERS); @@ -790,4 +793,127 @@ public void testBatchModeBidirectional() throws IOException { mapOf("name", "Dan", "reportsTo", "Andrew", "id", 6, "depth", 0), mapOf("name", "Asya", "reportsTo", "Ron", "id", 5, "depth", 1)))); } + + // ==================== Top-Level Literal Start Tests ==================== + + /** + * Test 20: Top-level graphLookup with single literal start value. BFS from "Eliot" finds the + * reporting chain: Eliot->Ron->Andrew. + */ + @Test + public void testTopLevelGraphLookupSingleLiteral() throws IOException { + JSONObject result = + executeQuery( + String.format( + "graphLookup %s" + + " start='Eliot'" + + " edge=reportsTo-->name" + + " maxDepth=5" + + " as reportingHierarchy", + TEST_INDEX_GRAPH_EMPLOYEES)); + + // Output is single row with just the reportingHierarchy array + verifySchema(result, schema("reportingHierarchy", "array")); + // BFS from "Eliot": toField=name matches Eliot -> Eliot row has reportsTo=Ron + // -> then name matches Ron -> Ron row has reportsTo=Andrew + // -> then name matches Andrew -> Andrew row has reportsTo=null (no further traversal) + verifyDataRows( + result, + rows( + (Object) + List.of( + Map.of("name", "Eliot", "reportsTo", "Ron", "id", 2), + Map.of("name", "Ron", "reportsTo", "Andrew", "id", 3), + mapOf("name", "Andrew", "reportsTo", null, "id", 4)))); + } + + /** + * Test 21: Top-level graphLookup with literal list start values. Combined BFS from "Eliot" and + * "Andrew". + */ + @Test + public void testTopLevelGraphLookupLiteralList() throws IOException { + JSONObject result = + executeQuery( + String.format( + "graphLookup %s" + + " start='Eliot', 'Andrew'" + + " edge=reportsTo-->name" + + " maxDepth=5" + + " as reportingHierarchy", + TEST_INDEX_GRAPH_EMPLOYEES)); + + verifySchema(result, schema("reportingHierarchy", "array")); + // Combined BFS from {Eliot, Andrew}: + // Depth 0: name IN (Eliot, Andrew) → finds Eliot (reportsTo=Ron) and Andrew (reportsTo=null) + // Depth 1: name IN (Ron) AND reportsTo NOT IN (Eliot, Andrew, Ron) → Ron excluded + // because Ron.reportsTo=Andrew is in visited set + verifyDataRows( + result, + rows( + (Object) + List.of( + Map.of("name", "Eliot", "reportsTo", "Ron", "id", 2), + mapOf("name", "Andrew", "reportsTo", null, "id", 4)))); + } + + /** Test 22: Top-level graphLookup with maxDepth. */ + @Test + public void testTopLevelGraphLookupWithMaxDepth() throws IOException { + JSONObject result = + executeQuery( + String.format( + "graphLookup %s" + + " start='Eliot'" + + " edge=reportsTo-->name" + + " maxDepth=0" + + " as reportingHierarchy", + TEST_INDEX_GRAPH_EMPLOYEES)); + + verifySchema(result, schema("reportingHierarchy", "array")); + // maxDepth=0: Only immediate match for "Eliot" (Eliot row), no further traversal + verifyDataRows( + result, rows((Object) List.of(Map.of("name", "Eliot", "reportsTo", "Ron", "id", 2)))); + } + + /** Test 23: Top-level graphLookup with depthField and maxDepth. */ + @Test + public void testTopLevelGraphLookupWithDepthField() throws IOException { + JSONObject result = + executeQuery( + String.format( + "graphLookup %s" + + " start='Eliot'" + + " edge=reportsTo-->name" + + " depthField=level" + + " maxDepth=5" + + " as reportingHierarchy", + TEST_INDEX_GRAPH_EMPLOYEES)); + + verifySchema(result, schema("reportingHierarchy", "array")); + verifyDataRows( + result, + rows( + (Object) + List.of( + mapOf("name", "Eliot", "reportsTo", "Ron", "id", 2, "level", 0), + mapOf("name", "Ron", "reportsTo", "Andrew", "id", 3, "level", 1), + mapOf("name", "Andrew", "reportsTo", null, "id", 4, "level", 2)))); + } + + /** Test 24: Top-level graphLookup with non-existent start value yields empty results. */ + @Test + public void testTopLevelGraphLookupNonExistentStart() throws IOException { + JSONObject result = + executeQuery( + String.format( + "graphLookup %s" + + " start='NonExistent'" + + " edge=reportsTo-->name" + + " as reportingHierarchy", + TEST_INDEX_GRAPH_EMPLOYEES)); + + verifySchema(result, schema("reportingHierarchy", "array")); + verifyDataRows(result, rows((Object) Collections.emptyList())); + } } diff --git a/integ-test/src/test/java/org/opensearch/sql/ppl/NewAddedCommandsIT.java b/integ-test/src/test/java/org/opensearch/sql/ppl/NewAddedCommandsIT.java index 84fdfdceb43..ded727765f7 100644 --- a/integ-test/src/test/java/org/opensearch/sql/ppl/NewAddedCommandsIT.java +++ b/integ-test/src/test/java/org/opensearch/sql/ppl/NewAddedCommandsIT.java @@ -10,6 +10,7 @@ import static org.opensearch.sql.common.setting.Settings.Key.CALCITE_ENGINE_ENABLED; import static org.opensearch.sql.legacy.TestsConstants.TEST_INDEX_BANK; import static org.opensearch.sql.legacy.TestsConstants.TEST_INDEX_DOG; +import static org.opensearch.sql.legacy.TestsConstants.TEST_INDEX_GRAPH_EMPLOYEES; import static org.opensearch.sql.legacy.TestsConstants.TEST_INDEX_MVEXPAND_EDGE_CASES; import static org.opensearch.sql.legacy.TestsConstants.TEST_INDEX_STRINGS; @@ -28,6 +29,7 @@ public void init() throws Exception { loadIndex(Index.DOG); loadIndex(Index.STRINGS); loadIndex(Index.MVEXPAND_EDGE_CASES); + loadIndex(Index.GRAPH_EMPLOYEES); } @Test @@ -240,6 +242,39 @@ public void testConvertCommand() throws IOException { } } + @Test + public void testGraphLookup() throws IOException { + enabledOnlyWhenPushdownIsEnabled(); + JSONObject result; + try { + result = + executeQuery( + String.format( + "source=%s | graphLookup %s start=reportsTo edge=reportsTo-->name" + + " as reportingHierarchy", + TEST_INDEX_GRAPH_EMPLOYEES, TEST_INDEX_GRAPH_EMPLOYEES)); + } catch (ResponseException e) { + result = new JSONObject(TestUtils.getResponseBody(e.getResponse())); + } + verifyQuery(result); + } + + @Test + public void testGraphLookupTopLevel() throws IOException { + enabledOnlyWhenPushdownIsEnabled(); + JSONObject result; + try { + result = + executeQuery( + String.format( + "graphLookup %s start='Eliot' edge=reportsTo-->name as reportingHierarchy", + TEST_INDEX_GRAPH_EMPLOYEES)); + } catch (ResponseException e) { + result = new JSONObject(TestUtils.getResponseBody(e.getResponse())); + } + verifyQuery(result); + } + private void verifyQuery(JSONObject result) throws IOException { if (isCalciteEnabled()) { assertFalse(result.getJSONArray("datarows").isEmpty()); diff --git a/integ-test/src/test/resources/expectedOutput/calcite/explain_graphlookup.yaml b/integ-test/src/test/resources/expectedOutput/calcite/explain_graphlookup.yaml new file mode 100644 index 00000000000..75c938aa9e5 --- /dev/null +++ b/integ-test/src/test/resources/expectedOutput/calcite/explain_graphlookup.yaml @@ -0,0 +1,15 @@ +calcite: + logical: | + LogicalSystemLimit(fetch=[10000], type=[QUERY_SIZE_LIMIT]) + LogicalProject(name=[$0], reportsTo=[$1], id=[$2], reportingHierarchy=[$9]) + LogicalGraphLookup(fromField=[reportsTo], toField=[name], outputField=[reportingHierarchy], depthField=[null], maxDepth=[0], bidirectional=[false]) + LogicalSort(fetch=[100]) + CalciteLogicalIndexScan(table=[[OpenSearch, opensearch-sql_test_index_graph_employees]]) + LogicalProject(name=[$0], reportsTo=[$1], id=[$2]) + CalciteLogicalIndexScan(table=[[OpenSearch, opensearch-sql_test_index_graph_employees]]) + physical: | + EnumerableLimit(fetch=[10000]) + EnumerableCalc(expr#0..9=[{inputs}], proj#0..2=[{exprs}], reportingHierarchy=[$t9]) + CalciteEnumerableGraphLookup(fromField=[reportsTo], toField=[name], outputField=[reportingHierarchy], depthField=[null], maxDepth=[0], bidirectional=[false]) + CalciteEnumerableIndexScan(table=[[OpenSearch, opensearch-sql_test_index_graph_employees]], PushDownContext=[[LIMIT->100], OpenSearchRequestBuilder(sourceBuilder={"from":0,"size":100,"timeout":"1m"}, requestedTotalSize=100, pageSize=null, startFrom=0)]) + CalciteEnumerableIndexScan(table=[[OpenSearch, opensearch-sql_test_index_graph_employees]], PushDownContext=[[PROJECT->[name, reportsTo, id]], OpenSearchRequestBuilder(sourceBuilder={"from":0,"timeout":"1m","_source":{"includes":["name","reportsTo","id"],"excludes":[]}}, requestedTotalSize=2147483647, pageSize=null, startFrom=0)]) diff --git a/integ-test/src/test/resources/expectedOutput/calcite/explain_graphlookup_top_level.yaml b/integ-test/src/test/resources/expectedOutput/calcite/explain_graphlookup_top_level.yaml new file mode 100644 index 00000000000..d88a852204f --- /dev/null +++ b/integ-test/src/test/resources/expectedOutput/calcite/explain_graphlookup_top_level.yaml @@ -0,0 +1,12 @@ +calcite: + logical: | + LogicalSystemLimit(fetch=[10000], type=[QUERY_SIZE_LIMIT]) + LogicalGraphLookup(fromField=[reportsTo], toField=[name], outputField=[reportingHierarchy], depthField=[null], maxDepth=[0], bidirectional=[false], startValues=[[Eliot]]) + LogicalValues(tuples=[[]]) + LogicalProject(name=[$0], reportsTo=[$1], id=[$2]) + CalciteLogicalIndexScan(table=[[OpenSearch, opensearch-sql_test_index_graph_employees]]) + physical: | + EnumerableLimit(fetch=[10000]) + CalciteEnumerableGraphLookup(fromField=[reportsTo], toField=[name], outputField=[reportingHierarchy], depthField=[null], maxDepth=[0], bidirectional=[false], startValues=[[Eliot]]) + EnumerableValues(tuples=[[]]) + CalciteEnumerableIndexScan(table=[[OpenSearch, opensearch-sql_test_index_graph_employees]], PushDownContext=[[PROJECT->[name, reportsTo, id]], OpenSearchRequestBuilder(sourceBuilder={"from":0,"timeout":"1m","_source":{"includes":["name","reportsTo","id"],"excludes":[]}}, requestedTotalSize=2147483647, pageSize=null, startFrom=0)]) diff --git a/opensearch/src/main/java/org/opensearch/sql/opensearch/planner/rules/EnumerableGraphLookupRule.java b/opensearch/src/main/java/org/opensearch/sql/opensearch/planner/rules/EnumerableGraphLookupRule.java index e210095b480..b79b58e96c6 100644 --- a/opensearch/src/main/java/org/opensearch/sql/opensearch/planner/rules/EnumerableGraphLookupRule.java +++ b/opensearch/src/main/java/org/opensearch/sql/opensearch/planner/rules/EnumerableGraphLookupRule.java @@ -93,6 +93,7 @@ public RelNode convert(RelNode rel) { convertedSource, convertedLookup, graphLookup.getStartField(), + graphLookup.getStartValues(), graphLookup.getFromField(), graphLookup.getToField(), graphLookup.getOutputField(), diff --git a/opensearch/src/main/java/org/opensearch/sql/opensearch/storage/scan/CalciteEnumerableGraphLookup.java b/opensearch/src/main/java/org/opensearch/sql/opensearch/storage/scan/CalciteEnumerableGraphLookup.java index dd9e3c3f6bc..adbd01e3a04 100644 --- a/opensearch/src/main/java/org/opensearch/sql/opensearch/storage/scan/CalciteEnumerableGraphLookup.java +++ b/opensearch/src/main/java/org/opensearch/sql/opensearch/storage/scan/CalciteEnumerableGraphLookup.java @@ -66,9 +66,9 @@ public class CalciteEnumerableGraphLookup extends GraphLookup implements Enumera * @param cluster Cluster * @param traitSet Trait set (must include EnumerableConvention) * @param source Source table RelNode - * @param lookup Lookup table RelNode // * @param lookupIndex OpenSearchIndex for the lookup table - * (extracted from lookup RelNode) - * @param startField Field name for start entities + * @param lookup Lookup table RelNode + * @param startField Field name for start entities (null in literal start mode) + * @param startValues Literal start values for top-level graphLookup (null in piped mode) * @param fromField Field name for outgoing edges * @param toField Field name for incoming edges * @param outputField Name of the output array field @@ -85,7 +85,8 @@ public CalciteEnumerableGraphLookup( RelTraitSet traitSet, RelNode source, RelNode lookup, - String startField, + @Nullable String startField, + @Nullable List startValues, String fromField, String toField, String outputField, @@ -102,6 +103,7 @@ public CalciteEnumerableGraphLookup( source, lookup, startField, + startValues, fromField, toField, outputField, @@ -122,6 +124,7 @@ public RelNode copy(RelTraitSet traitSet, List inputs) { inputs.get(0), inputs.get(1), startField, + startValues, fromField, toField, outputField, @@ -180,12 +183,13 @@ private static class GraphLookupEnumerator implements Enumerator<@Nullable Objec private final CalciteEnumerableIndexScan lookupScan; private final Enumerator<@Nullable Object> sourceEnumerator; private final List lookupFields; - private final int startFieldIndex; + private int startFieldIndex; private final int fromFieldIdx; private final int toFieldIdx; private Object[] current = null; private boolean batchModeCompleted = false; + private boolean literalStartCompleted = false; @SuppressWarnings("unchecked") GraphLookupEnumerator(CalciteEnumerableGraphLookup graphLookup) { @@ -203,8 +207,11 @@ private static class GraphLookupEnumerator implements Enumerator<@Nullable Objec } // When usePIT is true, no limit is set, allowing PIT-based pagination for complete results - // Get the source enumerator - if (graphLookup.getSource() instanceof Scannable scannable) { + // Get the source enumerator (null for literal start mode) + if (graphLookup.getStartValues() != null) { + this.sourceEnumerator = null; + this.startFieldIndex = -1; + } else if (graphLookup.getSource() instanceof Scannable scannable) { Enumerable sourceEnum = scannable.scan(); this.sourceEnumerator = (Enumerator<@Nullable Object>) sourceEnum.enumerator(); } else { @@ -213,12 +220,15 @@ private static class GraphLookupEnumerator implements Enumerator<@Nullable Objec } try { - List sourceFields = graphLookup.getSource().getRowType().getFieldNames(); this.lookupFields = graphLookup.getLookup().getRowType().getFieldNames(); - this.startFieldIndex = sourceFields.indexOf(graphLookup.getStartField()); this.fromFieldIdx = lookupFields.indexOf(graphLookup.fromField); this.toFieldIdx = lookupFields.indexOf(graphLookup.toField); + if (graphLookup.getStartValues() == null) { + List sourceFields = graphLookup.getSource().getRowType().getFieldNames(); + this.startFieldIndex = sourceFields.indexOf(graphLookup.getStartField()); + } + // Push down user-specified filter to the lookup scan if (graphLookup.filter != null) { List schema = graphLookup.getLookup().getRowType().getFieldNames(); @@ -236,26 +246,51 @@ private static class GraphLookupEnumerator implements Enumerator<@Nullable Objec } } } catch (Exception e) { - sourceEnumerator.close(); + if (sourceEnumerator != null) { + sourceEnumerator.close(); + } throw e; } } @Override public Object current() { + // Literal start mode: single column output, Calcite expects scalar value + if (graphLookup.getStartValues() != null) { + return current[0]; + } // source fields + output array (normal mode) or [source array, lookup array] (batch mode) return current; } @Override public boolean moveNext() { - if (graphLookup.batchMode) { + if (graphLookup.getStartValues() != null) { + return moveNextLiteralStartMode(); + } else if (graphLookup.batchMode) { return moveNextBatchMode(); } else { return moveNextNormalMode(); } } + /** + * Literal start mode: perform single BFS seeded with all literal start values, return one row. + */ + private boolean moveNextLiteralStartMode() { + if (literalStartCompleted) { + return false; + } + literalStartCompleted = true; + + // Perform single BFS seeded with all literal start values + List bfsResults = performBfs(graphLookup.getStartValues()); + + // Output single row: just the hierarchy array + current = new Object[] {bfsResults}; + return true; + } + /** * Batch mode: collect all source start values, perform unified BFS, return single aggregated * row. @@ -541,13 +576,18 @@ private void collectValues(Object value, List collector, Set vis @Override public void reset() { - sourceEnumerator.reset(); + if (sourceEnumerator != null) { + sourceEnumerator.reset(); + } current = null; + literalStartCompleted = false; } @Override public void close() { - sourceEnumerator.close(); + if (sourceEnumerator != null) { + sourceEnumerator.close(); + } } } } diff --git a/ppl/src/main/antlr/OpenSearchPPLParser.g4 b/ppl/src/main/antlr/OpenSearchPPLParser.g4 index 62766105595..c15e3461387 100644 --- a/ppl/src/main/antlr/OpenSearchPPLParser.g4 +++ b/ppl/src/main/antlr/OpenSearchPPLParser.g4 @@ -48,6 +48,7 @@ pplCommands | showDataSourcesCommand | searchCommand | multisearchCommand + | graphLookupCommand ; commands @@ -660,7 +661,9 @@ graphLookupCommand ; startClause - : START EQUAL startField = fieldExpression + : START EQUAL valueList + | START EQUAL startField = fieldExpression + | START EQUAL startValue = literalValue ; edgeClause @@ -705,7 +708,7 @@ sourceReference sourceFilterArg : ident EQUAL literalValue - | ident IN valueList + | ident IN LT_PRTHS valueList RT_PRTHS ; // join @@ -906,7 +909,7 @@ expression : valueExpression # valueExpr | relevanceExpression # relevanceExpr | left = expression comparisonOperator right = expression # compareExpr - | expression NOT? IN valueList # inExpr + | expression NOT? IN LT_PRTHS valueList RT_PRTHS # inExpr | expression NOT? BETWEEN expression AND expression # between ; @@ -1549,7 +1552,7 @@ intervalUnit ; valueList - : LT_PRTHS literalValue (COMMA literalValue)* RT_PRTHS + : literalValue (COMMA literalValue)* ; qualifiedName diff --git a/ppl/src/main/java/org/opensearch/sql/ppl/parser/AstBuilder.java b/ppl/src/main/java/org/opensearch/sql/ppl/parser/AstBuilder.java index 079cc47cb5d..ff0ef4bd8db 100644 --- a/ppl/src/main/java/org/opensearch/sql/ppl/parser/AstBuilder.java +++ b/ppl/src/main/java/org/opensearch/sql/ppl/parser/AstBuilder.java @@ -1570,7 +1570,22 @@ public UnresolvedPlan visitGraphLookupCommand(OpenSearchPPLParser.GraphLookupCom // Parse required base: start and edge OpenSearchPPLParser.StartClauseContext startCtx = ctx.startClause(); - Field startField = (Field) internalVisitExpression(startCtx.startField); + Field startField = null; + List startValues = null; + if (startCtx.startField != null) { + // Piped mode: start=fieldExpression + startField = (Field) internalVisitExpression(startCtx.startField); + } else if (startCtx.startValue != null) { + // Top-level mode: single literal e.g. start="Jack" + startValues = List.of((Literal) internalVisitExpression(startCtx.startValue)); + } else if (startCtx.valueList() != null) { + // Top-level mode: literal list e.g. start="Jack", "Eliot" + OpenSearchPPLParser.ValueListContext listCtx = startCtx.valueList(); + startValues = new ArrayList<>(); + for (OpenSearchPPLParser.LiteralValueContext lit : listCtx.literalValue()) { + startValues.add((Literal) internalVisitExpression(lit)); + } + } // Parse edge clause from EDGE_CLAUSE token (e.g., "edge=manager-->name") OpenSearchPPLParser.EdgeClauseContext edgeCtx = ctx.edgeClause(); String edgeClauseText = edgeCtx.edgeClauseToken.getText(); @@ -1630,6 +1645,7 @@ public UnresolvedPlan visitGraphLookupCommand(OpenSearchPPLParser.GraphLookupCom .as(as) .maxDepth(maxDepth) .startField(startField) + .startValues(startValues) .depthField(depthField) .direction(direction) .supportArray(supportArray) diff --git a/ppl/src/main/java/org/opensearch/sql/ppl/utils/PPLQueryDataAnonymizer.java b/ppl/src/main/java/org/opensearch/sql/ppl/utils/PPLQueryDataAnonymizer.java index 96c0787d5e3..fd1c10fea9c 100644 --- a/ppl/src/main/java/org/opensearch/sql/ppl/utils/PPLQueryDataAnonymizer.java +++ b/ppl/src/main/java/org/opensearch/sql/ppl/utils/PPLQueryDataAnonymizer.java @@ -230,11 +230,26 @@ public String visitLookup(Lookup node, String context) { @Override public String visitGraphLookup(GraphLookup node, String context) { - String child = node.getChild().get(0).accept(this, context); StringBuilder command = new StringBuilder(); - command.append(child).append(" | graphlookup ").append(MASK_TABLE); - if (node.getStartField() != null) { - command.append(" start=").append(MASK_COLUMN); + if (node.getStartValues() != null) { + // Top-level mode: no child/pipe prefix + command.append("graphlookup ").append(MASK_TABLE); + if (node.getStartValues().size() == 1) { + command.append(" start=").append(MASK_LITERAL); + } else { + command.append(" start="); + for (int i = 0; i < node.getStartValues().size(); i++) { + if (i > 0) command.append(", "); + command.append(MASK_LITERAL); + } + } + } else { + // Piped mode: has child + String child = node.getChild().get(0).accept(this, context); + command.append(child).append(" | graphlookup ").append(MASK_TABLE); + if (node.getStartField() != null) { + command.append(" start=").append(MASK_COLUMN); + } } String arrow = node.getDirection() == GraphLookup.Direction.BI ? "<->" : "-->"; command.append(" edge=").append(MASK_COLUMN).append(arrow).append(MASK_COLUMN); diff --git a/ppl/src/test/java/org/opensearch/sql/ppl/calcite/CalcitePPLGraphLookupTest.java b/ppl/src/test/java/org/opensearch/sql/ppl/calcite/CalcitePPLGraphLookupTest.java index e6cbfefc15a..3f1a1c7c0ab 100644 --- a/ppl/src/test/java/org/opensearch/sql/ppl/calcite/CalcitePPLGraphLookupTest.java +++ b/ppl/src/test/java/org/opensearch/sql/ppl/calcite/CalcitePPLGraphLookupTest.java @@ -5,6 +5,8 @@ package org.opensearch.sql.ppl.calcite; +import static org.junit.Assert.assertTrue; + import com.google.common.collect.ImmutableList; import java.util.List; import lombok.RequiredArgsConstructor; @@ -31,7 +33,9 @@ import org.apache.calcite.tools.Frameworks; import org.apache.calcite.tools.Programs; import org.checkerframework.checker.nullness.qual.Nullable; +import org.junit.Assert; import org.junit.Test; +import org.opensearch.sql.exception.SemanticCheckException; public class CalcitePPLGraphLookupTest extends CalcitePPLAbstractTest { @@ -129,6 +133,39 @@ public void testGraphLookupWithCompoundFilter() { verifyLogical(root, expectedLogical); } + @Test + public void testGraphLookupTopLevelSingleLiteral() { + // Top-level graphLookup with single literal start value + String ppl = + "graphLookup employee start=\"Dev\" edge=reportsTo-->name" + " as reportingHierarchy"; + + RelNode root = getRelNode(ppl); + String expectedLogical = + "LogicalGraphLookup(fromField=[reportsTo], toField=[name]," + + " outputField=[reportingHierarchy], depthField=[null], maxDepth=[0]," + + " bidirectional=[false], startValues=[[Dev]])\n" + + " LogicalValues(tuples=[[]])\n" + + " LogicalTableScan(table=[[scott, employee]])\n"; + verifyLogical(root, expectedLogical); + } + + @Test + public void testGraphLookupTopLevelLiteralList() { + // Top-level graphLookup with multiple literal start values + String ppl = + "graphLookup employee start=\"Dev\", \"Eliot\" edge=reportsTo-->name" + + " as reportingHierarchy"; + + RelNode root = getRelNode(ppl); + String expectedLogical = + "LogicalGraphLookup(fromField=[reportsTo], toField=[name]," + + " outputField=[reportingHierarchy], depthField=[null], maxDepth=[0]," + + " bidirectional=[false], startValues=[[Dev, Eliot]])\n" + + " LogicalValues(tuples=[[]])\n" + + " LogicalTableScan(table=[[scott, employee]])\n"; + verifyLogical(root, expectedLogical); + } + @Test public void testGraphLookupBidirectional() { // Test graphLookup with bidirectional traversal @@ -147,6 +184,33 @@ public void testGraphLookupBidirectional() { verifyLogical(root, expectedLogical); } + @Test + public void testGraphLookupLiteralStartInPipedModeIgnoreChild() { + // Literal start values should not be allowed in piped mode + String ppl = + "source=employee | where name=\"Dev\" | graphLookup employee start=\"Dev\"" + + " edge=reportsTo-->name as reportingHierarchy"; + + RelNode root = getRelNode(ppl); + String expectedLogical = + "LogicalGraphLookup(fromField=[reportsTo], toField=[name]," + + " outputField=[reportingHierarchy], depthField=[null], maxDepth=[0]," + + " bidirectional=[false], startValues=[[Dev]])\n" + + " LogicalValues(tuples=[[]])\n" + + " LogicalTableScan(table=[[scott, employee]])\n"; + verifyLogical(root, expectedLogical); + } + + @Test + public void testGraphLookupFieldStartInTopLevelModeRejectsError() { + // Field reference start should not be allowed in top-level mode (no piped source) + String ppl = + "graphLookup employee start=reportsTo edge=reportsTo-->name" + " as reportingHierarchy"; + + Throwable t = Assert.assertThrows(SemanticCheckException.class, () -> getRelNode(ppl)); + assertTrue(t.getMessage().contains("Field reference start requires a piped source")); + } + @Override protected Frameworks.ConfigBuilder config(CalciteAssert.SchemaSpec... schemaSpecs) { final SchemaPlus rootSchema = Frameworks.createRootSchema(true); diff --git a/ppl/src/test/java/org/opensearch/sql/ppl/utils/PPLQueryDataAnonymizerTest.java b/ppl/src/test/java/org/opensearch/sql/ppl/utils/PPLQueryDataAnonymizerTest.java index bb720bd4207..de230d208bb 100644 --- a/ppl/src/test/java/org/opensearch/sql/ppl/utils/PPLQueryDataAnonymizerTest.java +++ b/ppl/src/test/java/org/opensearch/sql/ppl/utils/PPLQueryDataAnonymizerTest.java @@ -710,6 +710,27 @@ public void testGraphLookup() { + " filter=(status = 'active' AND id > 2) as reportingHierarchy")); } + @Test + public void testGraphLookupTopLevel() { + // Top-level graphLookup with single literal + assertEquals( + "graphlookup table start=*** edge=identifier-->identifier as identifier", + anonymize( + "graphLookup employees start=\"Jack\" edge=manager-->name" + " as reportingHierarchy")); + // Top-level graphLookup with literal list + assertEquals( + "graphlookup table start=***, *** edge=identifier-->identifier as identifier", + anonymize( + "graphLookup employees start=\"Jack\", \"Eliot\" edge=manager-->name" + + " as reportingHierarchy")); + // Top-level graphLookup with maxDepth + assertEquals( + "graphlookup table start=*** edge=identifier-->identifier maxDepth=*** as identifier", + anonymize( + "graphLookup employees start=\"Jack\" edge=manager-->name" + + " maxDepth=3 as reportingHierarchy")); + } + @Test public void testInSubquery() { assertEquals(