Skip to content

Add SQL histogram and date_histogram bucket functions - #5700

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

Add SQL histogram and date_histogram bucket functions#5700
RyanL1997 wants to merge 4 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.

Not yet verified on the analytics-engine route; draft until it is.

@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

(Review updated until commit cc420ab)

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 test index for date_histogram

Relevant files:

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

Sub-PR theme: Implement bucket function expansion logic

Relevant files:

  • sql/src/main/java/org/opensearch/sql/sql/parser/bucket/BucketFunctionExpander.java
  • sql/src/main/java/org/opensearch/sql/sql/parser/bucket/BucketFunctionRegistry.java
  • sql/src/main/java/org/opensearch/sql/sql/parser/bucket/BucketFunctionUtils.java
  • sql/src/main/java/org/opensearch/sql/sql/parser/bucket/DateHistogramExpander.java
  • sql/src/main/java/org/opensearch/sql/sql/parser/bucket/HistogramExpander.java
  • sql/src/main/java/org/opensearch/sql/sql/parser/bucket/NamedArguments.java
  • sql/src/test/java/org/opensearch/sql/sql/parser/bucket/BucketFunctionRegistryTest.java
  • sql/src/test/java/org/opensearch/sql/sql/parser/bucket/BucketFunctionUtilsTest.java
  • sql/src/test/java/org/opensearch/sql/sql/parser/bucket/DateHistogramExpanderTest.java
  • sql/src/test/java/org/opensearch/sql/sql/parser/bucket/HistogramExpanderTest.java
  • sql/src/test/java/org/opensearch/sql/sql/parser/bucket/NamedArgumentsTest.java

Sub-PR theme: Wire bucket functions into SQL parser

Relevant files:

  • sql/src/main/java/org/opensearch/sql/sql/parser/AstExpressionBuilder.java
  • language-grammar/src/main/antlr4/OpenSearchSQLParser.g4
  • sql/src/main/antlr/OpenSearchSQLParser.g4
  • integ-test/src/test/java/org/opensearch/sql/sql/DateHistogramBucketFunctionIT.java

⚡ Recommended focus areas for review

Removed Function

The buildFunction method call was removed but the method itself is not shown as deleted in the diff. If buildFunction is still used elsewhere in the class, this change breaks those call sites. If it is now unused, it should be removed to avoid dead code.

String functionName = ctx.scalarFunctionName().getText();
List<UnresolvedExpression> args =
    ctx.functionArgs().functionArg().stream()
        .map(this::visitFunctionArg)
        .collect(Collectors.toList());

Optional<BucketFunctionExpander> bucketExpander = BucketFunctionRegistry.lookup(functionName);
if (bucketExpander.isPresent()) {
  return bucketExpander.get().expand(args);
}

return new Function(functionName, args);
Unchecked Cast

Line 108 casts the result of AstDSL.spanFromSpanLengthLiteral to Span without verification. If that method's contract changes or returns a different type under certain interval strings, this cast will throw ClassCastException at runtime.

Span span = AstDSL.spanFromSpanLengthLiteral(shiftedField, intervalLiteral);

@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Latest suggestions up to 6573786
Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
General
Catch specific exception type

Catching all RuntimeException types is too broad and may mask unexpected errors. The
ZoneOffset.of() method specifically throws DateTimeException for invalid zone
offsets. Catch only DateTimeException to ensure other runtime exceptions are not
silently converted to semantic errors.

sql/src/main/java/org/opensearch/sql/sql/parser/bucket/DateHistogramExpander.java [121-134]

 private static UnresolvedExpression applyTimeZoneShift(
     UnresolvedExpression field, Literal timeZoneLiteral) {
   String tzString = timeZoneLiteral.getValue().toString();
   int offsetSeconds;
   try {
     offsetSeconds = ZoneOffset.of(tzString).getTotalSeconds();
-  } catch (RuntimeException ex) {
+  } catch (DateTimeException ex) {
     throw new SemanticCheckException(
         "time_zone must be a valid offset like '+05:30' or 'Z'; got '" + tzString + "'");
   }
   return new Function(
       "timestampadd",
       List.of(
           AstDSL.stringLiteral("SECOND"),
           AstDSL.intLiteral(offsetSeconds),
           field));
 }
Suggestion importance[1-10]: 7

__

Why: The suggestion correctly identifies that catching RuntimeException is too broad. ZoneOffset.of() throws DateTimeException for invalid inputs, so catching the specific exception type improves error handling precision and prevents masking unexpected errors. However, this is a minor improvement in exception handling rather than a critical bug fix.

Medium

Previous suggestions

Suggestions up to commit 80f8b16
CategorySuggestion                                                                                                                                    Impact
General
Catch specific DateTimeException instead of RuntimeException

Catching RuntimeException is too broad and may hide unexpected errors. The
ZoneOffset.of() method specifically throws DateTimeException when the zone offset is
invalid. Catch the specific exception type to avoid masking other runtime issues.

sql/src/main/java/org/opensearch/sql/sql/parser/bucket/DateHistogramExpander.java [124-137]

 private static UnresolvedExpression applyTimeZoneShift(
     UnresolvedExpression field, Literal timeZoneLiteral) {
   String tzString = timeZoneLiteral.getValue().toString();
   int offsetSeconds;
   try {
     offsetSeconds = ZoneOffset.of(tzString).getTotalSeconds();
-  } catch (RuntimeException ex) {
+  } catch (DateTimeException ex) {
     throw new SemanticCheckException(
         "time_zone must be a valid offset like '+05:30' or 'Z'; got '" + tzString + "'");
   }
   return new Function(
       "timestampadd",
       List.of(AstDSL.stringLiteral("SECOND"), AstDSL.intLiteral(offsetSeconds), field));
 }
Suggestion importance[1-10]: 7

__

Why: Catching DateTimeException instead of RuntimeException is more specific and follows best practices for exception handling. However, this is a minor improvement that doesn't address a critical bug, as the current code still functions correctly.

Medium
Add validation before casting to prevent ClassCastException

The code performs unchecked casts after isKeyValuePair() validation. If
isKeyValuePair() has a bug or the validation logic changes, these casts could fail
with ClassCastException. Add defensive null checks or assertions before casting to
prevent potential runtime failures.

sql/src/main/java/org/opensearch/sql/sql/parser/bucket/NamedArguments.java [62-77]

 public static NamedArguments parse(List<UnresolvedExpression> args) {
   Map<String, UnresolvedExpression> arguments = new LinkedHashMap<>();
   for (UnresolvedExpression arg : args) {
     if (!isKeyValuePair(arg)) {
       throw new SemanticCheckException("Named arguments must be of form 'key'=value; got " + arg);
     }
     Function fn = (Function) arg;
-    Literal keyLiteral = (Literal) fn.getFuncArgs().get(0);
+    List<UnresolvedExpression> funcArgs = fn.getFuncArgs();
+    if (funcArgs.size() != 2 || !(funcArgs.get(0) instanceof Literal keyLiteral)) {
+      throw new SemanticCheckException("Invalid key-value pair structure: " + arg);
+    }
     String key = keyLiteral.getValue().toString().toLowerCase(Locale.ROOT);
-    UnresolvedExpression value = fn.getFuncArgs().get(1);
+    UnresolvedExpression value = funcArgs.get(1);
     if (arguments.put(key, value) != null) {
       throw new SemanticCheckException("Duplicate parameter: " + key);
     }
   }
   return new NamedArguments(arguments);
 }
Suggestion importance[1-10]: 3

__

Why: The suggestion adds redundant validation that duplicates the logic already performed by isKeyValuePair(). The casts are safe because isKeyValuePair() already validates the structure. The added checks would make the code more verbose without meaningful safety improvement.

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

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.

1 participant