Skip to content

Add SQL histogram and date_histogram bucket functions - #5700

Merged
RyanL1997 merged 18 commits into
opensearch-project:mainfrom
RyanL1997:sql-explore/sql-histogram
Aug 20, 2026
Merged

Add SQL histogram and date_histogram bucket functions#5700
RyanL1997 merged 18 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 724b1c4)

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 grammar rules for bucket functions

Relevant files:

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

Sub-PR theme: Implement bucket function AST building

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
  • integ-test/src/test/resources/indexDefinitions/date_histogram_test_index_mapping.json

Sub-PR theme: Update documentation for bucket functions

Relevant files:

  • docs/user/dql/aggregations.rst
  • docs/user/dql/functions.rst

⚡ Recommended focus areas for review

Possible Issue

The normalizeField method coerces string literals to column references, but this conversion is applied unconditionally to all string literals. If a user intentionally passes a string literal (e.g., for a computed expression or constant), it will be incorrectly treated as a column name. This could cause queries to fail or produce unexpected results when the string does not correspond to an actual column.

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

@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Latest suggestions up to 724b1c4

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Add null safety check

Add null safety check for literal.getValue() before calling toString(). If the
literal's value is null, this will throw a NullPointerException. Consider handling
the null case explicitly or validating that string literals cannot have null values.

sql/src/main/java/org/opensearch/sql/sql/parser/AstExpressionBuilder.java [182-187]

 private static UnresolvedExpression normalizeField(UnresolvedExpression field) {
   if (field instanceof Literal literal && literal.getType() == DataType.STRING) {
-    return AstDSL.qualifiedName(literal.getValue().toString());
+    Object value = literal.getValue();
+    if (value == null) {
+      throw new IllegalArgumentException("String literal field cannot have null value");
+    }
+    return AstDSL.qualifiedName(value.toString());
   }
   return field;
 }
Suggestion importance[1-10]: 7

__

Why: The suggestion correctly identifies a potential NullPointerException if literal.getValue() returns null. However, this is a defensive programming improvement rather than a critical bug fix, as the likelihood depends on whether the codebase allows null values in string literals. The score reflects its value as a safety enhancement.

Medium

Previous suggestions

Suggestions up to commit 7f6f3a3
CategorySuggestion                                                                                                                                    Impact
Possible issue
Add null check for literal value

The normalizeField method should validate that the literal value is not null before
calling toString(). If literal.getValue() returns null, this will throw a
NullPointerException at runtime.

sql/src/main/java/org/opensearch/sql/sql/parser/AstExpressionBuilder.java [182-187]

 private static UnresolvedExpression normalizeField(UnresolvedExpression field) {
   if (field instanceof Literal literal && literal.getType() == DataType.STRING) {
-    return AstDSL.qualifiedName(literal.getValue().toString());
+    Object value = literal.getValue();
+    if (value == null) {
+      throw new IllegalArgumentException("Field name cannot be null");
+    }
+    return AstDSL.qualifiedName(value.toString());
   }
   return field;
 }
Suggestion importance[1-10]: 7

__

Why: The suggestion correctly identifies a potential NullPointerException if literal.getValue() returns null. Adding a null check improves robustness, though the likelihood of this occurring in practice depends on upstream validation.

Medium
Suggestions up to commit ed7284e
CategorySuggestion                                                                                                                                    Impact
Possible issue
Add null check for literal value

The normalizeField method should handle potential null values from
literal.getValue() before calling toString(). If getValue() returns null, this will
throw a NullPointerException at runtime.

sql/src/main/java/org/opensearch/sql/sql/parser/AstExpressionBuilder.java [182-187]

 private static UnresolvedExpression normalizeField(UnresolvedExpression field) {
   if (field instanceof Literal literal && literal.getType() == DataType.STRING) {
-    return AstDSL.qualifiedName(literal.getValue().toString());
+    Object value = literal.getValue();
+    if (value != null) {
+      return AstDSL.qualifiedName(value.toString());
+    }
   }
   return field;
 }
Suggestion importance[1-10]: 7

__

Why: The suggestion correctly identifies a potential NullPointerException if literal.getValue() returns null. However, this is a defensive programming improvement rather than a critical bug, as the likelihood depends on upstream validation.

Medium
General
Validate exception details in test

The test only verifies that an exception is thrown but doesn't validate the
exception message or type. Consider capturing the exception and asserting on its
specific properties to ensure the correct error is being raised.

integ-test/src/test/java/org/opensearch/sql/sql/DateHistogramBucketFunctionIT.java [179-187]

 @Test
 public void groupingOnTheBucketWithoutADerivedTableIsRejected() {
-  assertThrows(
+  ResponseException exception = assertThrows(
       ResponseException.class,
       () ->
           executeQuery(
               "SELECT date_histogram(field=ts, interval='1h') AS b, COUNT(*) FROM "
                   + IDX
                   + " GROUP BY date_histogram(field=ts, interval='1h')"));
+  // Verify the exception contains expected error details
+  assertNotNull(exception.getMessage());
 }
Suggestion importance[1-10]: 5

__

Why: The suggestion improves test robustness by capturing and validating the exception, but the current test already verifies the expected behavior (exception is thrown). The improvement is marginal and mainly enhances test quality rather than fixing a bug.

Low
Suggestions up to commit 9d798fc
CategorySuggestion                                                                                                                                    Impact
General
Detect unsupported bucket function parameters

After validating required parameters, the code doesn't check if there are any
remaining unrecognized parameters in the args map. This could silently ignore typos
or invalid parameters. Consider checking if args is empty after removing known
parameters and throw an exception if unexpected parameters remain.

sql/src/main/java/org/opensearch/sql/sql/parser/AstExpressionBuilder.java [203-212]

 if (field == null) {
   throw new SemanticCheckException(
       String.format("Parameter field is required for %s function.", functionName));
 }
 if (intervals.size() != 1) {
   throw new SemanticCheckException(
       String.format(
           "Exactly one of %s is required for %s function.",
           String.join(", ", INTERVAL_ARGS), functionName));
 }
+if (!args.isEmpty()) {
+  throw new SyntaxCheckException(
+      String.format("Unsupported parameters for %s function: %s", functionName, String.join(", ", args.keySet())));
+}
Suggestion importance[1-10]: 8

__

Why: This is a valuable suggestion that addresses a potential bug where unrecognized parameters would be silently ignored. The code explicitly handles known unsupported parameters by throwing SyntaxCheckException (as shown in tests), so remaining parameters after extraction should also be validated. This improves correctness and user feedback.

Medium
Validate interval literal data type

The pattern matching with instanceof and cast is used, but the extracted interval
variable is not validated for its data type. If the literal is not a string or
numeric type, downstream code may fail unexpectedly. Add validation to ensure the
interval literal is of an appropriate type (STRING or numeric).

sql/src/main/java/org/opensearch/sql/sql/parser/AstExpressionBuilder.java [213-216]

 if (!(intervals.get(0) instanceof Literal interval)) {
   throw new SemanticCheckException(
       String.format("Parameter interval must be a literal for %s function.", functionName));
 }
+if (interval.getType() != DataType.STRING && !interval.getType().isNumeric()) {
+  throw new SemanticCheckException(
+      String.format("Parameter interval must be a string or numeric literal for %s function.", functionName));
+}
Suggestion importance[1-10]: 5

__

Why: The suggestion adds validation for the interval literal's data type, which could prevent downstream errors. However, the Span constructor and related code may already handle type validation, and the PR's test coverage doesn't indicate this is a critical issue. The improvement is defensive but not essential.

Low
Suggestions up to commit f3aaf4f
CategorySuggestion                                                                                                                                    Impact
General
Check duplicates before processing values

The duplicate parameter check occurs after visiting the argument value, which may
execute unnecessary work. Check for duplicate keys before visiting the value
expression to avoid processing duplicate arguments unnecessarily and improve
performance.

sql/src/main/java/org/opensearch/sql/sql/parser/AstExpressionBuilder.java [203-209]

 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]: 5

__

Why: The suggestion correctly identifies a minor optimization opportunity by checking for duplicate keys before visiting the argument value. This avoids unnecessary processing when duplicates are found, though the performance impact is minimal since visit() is typically fast for simple values.

Low
Suggestions up to commit 448b3ad
CategorySuggestion                                                                                                                                    Impact
General
Improve interval validation logic

Validate the field parameter before processing intervals to avoid potential null
pointer exceptions. If field is null, the subsequent intervals validation becomes
unnecessary and could mislead debugging efforts.

sql/src/main/java/org/opensearch/sql/sql/parser/AstExpressionBuilder.java [231-240]

 if (field == null) {
   throw new SemanticCheckException(
       String.format("Parameter field is required for %s function.", functionName));
 }
-if (intervals.size() != 1) {
+if (intervals.isEmpty()) {
   throw new SemanticCheckException(
       String.format(
           "Exactly one of %s is required for %s function.",
           String.join(", ", INTERVAL_ARGS), functionName));
 }
+if (intervals.size() > 1) {
+  throw new SemanticCheckException(
+      String.format(
+          "Only one of %s can be specified for %s function.",
+          String.join(", ", INTERVAL_ARGS), functionName));
+}
Suggestion importance[1-10]: 3

__

Why: The suggestion to split the intervals.size() != 1 check into separate checks for empty and multiple intervals provides slightly more specific error messages. However, the existing code is already correct and clear, and the improvement is marginal. The original validation logic is sufficient and the suggested change doesn't address any actual bug.

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>
@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.

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.

Following up here since your grammar comment supersedes this: what I described was the shape at the time, where the rule accepted any name and the set had to decide. With bucketArgName narrowed to the four names we lower, the fallback is automatic after all and the set is gone.

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.

@RyanL1997 RyanL1997 Aug 20, 2026

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 match what RelevanceQuery and the other fallback sites use.

On delegating: the parts that can be already are. An interval's contents are never checked here — 'xyz' fails downstream in Rounding, '-1h' in AstDSL. What stays is only the shape — which parameter names appeared, and whether field and an interval are there at all — and a missing interval NPEs inside spanFromSpanLengthLiteral before anything downstream sees it.

That part can't move. 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 no function is left to hold one. Lowering there is also what lets one change serve both engines — the Calcite path never goes through ExpressionAnalyzer. This is the span half of your suggestion; PPL's visitSpanClause does the same.

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.

Added comment on grammar changes. Please check if we can simplify here, especially get rid of the 2 set fields added above.

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.

Both the leftover-argument check and LEGACY_ONLY_ARGS are gone with the grammar change; details in the reply above.

INTERVAL_ARGS is still there, but it isn't a validation table any more — interval, fixed_interval and calendar_interval are synonyms and exactly one has to be picked, so it's the lookup that does the picking. Happy to inline the three names if you'd rather not have the field.

What's left is three checks, and none can move downstream: spanFromSpanLengthLiteral dereferences the interval on its first line, so a missing one is an NPE rather than a message.

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.

Both are gone now — INTERVAL_ARGS too.

bucketFunction spells out the two operands the way spanClause does in the PPL grammar: the field and the interval are positional and required, with named labels to reach them. The visitor is then the same three lines visitSpanClause is, through the same AstDSL call — no argument map, no required-parameter check, no duplicate detection, no literal check. The grammar makes each of those unrepresentable rather than detectable. −46/+6 in this file.

The one constraint it adds is ordering: field comes first, and writing the interval first is a parse error, so it reaches legacy, which accepts either order.

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

`LEGACY_ONLY_ARGS` listed five names, but AggMaker accepts nine on the bucket
aggregations: `children`, `extended_bounds`, `nested` and `reverse_nested` were
missing. Those four reached the leftover-argument branch, failed the
`containsAll` check and were reported as semantic errors -- and
RestSQLQueryAction only falls back on a syntax check, so the query stopped
short of the engine that implements them.

Before the grammar rule existed these calls were a V2 syntax error and legacy
answered them, so this was a regression introduced by defining the function
here. Confirmed against a running cluster: with the four names registered,
`date_histogram('field'='ts','fixed_interval'='1h','extended_bounds'='0:100')`
reaches legacy again, matching `alias`.

The set is now the union of what AggMaker.dateHistogram and AggMaker.histogram
accept, minus the parameters this lowering handles itself.

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 448b3ad

Three things the surrounding code already had a way of doing.

The mapping for the test index was an inline JSON string, which the formatter
had split mid-token. It is a file under `indexDefinitions/` now, loaded with
`getMappingFile` -- 72 of the 76 index entries take their mapping from a file
or a helper rather than a literal.

Four tests were rebuilding the string `bucketed()` already produces; they call
it now.

The message for a parameter that belongs to the legacy engine says so, matching
the five other places that decline this way -- `AstBuilder` for JOIN, UNION and
a nested function in HAVING, and this file for IN and EXISTS subqueries. The
message on the semantic branch is unchanged, since it is a real error rather
than a handoff.

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 f3aaf4f

@dai-chen dai-chen left a comment

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.

Please check if our doc/doctest already covers this or not.

Comment on lines +774 to +775
: stringLiteral
| ident

@dai-chen dai-chen Aug 20, 2026

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.

I don't see these in argument rule for other OS function in grammar. Are these only for backward compatibility, e.g., histogram('field'=...)? Without them, the fallback should happen automatically and no need to do validation in AST builder? If so, I think we can remove them because anyway we only partial support histogram based on span and do fallback in this PR.

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.

Yes — that was the only reason, and you're right that they aren't needed. Removed.

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.

Added comment on grammar changes. Please check if we can simplify here, especially get rid of the 2 set fields added above.

Review feedback: the argument-name rule was open where the other OpenSearch
functions enumerate their names, and the accepted set had to be mirrored in
Java as a result. `bucketArgName` lists the four names this lowering handles --
`field`, `interval`, `fixed_interval`, `calendar_interval` -- so anything else
is a parse error, which is the one exception RestSQLQueryAction falls back on.
The handoff is the grammar's now, not a table's.

`LEGACY_ONLY_ARGS` is gone with it, and so is the leftover-argument branch: once
the four names are taken out of the map it is always empty. What remains are the
three checks that cannot move downstream, because `spanFromSpanLengthLiteral`
dereferences the interval on its first line.

This also removes the failure mode behind the previous commit. That set had to
list every parameter the legacy engine implements, and four were missing; with
the grammar deciding, a name nobody listed falls back on its own.

Two consequences worth stating. The quoted spelling now reaches the legacy
engine rather than being lowered here -- which is where it went before this
function was defined at all, so nothing that used to work stops working. And
`missing` is dropped: `AggMaker` does not implement it either, so there is
nothing to defer to, and the `MISSING` token was unreachable behind
`MISSING_LITERAL` (ANTLR warns about this directly).

`FIXED_INTERVAL` and `CALENDAR_INTERVAL` are new tokens, added to
`keywordsCanBeId` so they can still name a column.

Verified: 82 parser unit tests, none failing; `:sql:build` green including the
coverage gate. Integration tests could not run locally -- the 3.9.0 distro no
longer bundles Jackson 2.x, so the plugin fails to install with jar hell on
`main` as well, pending opensearch-project#5703.

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

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 9d798fc

Follow-up on the two set fields. `bucketFunction` now spells out both operands
the way `spanClause` does in the PPL grammar -- the field and the interval are
positional and required, with named labels to reach them -- so the visitor is
the same three lines `visitSpanClause` is, through the same AstDSL call.

Both sets are gone, and so is everything they supported: no argument map, no
required-parameter checks, no duplicate detection, no literal check. The
grammar makes each of those unrepresentable rather than detectable.
AstExpressionBuilder loses 46 lines and gains 6.

The one constraint this adds is ordering: `field` comes first. Writing the
interval first is a parse error, so it reaches the legacy engine, which accepts
either order.

Verified on a live cluster, both the default and analytics routes: `interval`,
`fixed_interval`, `calendar_interval`, the numeric `histogram`, and the shape
Dashboards emits all return the same buckets on each; a reversed argument order
falls back; `alias` still reaches legacy on the default route. 82 parser unit
tests, none failing; `: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 ed7284e

@RyanL1997

RyanL1997 commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator Author

Hi @dai-chen , for:

Added comment on grammar changes. Please check if we can simplify here, especially get rid of the 2 set fields added above.

Both are gone.

bucketFunction now spells out the field and the interval the way spanClause does in the PPL grammar — positional and required, with named labels to reach them. The visitor is the same three lines visitSpanClause is, through the same AstDSL call. No argument map, no required-parameter check, no duplicate detection, no literal check: the grammar makes each of those unrepresentable rather than detectable.

`date_histogram` appeared once in the whole docs tree, in a dev note about
pagination, and `aggregations.rst` described a group-by expression as an
identifier, an ordinal or an expression. A bucket function is a fourth kind, so
it goes in that list, next to the other three.

The examples run under doctest, which already covers this file. They use the
indices it loads rather than adding new ones, and the prose states the two
things that are easy to get wrong from an Elasticsearch habit: the field comes
first, and the interval parameter is one of three names.

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

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 7f6f3a3

They are not in `functions.rst` because they are only valid as a grouping key,
but that is where someone looks for a function by name. The introduction says
where they live, in the form `expressions.rst` already uses to point at that
same file from the other direction.

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

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 724b1c4


Most of the specifications can be self explained just as a regular function with data type as argument. The only notation that needs elaboration is generic type ``T`` which binds to an actual type and can be used as return type. For example, ``ABS(NUMBER T) -> T`` means function ``ABS`` accepts an numerical argument of type ``T`` which could be any sub-type of ``NUMBER`` type and returns the actual type of ``T`` as return type. The actual type binds to generic type at runtime dynamically.

The bucket functions ``date_histogram`` and ``histogram`` are not listed here because they are only valid as a grouping key, please see also: `Aggregations <aggregations.rst>`_

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.

Please check if our doc/doctest already covers this or not.

@dai-chen I added under aggregations.rst, but do we need to mention it here?

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.

I think no need to mention it here. It should be clear since we've called both bucket or windowing function like other database.

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.

will remove this as a follow up.

@dai-chen dai-chen left a comment

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.

Thanks for the changes! Probably we can need to deep dive into the aliasing issue later.

@RyanL1997
RyanL1997 merged commit 7601a23 into opensearch-project:main Aug 20, 2026
40 checks passed
@RyanL1997
RyanL1997 deleted the sql-explore/sql-histogram branch August 20, 2026 23:45
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