Skip to content

Add SQL histogram and date_histogram bucket functions - #5700

Open
RyanL1997 wants to merge 12 commits into
opensearch-project:mainfrom
RyanL1997:sql-explore/sql-histogram
Open

Add SQL histogram and date_histogram bucket functions#5700
RyanL1997 wants to merge 12 commits into
opensearch-project:mainfrom
RyanL1997:sql-explore/sql-histogram

Conversation

@RyanL1997

@RyanL1997 RyanL1997 commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

Description

Adds histogram and date_histogram to V2 SQL as bucket functions. Each call is lowered during AST construction to primitives that already exist (Span, COALESCE, DATE_FORMAT, TIMESTAMPADD), so no new engine function or execution operator is introduced.

Usage

Arguments are named. Compute the bucket in a subquery and group by its alias — the planner does not accept GROUP BY <expression> directly.

SELECT b, COUNT(*)
FROM (SELECT date_histogram('field'=ts, 'interval'='1h') AS b FROM events) sub
GROUP BY b ORDER BY b
{
  "schema": [
    { "name": "b", "type": "timestamp" },
    { "name": "COUNT(*)", "type": "long" }
  ],
  "datarows": [
    ["2026-01-01 00:00:00", 12],
    ["2026-01-01 01:00:00", 24],
    ["2026-01-01 02:00:00", 17],
    ["2026-01-01 03:00:00", 19]
  ],
  "total": 4, "size": 4, "status": 200
}

The bucket comes back as a timestamp, so intervals below an hour split as you would expect, and a second grouping key works alongside it:

SELECT b, c, COUNT(*)
FROM (SELECT date_histogram('field'=ts, 'interval'='30m') AS b, category AS c
      FROM (SELECT * FROM events) i) sub
GROUP BY b, c ORDER BY b, c
"datarows": [
  ["2026-01-01 00:00:00", "alpha",  5],
  ["2026-01-01 00:30:00", "beta",   7],
  ["2026-01-01 01:00:00", "alpha", 11],
  ["2026-01-01 01:30:00", "gamma", 13],
  ["2026-01-01 02:00:00", "beta",  17],
  ["2026-01-01 03:00:00", "alpha", 19]
]

histogram buckets a numeric field the same way and returns the bucket's lower bound:

SELECT b, COUNT(*)
FROM (SELECT histogram('field'=value, 'interval'=20) AS b FROM events) sub
GROUP BY b ORDER BY b
-- [0, 19], [20, 20], [40, 20], [60, 13]

Parameters

function accepted
histogram field, interval, offset, missing
date_histogram field, interval / fixed_interval / calendar_interval, format, time_zone, missing

The three interval spellings are synonyms; exactly one must be present. min_doc_count, order and alias are rejected because they would have to mutate the surrounding query (HAVING / ORDER BY / the SELECT-list alias). date_histogram's offset is rejected pending a duration-string parser distinct from time_zone's ZoneOffset format.

Positional calls keep going to the legacy engine

These names are new to the V2 grammar but not to the plugin — the legacy engine has accepted date_histogram(field=<col>, 'interval'=<n>) in GROUP BY for a long time, and queries reach it only when V2 raises SyntaxCheckException, the one exception RestSQLQueryAction falls back on. Now that V2 matches these calls first, an unrecognized shape has to decline with that exception or the query stops at V2:

query before this PR with #5514 as written with this PR
GROUP BY date_histogram(field='ts','interval'='1h') 4 buckets HTTP 400 4 buckets
GROUP BY date_histogram('field'='ts','interval'='1h') 4 buckets (legacy) 4 buckets (V2) 4 buckets (V2)

Other rejections are unchanged: once a call is in the named-argument form, a bad parameter is the caller's error and gets a clear message instead of being re-run by an engine that never understood the query.

Check List

  • New functionality includes testing.
  • New functionality has been documented.
  • Commits are signed per the DCO using --signoff.

@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

(Review updated until commit 7950169)

Here are some key observations to aid the review process:

🧪 PR contains tests
🔒 No security concerns identified
✅ No TODO sections
🔀 Multiple PR themes

Sub-PR theme: Add bucket function grammar rules

Relevant files:

  • language-grammar/src/main/antlr4/OpenSearchSQLParser.g4
  • sql/src/main/antlr/OpenSearchSQLParser.g4

Sub-PR theme: Implement bucket function AST lowering

Relevant files:

  • sql/src/main/java/org/opensearch/sql/sql/parser/AstExpressionBuilder.java
  • sql/src/test/java/org/opensearch/sql/sql/parser/AstExpressionBuilderTest.java

Sub-PR theme: Add integration tests for bucket functions

Relevant files:

  • integ-test/src/test/java/org/opensearch/sql/legacy/SQLIntegTestCase.java
  • integ-test/src/test/java/org/opensearch/sql/sql/DateHistogramBucketFunctionIT.java
  • integ-test/src/test/resources/date_histogram_test.json

⚡ Recommended focus areas for review

Possible Issue

The normalizeField method coerces a STRING literal to a column reference, but only checks the literal type, not whether the string content is a valid column name. If a user passes an arbitrary string (e.g., 'not-a-column'), it becomes a qualified name that may fail later during resolution. This could produce confusing error messages instead of catching the issue early.

private static UnresolvedExpression normalizeField(UnresolvedExpression field) {
  if (field instanceof Literal literal && literal.getType() == DataType.STRING) {
    return AstDSL.qualifiedName(literal.getValue().toString());
  }
  return field;
}
Possible Issue

The substituteMissing method uses IFNULL because V2 has no implementation for coalesce, but the comment states V2 registers coalesce as a name. If V2 later implements coalesce, this code will continue using ifnull without any indication that the workaround is still necessary. Consider adding a TODO or version check to revisit this when coalesce becomes available.

/**
 * Substitutes {@code missing} for a null field before bucketing. V2 registers `coalesce` as a
 * name but has no implementation for it, so the query fails at execution with "unsupported
 * function name"; `ifnull` is the two-argument form V2 actually evaluates.
 */
private static UnresolvedExpression substituteMissing(
    UnresolvedExpression field, UnresolvedExpression missing) {
  return missing == null
      ? field
      : new Function(IFNULL.getName().getFunctionName(), List.of(field, missing));
}

@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Latest suggestions up to 7950169

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
General
Check duplicates before processing values

The duplicate parameter check occurs after calling visit(arg.bucketArgValue()),
which may perform expensive operations or have side effects. Move the duplicate
check before visiting the value to avoid unnecessary processing when a duplicate is
detected.

sql/src/main/java/org/opensearch/sql/sql/parser/AstExpressionBuilder.java [193-200]

 Map<String, UnresolvedExpression> args = new LinkedHashMap<>();
 for (BucketArgContext arg : ctx.bucketFunction().bucketArg()) {
   String name = StringUtils.unquoteText(arg.bucketArgName().getText()).toLowerCase(Locale.ROOT);
-  if (args.put(name, visit(arg.bucketArgValue())) != null) {
+  if (args.containsKey(name)) {
     throw new SemanticCheckException(
         String.format("Parameter '%s' can only be specified once.", name));
   }
+  args.put(name, visit(arg.bucketArgValue()));
 }
Suggestion importance[1-10]: 6

__

Why: Valid optimization that avoids unnecessary visit() calls when duplicates are detected. The improved code checks for duplicates before processing, which is more efficient, though the impact is moderate since duplicate parameters are likely rare.

Low

Previous suggestions

Suggestions up to commit 3f6b51c
CategorySuggestion                                                                                                                                    Impact
General
Check duplicates before processing values

The duplicate parameter check occurs after visiting bucketArgValue(), which may
execute expensive operations or have side effects. Check for duplicate keys before
visiting the value to avoid unnecessary processing when a duplicate is detected.

sql/src/main/java/org/opensearch/sql/sql/parser/AstExpressionBuilder.java [193-200]

 Map<String, UnresolvedExpression> args = new LinkedHashMap<>();
 for (BucketArgContext arg : ctx.bucketFunction().bucketArg()) {
   String name = StringUtils.unquoteText(arg.bucketArgName().getText()).toLowerCase(Locale.ROOT);
-  if (args.put(name, visit(arg.bucketArgValue())) != null) {
+  if (args.containsKey(name)) {
     throw new SemanticCheckException(
         String.format("Parameter '%s' can only be specified once.", name));
   }
+  args.put(name, visit(arg.bucketArgValue()));
 }
Suggestion importance[1-10]: 6

__

Why: Valid optimization to check for duplicate parameter names before visiting bucketArgValue(), which could avoid unnecessary processing. However, the impact is moderate since duplicate parameters are likely rare in practice, and the visit() operation may not always be expensive.

Low
Suggestions up to commit 3a2b1e6
CategorySuggestion                                                                                                                                    Impact
Possible issue
Validate literal type is string or numeric

The method returns a Literal but doesn't validate that the literal is actually a
string or numeric type. A boolean or other non-string/non-numeric literal would pass
through unchecked, potentially causing issues downstream when the interval is
processed.

sql/src/main/java/org/opensearch/sql/sql/parser/AstExpressionBuilder.java [244-253]

 private static Literal stringOrNumericArg(Map<String, UnresolvedExpression> args, String name) {
   UnresolvedExpression value = args.remove(name);
   if (value == null) {
     return null;
   }
   if (!(value instanceof Literal literal)) {
     throw new SemanticCheckException(name + " must be a literal; got " + value);
   }
+  DataType type = literal.getType();
+  if (type != DataType.STRING && !type.isNumeric()) {
+    throw new SemanticCheckException(name + " must be a string or numeric literal; got " + type);
+  }
   return literal;
 }
Suggestion importance[1-10]: 7

__

Why: The suggestion correctly identifies that stringOrNumericArg doesn't validate the literal's data type. Adding type validation would prevent invalid literal types (like boolean) from being accepted as interval values, improving robustness. However, this is a defensive check rather than fixing a critical bug, as downstream processing may already handle invalid types.

Medium
Suggestions up to commit bb83e90
CategorySuggestion                                                                                                                                    Impact
General
Validate before modifying arguments map

The method modifies the args map by removing keys in stringOrNumericArg, but if
multiple interval parameters are present, all are removed before the error is
thrown. This could mask which specific parameters were duplicated. Consider checking
for multiple intervals before removing any from the map.

sql/src/main/java/org/opensearch/sql/sql/parser/AstExpressionBuilder.java [222-237]

 private static Literal intervalOf(Map<String, UnresolvedExpression> args, String functionName) {
-  List<Literal> supplied =
-      Stream.of("interval", "fixed_interval", "calendar_interval")
-          .map(key -> stringOrNumericArg(args, key))
-          .filter(Objects::nonNull)
-          .collect(Collectors.toList());
-  if (supplied.isEmpty()) {
+  List<String> intervalKeys = Stream.of("interval", "fixed_interval", "calendar_interval")
+      .filter(args::containsKey)
+      .collect(Collectors.toList());
+  if (intervalKeys.isEmpty()) {
     throw new SemanticCheckException(
         functionName + " requires one of: interval, fixed_interval, calendar_interval");
   }
-  if (supplied.size() > 1) {
+  if (intervalKeys.size() > 1) {
     throw new SemanticCheckException(
         functionName + " accepts only one of: interval, fixed_interval, calendar_interval");
   }
-  return supplied.get(0);
+  return stringOrNumericArg(args, intervalKeys.get(0));
 }
Suggestion importance[1-10]: 7

__

Why: The suggestion improves code clarity by checking for multiple intervals before removing any from the map. This makes the error detection more explicit and avoids potential confusion about which parameters were present. The improved approach is cleaner and more maintainable, though the original code is functionally correct.

Medium
Check for duplicates before processing

The duplicate parameter check occurs after visiting the argument value, which could
trigger unnecessary computation or side effects before detecting the duplicate. Move
the duplicate check before visiting the value to fail fast and avoid processing
invalid arguments.

sql/src/main/java/org/opensearch/sql/sql/parser/AstExpressionBuilder.java [184-190]

 Map<String, UnresolvedExpression> args = new LinkedHashMap<>();
 for (BucketArgContext arg : ctx.bucketFunction().bucketArg()) {
   String name = StringUtils.unquoteText(arg.bucketArgName().getText()).toLowerCase(Locale.ROOT);
-  if (args.put(name, visit(arg.bucketArgValue())) != null) {
+  if (args.containsKey(name)) {
     throw new SemanticCheckException("Duplicate parameter: " + name);
   }
+  args.put(name, visit(arg.bucketArgValue()));
 }
Suggestion importance[1-10]: 6

__

Why: The suggestion correctly identifies that checking for duplicates before visiting the argument value is more efficient and avoids unnecessary computation. However, the impact is relatively minor since visit() is unlikely to have significant side effects in this context, and the original code is functionally correct.

Low
Suggestions up to commit cb0023a
CategorySuggestion                                                                                                                                    Impact
General
Check duplicates before visiting arguments

The duplicate parameter check occurs after visiting the argument value, which means
the visitor traverses the AST even for duplicate parameters. Move the duplicate
check before visiting bucketArgValue() to avoid unnecessary AST traversal and
potential side effects from visiting duplicate arguments.

sql/src/main/java/org/opensearch/sql/sql/parser/AstExpressionBuilder.java [185-190]

 for (BucketArgContext arg : ctx.bucketFunction().bucketArg()) {
   String name = StringUtils.unquoteText(arg.bucketArgName().getText()).toLowerCase(Locale.ROOT);
-  if (args.put(name, visit(arg.bucketArgValue())) != null) {
+  if (args.containsKey(name)) {
     throw new SemanticCheckException("Duplicate parameter: " + name);
   }
+  args.put(name, visit(arg.bucketArgValue()));
 }
Suggestion importance[1-10]: 7

__

Why: The suggestion correctly identifies that checking for duplicates before visiting bucketArgValue() prevents unnecessary AST traversal and potential side effects. This is a good optimization that improves efficiency and code clarity.

Medium
Validate literal type is string/numeric

The method validates that the argument is a Literal but doesn't verify it's actually
a string or numeric type. A boolean or other literal type could pass through. Add
validation to ensure the literal is of an expected type (STRING, INTEGER, LONG,
DOUBLE, etc.) to prevent invalid interval values.

sql/src/main/java/org/opensearch/sql/sql/parser/AstExpressionBuilder.java [239-248]

 private static Literal stringOrNumericArg(Map<String, UnresolvedExpression> args, String name) {
   UnresolvedExpression value = args.remove(name);
   if (value == null) {
     return null;
   }
   if (!(value instanceof Literal literal)) {
     throw new SemanticCheckException(name + " must be a literal; got " + value);
   }
+  DataType type = literal.getType();
+  if (type != DataType.STRING && type != DataType.INTEGER && type != DataType.LONG && type != DataType.DOUBLE) {
+    throw new SemanticCheckException(name + " must be a string or numeric literal");
+  }
   return literal;
 }
Suggestion importance[1-10]: 6

__

Why: The suggestion adds type validation to ensure only string or numeric literals are accepted for interval parameters. While this improves robustness, the impact is moderate since invalid types would likely fail downstream during span creation or execution.

Low
Suggestions up to commit 981a438
CategorySuggestion                                                                                                                                    Impact
General
Remove misleading interval examples

The error message references interval examples ('1d', '15m') but this method is used
for non-interval parameters like format and time_zone. The examples should be
removed or made generic to avoid confusion when the error is raised for parameters
that don't accept interval values.

sql/src/main/java/org/opensearch/sql/sql/parser/AstExpressionBuilder.java [245-255]

 private static Literal stringArg(Map<String, UnresolvedExpression> args, String name) {
   UnresolvedExpression value = args.remove(name);
   if (value == null) {
     return null;
   }
   if (!(value instanceof Literal literal) || literal.getType() != DataType.STRING) {
     throw new SemanticCheckException(
-        name + " must be a string literal (e.g. '1d', '15m'); got " + value);
+        name + " must be a string literal; got " + value);
   }
   return literal;
 }
Suggestion importance[1-10]: 6

__

Why: The error message includes interval examples ('1d', '15m') that are misleading when stringArg is used for non-interval parameters like format and time_zone. Removing these examples improves clarity and prevents user confusion.

Low

@RyanL1997 RyanL1997 added feature SQL enhancement New feature or request labels Aug 17, 2026
@RyanL1997
RyanL1997 force-pushed the sql-explore/sql-histogram branch from 80f8b16 to 7151095 Compare August 17, 2026 18:24
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 7151095

Adds parse-time support for `histogram` and `date_histogram` in V2 SQL with
named-argument invocation. Each call is lowered during AST construction to
primitives that already exist -- `Span`, `COALESCE`, `DATE_FORMAT`,
`TIMESTAMPADD` -- so no new engine function or execution operator is
introduced, and the lowering happens before the V2 and analytics-engine paths
diverge.

Supported parameters:

  histogram        field, interval, offset, missing
  date_histogram   field, interval / fixed_interval / calendar_interval,
                   format, time_zone, missing

`min_doc_count`, `order` and `alias` are rejected: they would have to mutate
the surrounding query (HAVING / ORDER BY / the SELECT-list alias), which needs
parser plumbing that reaches outside the function call. `date_histogram`'s
`offset` is rejected pending a duration-string parser distinct from
`time_zone`'s ZoneOffset format.

These functions are new to the V2 grammar but not to the plugin, and that is
where the care is needed. The legacy engine has accepted
`date_histogram(field=<col>, 'interval'=<n>)` in GROUP BY since before V2
existed, and requests reach it only when V2 raises SyntaxCheckException -- the
only type RestSQLQueryAction falls back on. Teaching V2 to match those calls
means it answers them first, so declining an unrecognized call shape with
SemanticCheckException would stop the query at V2 and silently drop a working
feature. Measured on a live cluster, `SELECT COUNT(*) FROM idx GROUP BY
date_histogram(field='ts','interval'='1h')` returned four buckets before the
grammar change and HTTP 400 after it.

Both expanders therefore decline an unrecognized shape with
SyntaxCheckException. Every other rejection is unchanged on purpose: once a
call is in the property-bag form these expanders own, a bad parameter is the
caller's mistake, and handing it to an engine that never understood the query
would answer a clear error with a confusing one.

The expander unit tests assert the shape of the AST that gets built, which says
nothing about whether the lowered Span survives analysis, planning and
pushdown. DateHistogramBucketFunctionIT asserts bucket keys and counts against
date_histogram_test, 72 documents on fixed timestamps chosen so an hourly
grouping must yield 12/24/17/19 and a half-hourly one 5/7/11/13/17/19. It
covers hourly, half-hourly and daily intervals, the fixed_interval and
calendar_interval synonyms, a second grouping key, a WHERE clause, numeric
histogram buckets, and both positional forms still reaching the legacy engine.

One test records a limitation rather than a guarantee. Selecting the bucket
alongside a second grouping key directly off the table leaves the span's field
typed UNDEFINED by the time the aggregate runs and the request fails; wrapping
the scan in its own derived table resolves it, and a single grouping key is
unaffected either way. Clients already emit the wrapped form, so this is pinned
where it can be seen rather than left as folklore in a comment.

Co-authored-by: Varun <stvarun11@gmail.com>
Signed-off-by: Jialiang Liang <ryanleeang@gmail.com>
@RyanL1997
RyanL1997 force-pushed the sql-explore/sql-histogram branch from 7151095 to 6573786 Compare August 17, 2026 18:42
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 6573786

CsvFormatResponseIT.dateHistogramTest has been asserting this query for years:

  SELECT COUNT(*) FROM <idx>
  GROUP BY date_histogram('field'='insert_time','fixed_interval'='4d','alias'='days')

It broke once these names entered the V2 grammar. The keys are quoted, so V2
reads it as named arguments and takes over, then rejects `alias` -- a parameter
the legacy engine implements and this expander does not.

The earlier fix assumed the quoted-key form belongs to V2, so a bad parameter
there is the caller's error. That is wrong: legacy uses the same spelling and
accepts parameters V2 has no lowering for, so "unsupported here" cannot be
treated as "invalid". Every rejection in the bucket package now raises
SyntaxCheckException, which means anything this expander cannot lower reaches
the legacy engine exactly as it did before the grammar change -- answered if
legacy understands it, and refused with legacy's own message if not. The cost
is that a genuine typo in the V2 form gets legacy's error rather than ours;
that is worth far less than a query that used to work.

Adds coverage for the `alias` case at both levels, since the positional form
alone did not catch it.

Signed-off-by: Jialiang Liang <ryanleeang@gmail.com>
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit cc420ab

…cs engine

Verified against a local analytics-engine sandbox (9 plugins, every index
parquet-backed so all data queries route to DataFusion). Three problems showed
up, none of them visible on the default route.

The dataset could not load at all. Parquet-backed indices are append-only and
reject a custom document id, so all 72 bulk items failed and every assertion
saw an empty index. The ids were never read by any test; dropping them lets the
same dataset load on both routes.

Three tests asserted results that only the legacy engine can produce. The old
`date_histogram(field=<col>, ...)` spelling, and the `alias` parameter, are
understood only by the legacy V1 engine, and that engine is reachable only
through RestSQLQueryAction -- the analytics route enters through
RestUnifiedQueryAction, which has no fallback to it. Those queries have never
worked on the analytics route, before or after this change, so tests asserting
their results can only ever pass on one of the two. Removed. The behaviour they
guarded is still covered where it belongs: CsvFormatResponseIT.dateHistogramTest
has asserted the `alias` shape for years and is what caught the regression in
CI, and the expander unit tests assert the exception type directly, without
needing an engine at all.

One test asserted a failure -- that a second grouping key over a bare table scan
leaves the span's field typed UNDEFINED. That is a V2 execution defect, not a
property of these functions, and the analytics route resolves the same query
correctly. Pinning it made the suite demand an engine bug stay unfixed and fail
wherever it was already fixed. Removed; the constraint is noted on the test that
uses the derived-table form.

Seven tests remain, all asserting what a query returns rather than which engine
answered it. They pass identically on both routes.

Signed-off-by: Jialiang Liang <ryanleeang@gmail.com>
…ping them

Three of these tests assert results only the legacy V1 engine can produce: the
positional `date_histogram(field=<col>, ...)` spelling and the `alias`
parameter. That engine is reachable only through RestSQLQueryAction's
SyntaxCheckException fallback, and the analytics-engine route enters through
RestUnifiedQueryAction, which has no such fallback -- so those queries have
never worked there.

They were removed in the previous commit to keep the suite green on both
routes. Restoring them behind @RequiresCapability keeps the guard where it
matters and still leaves both routes green, which is what the existing
capability mechanism is for: the default route runs all ten, the analytics
route skips these three with the reason printed. The guard is worth keeping --
these are the shapes a V2 grammar addition can silently take away from the
legacy engine, which is exactly the regression CI caught here.

LEGACY_ENGINE_FALLBACK is worded after LEGACY_METHOD_QUERY, which covers the
same situation for method-query syntax.

Signed-off-by: Jialiang Liang <ryanleeang@gmail.com>
@RyanL1997

RyanL1997 commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator Author

Test report — with and without the analytics engine

Verified locally on both routes at 510eaaf4.

This PR's tests

default route analytics engine
DateHistogramBucketFunctionIT 10 run, 10 passed 7 passed, 3 skipped, 0 failed
CsvFormatResponseIT (pre-existing) 25 passed
:sql:test 156 passed, coverage 1.0000 line / 1.0000 branch

The seven that run on both routes return identical values: hourly 12/24/17/19, half-hourly 5/7/11/13/17/19, daily 72, numeric 19/20/20/13.

The three skipped ones assert results only the legacy V1 engine produces — the positional date_histogram(field=<col>, …) spelling, and alias. That engine is reached through RestSQLQueryAction's SyntaxCheckException fallback; the analytics route enters through RestUnifiedQueryAction, which has none, so these have never worked there. Gated with @RequiresCapability(LEGACY_ENGINE_FALLBACK) rather than deleted — they guard the shapes a V2 grammar addition can quietly take away from the legacy engine, which is the regression CI caught here.

Full suite on the analytics engine

Whole sql.sql.* + sql.legacy.* suite, same machine, same cluster, run twice — once with main, once with this branch.

main this PR
Total 968 978
Passed 456 465
Failed 154 152
Skipped 358 361
Existing tests removed 0
Existing tests newly skipped 0
pass → fail 13
fail → pass 15
Added by this PR 10

The 13 and the 15 are the same kind of test — testPow, testDivide, ipTypeShouldPassJdbcFormatter — none related to these functions, moving in both directions. Re-running three of those classes twice on main, same cluster, nothing changed flipped 5 of 106 on its own. That noise floor covers the 13.

Also fixed

The dataset carried explicit document ids. Parquet-backed indices are append-only and reject them, so all 72 bulk items failed and every assertion saw an empty index. Dropping the ids lets one dataset serve both routes. timewrap_test.json has the same problem.

Harness notes

From dai-chen/sql-1 feature/ae-compat-test-refactor. Four things needed adjusting on macOS:

  • protoc is a prerequisite the README doesn't list — the native build fails without it
  • start-cluster.sh uses GNU sed -i
  • the plugin version is pinned to 3.8.0.0-SNAPSHOT
  • the run.gradle anchor it patches no longer exists on current main

@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 510eaaf

Comment thread sql/src/main/java/org/opensearch/sql/sql/parser/AstExpressionBuilder.java Outdated
public void positionalCallReturnsHourlyBuckets() throws IOException {
JSONObject response =
executeQuery(
"SELECT COUNT(*) FROM " + IDX + " GROUP BY date_histogram(field='ts','interval'='1h')");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you elaborate why this is not supported with current changes? I thought this should be handled by V2 only with grammar changes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thats correct and verified with fix. The previous assumption I made was wrong.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the grammar change V2 handles these directly, so two of the three gates are gone. The only one left is alias: no lowering here, and no legacy engine to fall back to on the analytics route.

One thing that isn't grammar-related — the bucket still has to be projected in a derived table before it can be grouped on. GROUP BY date_histogram(...) straight over a base table can't resolve the span's field.

Comment thread sql/src/main/java/org/opensearch/sql/sql/parser/bucket/DateHistogramExpander.java Outdated
Comment thread sql/src/main/java/org/opensearch/sql/sql/parser/bucket/DateHistogramExpander.java Outdated
Review feedback: making every rejection a SyntaxCheckException caused an
unexpected fallback. A misspelled `time_zone`, two interval synonyms at once,
a missing required parameter — all of those were being handed to the legacy
engine, which answers with an opaque parser error about a query the user never
wrote, hiding the message that would have told them what was wrong.

AstBuilder.visitTableFunctionRelation already makes this call for table
functions, in a comment that says as much: "Use SemanticCheckException (not
SyntaxCheckException) so the request does not fall back to the legacy SQL
engine, whose opaque parser error would mask this message." Same split here.

SyntaxCheckException is now reserved for the two cases that mean "this call
shape is not mine": arguments that are not the named form at all, and named
arguments carrying a parameter this expander has no lowering for, such as
`alias`, which the legacy engine does implement. Those still have to reach it.
Everything else — missing field, missing or duplicated interval, a
non-string where a string literal is required, an invalid time zone — is the
caller's mistake inside a shape this expander owns, and now says so directly.

The boundary test asserts both halves so neither can be collapsed into the
other without failing.

Signed-off-by: Jialiang Liang <ryanleeang@gmail.com>
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit b2cdea1

Review feedback: the bucket package was a second function and argument
resolution path alongside the one already here. It is gone. `histogram` and
`date_histogram` now get a `visitBucketFunctionCall` method next to
`visitHighlightFunctionCall` and `visitPercentileApproxFunctionCall`, and the
grammar carries the argument shape the way `highlightFunction` does:

  bucketFunction : bucketFunctionName LR_BRACKET bucketArg (COMMA bucketArg)* RR_BRACKET
  bucketArg      : bucketArgName EQUAL_SYMBOL bucketArgValue

That deletes NamedArguments outright. It existed to work out which half of a
`Function("=", ...)` was the key, which was only necessary because the call
went through the generic functionArgs rule; with a rule of its own the parser
answers that, and the visitor reads names and values directly. The registry and
the expander interface went with it -- one mapped two names, the other had two
implementations.

The exception split now falls out of the grammar rather than being asserted in
code. The positional spelling the legacy engine has always answered no longer
matches `bucketArg`, so it stays an unrecognized scalar function and
RestSQLQueryAction hands it back, without this code deciding anything. Only
parameters that parse but have no lowering here -- alias, min_doc_count, order,
which legacy implements -- still need an explicit SyntaxCheckException.

Tests moved into AstExpressionBuilderTest alongside the other function-building
tests. Net 1228 lines removed. `:sql:build` green including the coverage gate,
DateHistogramBucketFunctionIT 10/10, CsvFormatResponseIT 25/25, and the bucket
values are unchanged on a live cluster: hourly 12/24/17/19, half-hourly
5/7/11/13/17/19, numeric 19/20/20/13.

Signed-off-by: Jialiang Liang <ryanleeang@gmail.com>
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit b954e10

@RyanL1997

Copy link
Copy Markdown
Collaborator Author

Hi @dai-chen, I just update this PR with the refactor commit I missed to push. Please re-take a look.

Testing the refactor against a live cluster turned up a parameter that has
never worked. `missing` lowered to `coalesce`, which V2 lists in
BuiltinFunctionName but does not implement, so any query using it failed at
execution with "unsupported function name: coalesce". The unit tests asserted
that the AST contained a coalesce node, which is true and says nothing about
whether the query runs. `ifnull` is the two-argument form V2 evaluates.

The integration test that pins this uses the numeric field on purpose.
Substituting into a date needs a timestamp-typed replacement, and the grammar
admits only literals in this position, so a date `missing` reaches IFNULL as
TIMESTAMP against STRING — which V2 accepts and the analytics engine rejects.
Asserting either outcome would contradict the other route.

Verified after the refactor: 983 default-route tests with no failures, the
analytics route 8 passed and 3 skipped with none failing, `:sql:build` green
including the coverage gate, and the bucket values unchanged on both routes.
Also checked argument order, function-name and key casing, extra whitespace,
double-quoted keys, backticked and string field names, and numeric intervals.

Signed-off-by: Jialiang Liang <ryanleeang@gmail.com>
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 981a438

Review feedback: a histogram expression should end up as an OpenSearch
histogram aggregate, so generating date_format or timestampadd was surprising.
It is, and they are gone -- `format` and `time_zone` now defer to the legacy
engine along with alias, min_doc_count and order.

The legacy engine implements all five natively (AggMaker builds them straight
onto the date_histogram aggregation), and for time_zone it does so better:
`dateHistogram.timeZone(ZoneOffset.of(value))` shifts bucket boundaries
properly, where this code was adding a fixed number of seconds and would have
been wrong across a daylight-saving change. Handing those queries back means
they are answered by the implementation that already had them right.

What is left always produces a Span, which is what the earlier comment about
lowering to existing AST primitives described. `missing` still wraps the field
in `ifnull`, since substituting a value has to happen before bucketing.

Verified on a live cluster: the plain and `missing` forms answer from V2
(12/24/17/19 and 19/20/20/13), while `format`, `time_zone` and `alias` reach
legacy and answer correctly -- time_zone returning 5/18/30/19, the shifted
boundaries. 983 default-route tests pass, and `:sql:build` is green including
the coverage gate.

Signed-off-by: Jialiang Liang <ryanleeang@gmail.com>
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit cb0023a

Review feedback: with the grammar change these should be handled by V2 rather
than deferred. They are now. `bucketArgName` admits a bare identifier as well
as a quoted string, so `date_histogram(field=ts, interval='1h')` -- the
spelling the legacy engine has always taken -- lowers to a Span like any other
call. INTERVAL, MISSING, ORDER and TIME_ZONE are listed explicitly because they
are reserved words that `ident` excludes.

I had assumed V2 could not group directly on an expression and that these
queries could only ever come from legacy. That was wrong: the limitation is
specific to two grouping keys over a bare table scan, and a single key is
fine. Confirmed by the explain plan (ProjectOperator over OpenSearchIndexScan)
and by the return type, which is long from V2 where legacy gives double.

Two of the three capability-gated tests are gone as a result -- both routes now
answer those queries and agree on the values. Only the `alias` case still
defers, since that parameter has no lowering here and the analytics route has
no legacy engine to hand it to.

Verified: 983 default-route tests with no failures; the analytics route 10
passed, 1 skipped, none failed; `:sql:build` green including the coverage gate.
Against a main baseline on the same cluster the analytics suite moved 15
pass->fail and 14 fail->pass, all in unrelated classes -- the same noise floor
measured earlier, where re-running three classes on main alone flipped 5 of 106.

Signed-off-by: Jialiang Liang <ryanleeang@gmail.com>
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit bb83e90

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>
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 3a2b1e6

Comment on lines +116 to +117
private static final Set<String> LEGACY_ONLY_BUCKET_ARGS =
Set.of("alias", "format", "time_zone", "min_doc_count", "order");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because we've defined this in grammar, the fallback should happen automatically?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Other way round, I think — putting them in the grammar is what stops the fallback happening on its own.

Before the rule, date_histogram(...) was unknown to V2, so it threw SyntaxCheckException and RestSQLQueryAction handed it to legacy. Now V2 matches the call and builds an AST, so nothing throws and it never gets there — CsvFormatResponseIT.dateHistogramTest broke exactly then, and passes again only because alias is declined explicitly.

It needs to be a closed set rather than anything-left-over, since bare argument names mean misspellings parse too — and on the analytics route there's no legacy engine behind it to absorb them.

Comment on lines +193 to +195
if (args.put(name, visit(arg.bucketArgValue())) != null) {
throw new SemanticCheckException("Duplicate parameter: " + name);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validation like this and below looks very complex. Could you confirm if it's fine to delegate it to final DSL execution? Because I don't find similar validation in other OS function.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refactored — the helpers are gone, the parameter names are declared as data, and the messages now match what RelevanceQuery uses.

Delegating to execution isn't possible here though: relevance functions survive as a FunctionExpression down to RelevanceQuery.build(), where their parameter table lives. A bucket call is lowered to a Span while the AST is built, so there's no function left downstream to check. Lowering there is also what lets one change serve both engines — the Calcite path never goes through ExpressionAnalyzer, so the AST is the only point they share. This is the span half of your suggestion; PPL's visitSpanClause does the same thing.

Review feedback: the validation read as more machinery than the other
OpenSearch functions carry. The three helpers are gone -- the checks are
inline, the two sets of parameter names are declared as data, and the messages
now match the wording RelevanceQuery already uses ("Parameter %s is invalid for
%s function.", "Parameter '%s' can only be specified once."). 69 lines to 52.

On delegating the checks to execution instead: that works for the relevance
functions because they survive as a FunctionExpression all the way to
RelevanceQuery.build(), which is where their parameter table lives. A bucket
call is lowered to a Span while the AST is being built, so nothing downstream
still sees a function to check. What is left cannot be deferred either --
AstDSL.spanFromSpanLengthLiteral dereferences the interval on its first line,
so a missing one is an NPE rather than a message.

Parse-time lowering is also what keeps this one change serving both engines.
Span is consumed independently by ExpressionAnalyzer, CompositeAggregationBuilder
and Rounding on the V2 side, and by CalciteRexNodeVisitor and
CalciteRelNodeVisitor on the analytics side -- and the Calcite path never goes
through ExpressionAnalyzer, so the AST is the only point the two share. Keeping
the call as a function would mean teaching each of those about it separately,
and CompositeAggregationBuilder dispatches on `instanceof SpanExpression`, so a
function would fall through to a terms aggregation instead of a histogram.

This follows the span half of the earlier suggestion: PPL builds its span the
same way, in visitSpanClause, through the same AstDSL call.

Verified: 13 integration tests, none failing or skipped; `:sql:build` green
including the coverage gate.

Signed-off-by: Jialiang Liang <ryanleeang@gmail.com>
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 3f6b51c

`bucketArgName` listed MISSING among the reserved words it accepts, but the
lexer never emits that token: MISSING_LITERAL matches the same text and is
declared first, so the alternative could not be reached. Confirmed against a
running cluster -- `missing=0` written bare is declined by the V2 parser and
handed to the legacy engine, while `'missing'=0` in quotes works and stays on
the V2 path, which is the spelling the integration test already uses.

The other three reserved words are reachable and stay: `interval=` answers
directly, and `order=`/`time_zone=` reach the builder and are declined there by
name, as intended.

Verified: 13 integration tests, none failing or skipped; `:sql:build` green
including the coverage gate.

Signed-off-by: Jialiang Liang <ryanleeang@gmail.com>
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 7950169

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request feature SQL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants