Skip to content

Commit 3a2b1e6

Browse files
committed
Report a misspelled bucket parameter instead of deferring it
Follow-up to the review. Accepting bare argument names means anything now parses, so an unrecognised name reaches the builder as a leftover argument and was being declined as a syntax check -- which routes it to the legacy engine. A typo would quietly become a legacy-engine query instead of an error, the failure mode the earlier review comment was about. Only the parameters the legacy engine actually implements -- alias, format, time_zone, min_doc_count, order -- defer now. Anything else is a semantic check, so the caller sees the message. Also in this commit: `ifnull` is built from BuiltinFunctionName like the other constant function names in this file rather than a string literal; the new capability constant no longer sits between LEGACY_METHOD_QUERY and its javadoc, which left that constant undocumented. Correcting the previous commit message: it said the grouping limitation was specific to two grouping keys and that a single key was fine. That is wrong. A span over a bare table scan cannot resolve its field either way -- SELECT date_histogram('field'=ts, 'interval'='1h') AS b, COUNT(*) FROM idx GROUP BY date_histogram('field'=ts, 'interval'='1h') fails on both routes, with or without the select alias, so the bucket always has to be projected in a derived table first. What the grammar change did fix is the bare-name spelling, which is what let the two capability gates go. A test now pins the rejection, asserting only that it is rejected, since the two routes word the error differently. Added coverage for the 1M and 1y calendar units Dashboards emits at the wider zoom levels, which nothing exercised before. Verified: 13 integration tests, none failing or skipped, on the default route; `:sql:build` green including the coverage gate. Signed-off-by: Jialiang Liang <ryanleeang@gmail.com>
1 parent bb83e90 commit 3a2b1e6

4 files changed

Lines changed: 73 additions & 23 deletions

File tree

‎integ-test/src/test/java/org/opensearch/sql/sql/DateHistogramBucketFunctionIT.java‎

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
package org.opensearch.sql.sql;
77

8+
import static org.junit.Assert.assertThrows;
89
import static org.opensearch.sql.util.Capability.LEGACY_ENGINE_FALLBACK;
910
import static org.opensearch.sql.util.MatcherUtils.rows;
1011
import static org.opensearch.sql.util.MatcherUtils.verifyDataRows;
@@ -13,6 +14,7 @@
1314
import java.io.IOException;
1415
import org.json.JSONObject;
1516
import org.junit.Test;
17+
import org.opensearch.client.ResponseException;
1618
import org.opensearch.sql.legacy.SQLIntegTestCase;
1719
import org.opensearch.sql.util.RequiresCapability;
1820

@@ -38,7 +40,10 @@ protected void init() throws Exception {
3840
loadIndex(Index.DATE_HISTOGRAM_TEST);
3941
}
4042

41-
/** The planner rejects {@code GROUP BY <expression>}, so the bucket is aliased in a subquery. */
43+
/**
44+
* The bucket has to be projected in a derived table before it can be grouped on; see {@link
45+
* #groupingOnTheBucketWithoutADerivedTableIsRejected}. This is also the shape Dashboards emits.
46+
*/
4247
private static String bucketed(String bucketExpr) {
4348
return "SELECT b, COUNT(*) FROM (SELECT "
4449
+ bucketExpr
@@ -99,10 +104,7 @@ public void intervalSynonymsProduceTheSameBuckets() throws IOException {
99104
}
100105
}
101106

102-
/**
103-
* The scan sits in its own derived table because the V2 engine cannot resolve the span's field
104-
* otherwise when a second grouping key is present.
105-
*/
107+
/** A second grouping key needs the scan in a derived table of its own as well. */
106108
@Test
107109
public void bucketsCombineWithAnAdditionalGroupingKey() throws IOException {
108110
JSONObject response =
@@ -215,4 +217,31 @@ public void unquotedArgumentNamesReturnNumericBuckets() throws IOException {
215217

216218
verifyDataRowsInOrder(response, rows(0, 19), rows(20, 20), rows(40, 20), rows(60, 13));
217219
}
220+
221+
/**
222+
* A span over a bare table scan cannot resolve its field, with or without a select alias, so the
223+
* bucket always has to be projected in a derived table first. Both routes reject this; only the
224+
* message differs, so the assertion is on the rejection alone.
225+
*/
226+
@Test
227+
public void groupingOnTheBucketWithoutADerivedTableIsRejected() {
228+
assertThrows(
229+
ResponseException.class,
230+
() ->
231+
executeQuery(
232+
"SELECT date_histogram('field'=ts, 'interval'='1h') AS b, COUNT(*) FROM "
233+
+ IDX
234+
+ " GROUP BY date_histogram('field'=ts, 'interval'='1h')"));
235+
}
236+
237+
/** Calendar units: Dashboards emits 1M and 1y at the wider zoom levels. */
238+
@Test
239+
public void calendarIntervalsBucketByMonthAndYear() throws IOException {
240+
verifyDataRows(
241+
executeQuery(bucketed("date_histogram('field'=ts, 'interval'='1M')")),
242+
rows("2026-01-01 00:00:00", 72));
243+
verifyDataRows(
244+
executeQuery(bucketed("date_histogram('field'=ts, 'interval'='1y')")),
245+
rows("2026-01-01 00:00:00", 72));
246+
}
218247
}

‎integ-test/src/test/java/org/opensearch/sql/util/Capability.java‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -537,21 +537,21 @@ public enum Capability {
537537
PREPARED_STATEMENT(
538538
"Prepared statements are unsupported on the analytics-engine route (Calcite path)."),
539539

540-
/**
541-
* FRONTEND: legacy method-query syntax (regexp_query/wildcard_query) is not in the Calcite
542-
* grammar.
543-
*/
544540
/**
545541
* FRONTEND: the legacy V1 engine answers call shapes the V2 grammar declines, but only on the
546542
* default route. Requests reach it when RestSQLQueryAction catches a SyntaxCheckException; the
547543
* analytics-engine route enters through RestUnifiedQueryAction, which has no such fallback.
548544
*/
549545
LEGACY_ENGINE_FALLBACK(
550-
"A call shape only the legacy V1 engine understands (e.g. positional"
551-
+ " date_histogram(field=<col>, ...), or an `alias` parameter) can't be answered on the"
552-
+ " analytics-engine route: reaching that engine depends on RestSQLQueryAction's"
553-
+ " SyntaxCheckException fallback, and the analytics route does not go through it."),
546+
"A call shape only the legacy V1 engine understands (e.g. a date_histogram `alias`"
547+
+ " parameter) can't be answered on the analytics-engine route: reaching that engine"
548+
+ " depends on RestSQLQueryAction's SyntaxCheckException fallback, and the analytics"
549+
+ " route does not go through it."),
554550

551+
/**
552+
* FRONTEND: legacy method-query syntax (regexp_query/wildcard_query) is not in the Calcite
553+
* grammar.
554+
*/
555555
LEGACY_METHOD_QUERY(
556556
"Legacy method-query syntax (regexp_query/wildcard_query/query/matchquery) is not in the"
557557
+ " Calcite grammar used by the analytics-engine route."),

‎sql/src/main/java/org/opensearch/sql/sql/parser/AstExpressionBuilder.java‎

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import static org.opensearch.sql.ast.dsl.AstDSL.between;
99
import static org.opensearch.sql.ast.dsl.AstDSL.not;
1010
import static org.opensearch.sql.ast.dsl.AstDSL.qualifiedName;
11+
import static org.opensearch.sql.expression.function.BuiltinFunctionName.IFNULL;
1112
import static org.opensearch.sql.expression.function.BuiltinFunctionName.IS_NOT_NULL;
1213
import static org.opensearch.sql.expression.function.BuiltinFunctionName.IS_NULL;
1314
import static org.opensearch.sql.expression.function.BuiltinFunctionName.LIKE;
@@ -81,6 +82,7 @@
8182
import java.util.Map;
8283
import java.util.Objects;
8384
import java.util.Optional;
85+
import java.util.Set;
8486
import java.util.stream.Collectors;
8587
import java.util.stream.Stream;
8688
import org.antlr.v4.runtime.RuleContext;
@@ -110,6 +112,10 @@
110112
/** Expression builder to parse text to expression in AST. */
111113
public class AstExpressionBuilder extends OpenSearchSQLParserBaseVisitor<UnresolvedExpression> {
112114

115+
/** Bucket parameters a span cannot express, which the legacy engine answers instead. */
116+
private static final Set<String> LEGACY_ONLY_BUCKET_ARGS =
117+
Set.of("alias", "format", "time_zone", "min_doc_count", "order");
118+
113119
private final AstBuildGuard guard;
114120

115121
public AstExpressionBuilder() {
@@ -173,9 +179,9 @@ public UnresolvedExpression visitScalarFunctionCall(ScalarFunctionCallContext ct
173179

174180
/**
175181
* Lowers {@code histogram} and {@code date_histogram} to a {@link Span} over the bucketed field.
176-
* The grammar admits only the {@code 'name'=value} form, so the positional spelling the legacy
177-
* engine has always answered never reaches here -- it stays an unknown scalar function, and
178-
* RestSQLQueryAction hands it back to that engine.
182+
* Parameters the span cannot express are implemented by the legacy engine through the native
183+
* date_histogram aggregation, so those calls are declined as a syntax check to let
184+
* RestSQLQueryAction hand them back to it.
179185
*/
180186
@Override
181187
public UnresolvedExpression visitBucketFunctionCall(BucketFunctionCallContext ctx) {
@@ -193,13 +199,12 @@ public UnresolvedExpression visitBucketFunctionCall(BucketFunctionCallContext ct
193199
UnresolvedExpression missing = args.remove("missing");
194200
Literal interval = intervalOf(args, functionName);
195201

196-
// Anything left is a parameter this lowering has no equivalent for. The legacy engine
197-
// implements all of them -- alias, format, time_zone, min_doc_count, order -- through the
198-
// native date_histogram aggregation, so decline in the one way RestSQLQueryAction falls back
199-
// on rather than failing a query it can answer.
200202
if (!args.isEmpty()) {
201-
throw new SyntaxCheckException(
202-
functionName + " does not accept parameter: " + String.join(", ", args.keySet()));
203+
String names = String.join(", ", args.keySet());
204+
if (LEGACY_ONLY_BUCKET_ARGS.containsAll(args.keySet())) {
205+
throw new SyntaxCheckException(functionName + " does not accept parameter: " + names);
206+
}
207+
throw new SemanticCheckException(functionName + " does not accept parameter: " + names);
203208
}
204209

205210
return AstDSL.spanFromSpanLengthLiteral(
@@ -262,7 +267,9 @@ private static UnresolvedExpression normalizeField(UnresolvedExpression field) {
262267
*/
263268
private static UnresolvedExpression substituteMissing(
264269
UnresolvedExpression field, UnresolvedExpression missing) {
265-
return missing == null ? field : new Function("ifnull", List.of(field, missing));
270+
return missing == null
271+
? field
272+
: new Function(IFNULL.getName().getFunctionName(), List.of(field, missing));
266273
}
267274

268275
@Override

‎sql/src/test/java/org/opensearch/sql/sql/parser/AstExpressionBuilderTest.java‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -959,6 +959,20 @@ public void badBucketArgumentIsReportedRatherThanDeferred() {
959959
() -> buildExprAst("date_histogram('field'=ts, 'interval'='1d', 'interval'='2d')"));
960960
}
961961

962+
/**
963+
* Bare argument names let anything parse, so a name the legacy engine does not implement either
964+
* is a typo. Reporting it keeps a misspelling from silently becoming a legacy-engine query.
965+
*/
966+
@Test
967+
public void unknownBucketParameterIsReportedRatherThanDeferred() {
968+
assertThrows(
969+
SemanticCheckException.class,
970+
() -> buildExprAst("date_histogram('field'=ts, 'interval'='1d', 'feild'='ts')"));
971+
assertThrows(
972+
SemanticCheckException.class,
973+
() -> buildExprAst("date_histogram('field'=ts, 'interval'='1d', 'alias'='d', 'nope'=1)"));
974+
}
975+
962976
private Node buildExprAst(String expr) {
963977
return buildExprAst(expr, astExprBuilder);
964978
}

0 commit comments

Comments
 (0)