Add SQL histogram and date_histogram bucket functions - #5700
Conversation
PR Reviewer Guide 🔍(Review updated until commit 7950169)Here are some key observations to aid the review process:
|
PR Code Suggestions ✨Latest suggestions up to 7950169 Explore these optional code suggestions:
Previous suggestionsSuggestions up to commit 3f6b51c
Suggestions up to commit 3a2b1e6
Suggestions up to commit bb83e90
Suggestions up to commit cb0023a
Suggestions up to commit 981a438
|
80f8b16 to
7151095
Compare
|
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>
7151095 to
6573786
Compare
|
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>
|
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>
Test report — with and without the analytics engineVerified locally on both routes at This PR's tests
The seven that run on both routes return identical values: hourly The three skipped ones assert results only the legacy V1 engine produces — the positional Full suite on the analytics engineWhole
The 13 and the 15 are the same kind of test — Also fixedThe 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. Harness notesFrom dai-chen/sql-1
|
|
Persistent review updated to latest commit 510eaaf |
| public void positionalCallReturnsHourlyBuckets() throws IOException { | ||
| JSONObject response = | ||
| executeQuery( | ||
| "SELECT COUNT(*) FROM " + IDX + " GROUP BY date_histogram(field='ts','interval'='1h')"); |
There was a problem hiding this comment.
Could you elaborate why this is not supported with current changes? I thought this should be handled by V2 only with grammar changes.
There was a problem hiding this comment.
thats correct and verified with fix. The previous assumption I made was wrong.
There was a problem hiding this comment.
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.
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>
|
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>
|
Persistent review updated to latest commit b954e10 |
|
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>
|
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>
|
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>
|
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>
|
Persistent review updated to latest commit 3a2b1e6 |
| private static final Set<String> LEGACY_ONLY_BUCKET_ARGS = | ||
| Set.of("alias", "format", "time_zone", "min_doc_count", "order"); |
There was a problem hiding this comment.
Because we've defined this in grammar, the fallback should happen automatically?
There was a problem hiding this comment.
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.
| if (args.put(name, visit(arg.bucketArgValue())) != null) { | ||
| throw new SemanticCheckException("Duplicate parameter: " + name); | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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>
|
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>
|
Persistent review updated to latest commit 7950169 |
Description
Adds
histogramanddate_histogramto 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.{ "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:histogrambuckets a numeric field the same way and returns the bucket's lower bound:Parameters
histogramfield,interval,offset,missingdate_histogramfield,interval/fixed_interval/calendar_interval,format,time_zone,missingThe three interval spellings are synonyms; exactly one must be present.
min_doc_count,orderandaliasare rejected because they would have to mutate the surrounding query (HAVING / ORDER BY / the SELECT-list alias).date_histogram'soffsetis rejected pending a duration-string parser distinct fromtime_zone'sZoneOffsetformat.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>)inGROUP BYfor a long time, and queries reach it only when V2 raisesSyntaxCheckException, the one exceptionRestSQLQueryActionfalls back on. Now that V2 matches these calls first, an unrecognized shape has to decline with that exception or the query stops at V2:GROUP BY date_histogram(field='ts','interval'='1h')GROUP BY date_histogram('field'='ts','interval'='1h')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
--signoff.