Skip to content

Commit 94649fb

Browse files
authored
[Feature] PPL Highlight Support (#5234)
1 parent 87067f1 commit 94649fb

38 files changed

Lines changed: 1412 additions & 33 deletions

File tree

core/src/main/java/org/opensearch/sql/ast/statement/Query.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import lombok.Setter;
1212
import lombok.ToString;
1313
import org.opensearch.sql.ast.AbstractNodeVisitor;
14+
import org.opensearch.sql.ast.tree.HighlightConfig;
1415
import org.opensearch.sql.ast.tree.UnresolvedPlan;
1516
import org.opensearch.sql.executor.QueryType;
1617

@@ -25,6 +26,7 @@ public class Query extends Statement {
2526
protected final UnresolvedPlan plan;
2627
protected final int fetchSize;
2728
private final QueryType queryType;
29+
private HighlightConfig highlightConfig;
2830

2931
@Override
3032
public <R, C> R accept(AbstractNodeVisitor<R, C> visitor, C context) {
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package org.opensearch.sql.ast.tree;
7+
8+
import java.util.LinkedHashMap;
9+
import java.util.List;
10+
import java.util.Map;
11+
12+
/**
13+
* Bundles highlight configuration: field names (or wildcards) with per-field options, and optional
14+
* global pre/post tags and fragment size. Supports both the simple array format ({@code ["*"]}) and
15+
* the rich OSD object format with {@code pre_tags}, {@code post_tags}, {@code fields}, and {@code
16+
* fragment_size}.
17+
*
18+
* <p>The {@code fields} map keys are field names or wildcards; the values are per-field option maps
19+
* that are passed through to the OpenSearch highlight builder (e.g. {@code fragment_size}, {@code
20+
* number_of_fragments}, {@code type}).
21+
*/
22+
public record HighlightConfig(
23+
Map<String, Map<String, Object>> fields,
24+
List<String> preTags,
25+
List<String> postTags,
26+
Integer fragmentSize) {
27+
28+
/** Convenience constructor for the simple array format (fields only, no tag/size overrides). */
29+
public HighlightConfig(List<String> fieldNames) {
30+
this(toFieldMap(fieldNames), null, null, null);
31+
}
32+
33+
/** Returns the field names as a list (for display and iteration). */
34+
public List<String> fieldNames() {
35+
return fields == null ? List.of() : List.copyOf(fields.keySet());
36+
}
37+
38+
private static Map<String, Map<String, Object>> toFieldMap(List<String> fieldNames) {
39+
if (fieldNames == null) {
40+
return null;
41+
}
42+
Map<String, Map<String, Object>> map = new LinkedHashMap<>();
43+
for (String name : fieldNames) {
44+
map.put(name, Map.of());
45+
}
46+
return map;
47+
}
48+
}

core/src/main/java/org/opensearch/sql/calcite/CalcitePlanContext.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import org.apache.calcite.rex.RexNode;
2323
import org.apache.calcite.tools.FrameworkConfig;
2424
import org.opensearch.sql.ast.expression.UnresolvedExpression;
25+
import org.opensearch.sql.ast.tree.HighlightConfig;
2526
import org.opensearch.sql.calcite.utils.CalciteToolsHelper;
2627
import org.opensearch.sql.calcite.utils.CalciteToolsHelper.OpenSearchRelBuilder;
2728
import org.opensearch.sql.common.setting.Settings;
@@ -45,6 +46,7 @@ public class CalcitePlanContext {
4546
private static final ThreadLocal<Boolean> legacyPreferredFlag =
4647
ThreadLocal.withInitial(() -> true);
4748

49+
@Getter @Setter private HighlightConfig highlightConfig;
4850
@Getter @Setter private boolean isResolvingJoinCondition = false;
4951
@Getter @Setter private boolean isResolvingSubquery = false;
5052
@Getter @Setter private boolean inCoalesceFunction = false;
@@ -96,6 +98,7 @@ private CalcitePlanContext(CalcitePlanContext parent) {
9698
this.relBuilder = parent.relBuilder; // Share the same relBuilder
9799
this.rexBuilder = parent.rexBuilder; // Share the same rexBuilder
98100
this.functionProperties = parent.functionProperties;
101+
this.highlightConfig = parent.highlightConfig;
99102
this.rexLambdaRefMap = new HashMap<>(); // New map for lambda variables
100103
this.capturedVariables = new ArrayList<>(); // New list for captured variables
101104
this.inLambdaContext = true; // Mark that we're inside a lambda

core/src/main/java/org/opensearch/sql/calcite/CalciteRelNodeVisitor.java

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@
156156
import org.opensearch.sql.ast.tree.Values;
157157
import org.opensearch.sql.ast.tree.Window;
158158
import org.opensearch.sql.calcite.plan.AliasFieldsWrappable;
159+
import org.opensearch.sql.calcite.plan.HighlightPushDown;
159160
import org.opensearch.sql.calcite.plan.OpenSearchConstants;
160161
import org.opensearch.sql.calcite.plan.rel.LogicalGraphLookup;
161162
import org.opensearch.sql.calcite.plan.rel.LogicalSystemLimit;
@@ -171,6 +172,7 @@
171172
import org.opensearch.sql.datasource.DataSourceService;
172173
import org.opensearch.sql.exception.CalciteUnsupportedException;
173174
import org.opensearch.sql.exception.SemanticCheckException;
175+
import org.opensearch.sql.expression.HighlightExpression;
174176
import org.opensearch.sql.expression.function.BuiltinFunctionName;
175177
import org.opensearch.sql.expression.function.PPLFuncImpTable;
176178
import org.opensearch.sql.expression.parse.RegexCommonUtils;
@@ -225,10 +227,21 @@ public RelNode visitRelation(Relation node, CalcitePlanContext context) {
225227
}
226228
context.relBuilder.scan(node.getTableQualifiedName().getParts());
227229
RelNode scan = context.relBuilder.peek();
230+
231+
// Eagerly push down highlight config to the scan (highlight is a scan hint, not an operator)
232+
if (context.getHighlightConfig() != null && scan instanceof HighlightPushDown) {
233+
RelNode newScan = ((HighlightPushDown) scan).pushDownHighlight(context.getHighlightConfig());
234+
context.relBuilder.build(); // pop old scan
235+
context.relBuilder.push(newScan);
236+
scan = newScan;
237+
context.setHighlightConfig(null); // consumed
238+
}
239+
228240
if (scan instanceof AliasFieldsWrappable) {
229-
return ((AliasFieldsWrappable) scan).wrapProjectForAliasFields(context.relBuilder);
241+
((AliasFieldsWrappable) scan).wrapProjectForAliasFields(context.relBuilder);
230242
}
231-
return scan;
243+
244+
return context.relBuilder.peek();
232245
}
233246

234247
// This is a tool method to add an existed RelOptTable to builder stack, not used for now
@@ -419,6 +432,12 @@ public RelNode visitProject(Project node, CalcitePlanContext context) {
419432
List<RexNode> expandedFields =
420433
expandProjectFields(node.getProjectList(), currentFields, context);
421434

435+
// Include _highlight in projections when the highlight column is present in the schema
436+
int hlIndex = currentFields.indexOf(HighlightExpression.HIGHLIGHT_FIELD);
437+
if (hlIndex >= 0) {
438+
expandedFields.add(context.relBuilder.field(hlIndex));
439+
}
440+
422441
if (node.isExcluded()) {
423442
validateExclusion(expandedFields, currentFields);
424443
context.relBuilder.projectExcept(expandedFields);
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package org.opensearch.sql.calcite.plan;
7+
8+
import org.apache.calcite.rel.RelNode;
9+
import org.opensearch.sql.ast.tree.HighlightConfig;
10+
11+
/**
12+
* Interface for scan nodes that support highlight pushdown. Highlight is a scan hint (not a
13+
* relational operator), so it is pushed down eagerly during plan construction rather than via an
14+
* optimizer rule.
15+
*/
16+
public interface HighlightPushDown {
17+
RelNode pushDownHighlight(HighlightConfig highlightConfig);
18+
}

core/src/main/java/org/opensearch/sql/executor/QueryService.java

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import org.opensearch.sql.analysis.AnalysisContext;
2828
import org.opensearch.sql.analysis.Analyzer;
2929
import org.opensearch.sql.ast.statement.ExplainMode;
30+
import org.opensearch.sql.ast.tree.HighlightConfig;
3031
import org.opensearch.sql.ast.tree.UnresolvedPlan;
3132
import org.opensearch.sql.calcite.CalcitePlanContext;
3233
import org.opensearch.sql.calcite.CalciteRelNodeVisitor;
@@ -87,8 +88,17 @@ public void execute(
8788
UnresolvedPlan plan,
8889
QueryType queryType,
8990
ResponseListener<ExecutionEngine.QueryResponse> listener) {
91+
execute(plan, queryType, null, listener);
92+
}
93+
94+
/** Execute with optional highlight config. */
95+
public void execute(
96+
UnresolvedPlan plan,
97+
QueryType queryType,
98+
HighlightConfig highlightConfig,
99+
ResponseListener<ExecutionEngine.QueryResponse> listener) {
90100
if (shouldUseCalcite(queryType)) {
91-
executeWithCalcite(plan, queryType, listener);
101+
executeWithCalcite(plan, queryType, highlightConfig, listener);
92102
} else {
93103
executeWithLegacy(plan, queryType, listener, Optional.empty());
94104
}
@@ -100,8 +110,18 @@ public void explain(
100110
QueryType queryType,
101111
ResponseListener<ExecutionEngine.ExplainResponse> listener,
102112
ExplainMode mode) {
113+
explain(plan, queryType, null, listener, mode);
114+
}
115+
116+
/** Explain with optional highlight config. */
117+
public void explain(
118+
UnresolvedPlan plan,
119+
QueryType queryType,
120+
HighlightConfig highlightConfig,
121+
ResponseListener<ExecutionEngine.ExplainResponse> listener,
122+
ExplainMode mode) {
103123
if (shouldUseCalcite(queryType)) {
104-
explainWithCalcite(plan, queryType, listener, mode);
124+
explainWithCalcite(plan, queryType, highlightConfig, listener, mode);
105125
} else {
106126
explainWithLegacy(plan, queryType, listener, mode, Optional.empty());
107127
}
@@ -110,6 +130,7 @@ public void explain(
110130
public void executeWithCalcite(
111131
UnresolvedPlan plan,
112132
QueryType queryType,
133+
HighlightConfig highlightConfig,
113134
ResponseListener<ExecutionEngine.QueryResponse> listener) {
114135
CalcitePlanContext.run(
115136
() -> {
@@ -121,6 +142,7 @@ public void executeWithCalcite(
121142
CalcitePlanContext context =
122143
CalcitePlanContext.create(
123144
buildFrameworkConfig(), SysLimit.fromSettings(settings), queryType);
145+
context.setHighlightConfig(highlightConfig);
124146
RelNode relNode = analyze(plan, context);
125147
RelNode calcitePlan = convertToCalcitePlan(relNode, context);
126148
analyzeMetric.set(System.nanoTime() - analyzeStart);
@@ -140,6 +162,7 @@ public void executeWithCalcite(
140162
public void explainWithCalcite(
141163
UnresolvedPlan plan,
142164
QueryType queryType,
165+
HighlightConfig highlightConfig,
143166
ResponseListener<ExecutionEngine.ExplainResponse> listener,
144167
ExplainMode mode) {
145168
CalcitePlanContext.run(
@@ -149,6 +172,7 @@ public void explainWithCalcite(
149172
CalcitePlanContext context =
150173
CalcitePlanContext.create(
151174
buildFrameworkConfig(), SysLimit.fromSettings(settings), queryType);
175+
context.setHighlightConfig(highlightConfig);
152176
context.run(
153177
() -> {
154178
RelNode relNode = analyze(plan, context);

core/src/main/java/org/opensearch/sql/executor/execution/QueryPlan.java

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import java.util.Optional;
99
import org.apache.commons.lang3.NotImplementedException;
1010
import org.opensearch.sql.ast.statement.ExplainMode;
11+
import org.opensearch.sql.ast.tree.HighlightConfig;
1112
import org.opensearch.sql.ast.tree.Paginate;
1213
import org.opensearch.sql.ast.tree.UnresolvedPlan;
1314
import org.opensearch.sql.common.response.ResponseListener;
@@ -29,18 +30,32 @@ public class QueryPlan extends AbstractPlan {
2930

3031
protected final Optional<Integer> pageSize;
3132

33+
protected final HighlightConfig highlightConfig;
34+
3235
/** Constructor. */
3336
public QueryPlan(
3437
QueryId queryId,
3538
QueryType queryType,
3639
UnresolvedPlan plan,
3740
QueryService queryService,
3841
ResponseListener<ExecutionEngine.QueryResponse> listener) {
42+
this(queryId, queryType, plan, queryService, listener, null);
43+
}
44+
45+
/** Constructor with highlight config. */
46+
public QueryPlan(
47+
QueryId queryId,
48+
QueryType queryType,
49+
UnresolvedPlan plan,
50+
QueryService queryService,
51+
ResponseListener<ExecutionEngine.QueryResponse> listener,
52+
HighlightConfig highlightConfig) {
3953
super(queryId, queryType);
4054
this.plan = plan;
4155
this.queryService = queryService;
4256
this.listener = listener;
4357
this.pageSize = Optional.empty();
58+
this.highlightConfig = highlightConfig;
4459
}
4560

4661
/** Constructor with page size. */
@@ -56,14 +71,15 @@ public QueryPlan(
5671
this.queryService = queryService;
5772
this.listener = listener;
5873
this.pageSize = Optional.of(pageSize);
74+
this.highlightConfig = null;
5975
}
6076

6177
@Override
6278
public void execute() {
6379
if (pageSize.isPresent()) {
6480
queryService.execute(new Paginate(pageSize.get(), plan), getQueryType(), listener);
6581
} else {
66-
queryService.execute(plan, getQueryType(), listener);
82+
queryService.execute(plan, getQueryType(), highlightConfig, listener);
6783
}
6884
}
6985

@@ -75,7 +91,7 @@ public void explain(
7591
new NotImplementedException(
7692
"`explain` feature for paginated requests is not implemented yet."));
7793
} else {
78-
queryService.explain(plan, getQueryType(), listener, mode);
94+
queryService.explain(plan, getQueryType(), highlightConfig, listener, mode);
7995
}
8096
}
8197
}

core/src/main/java/org/opensearch/sql/executor/execution/QueryPlanFactory.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,12 @@ public AbstractPlan visitQuery(
122122
}
123123
} else {
124124
return new QueryPlan(
125-
QueryId.queryId(), node.getQueryType(), node.getPlan(), queryService, context.getLeft());
125+
QueryId.queryId(),
126+
node.getQueryType(),
127+
node.getPlan(),
128+
queryService,
129+
context.getLeft(),
130+
node.getHighlightConfig());
126131
}
127132
}
128133

core/src/main/java/org/opensearch/sql/expression/HighlightExpression.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
/** Highlight Expression. */
2424
@Getter
2525
public class HighlightExpression extends FunctionExpression {
26+
public static final String HIGHLIGHT_FIELD = "_highlight";
27+
2628
private final Expression highlightField;
2729
private final ExprType type;
2830

core/src/test/java/org/opensearch/sql/executor/execution/QueryPlanTest.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,15 +52,15 @@ public void execute_no_page_size() {
5252
QueryPlan query = new QueryPlan(queryId, queryType, plan, queryService, queryListener);
5353
query.execute();
5454

55-
verify(queryService, times(1)).execute(any(), any(), any());
55+
verify(queryService, times(1)).execute(any(), any(), any(), any());
5656
}
5757

5858
@Test
5959
public void explain_no_page_size() {
6060
QueryPlan query = new QueryPlan(queryId, queryType, plan, queryService, queryListener);
6161
query.explain(explainListener, mode);
6262

63-
verify(queryService, times(1)).explain(plan, queryType, explainListener, mode);
63+
verify(queryService, times(1)).explain(plan, queryType, null, explainListener, mode);
6464
}
6565

6666
@Test

0 commit comments

Comments
 (0)