Add SQL histogram and date_histogram bucket functions - #5700
Conversation
PR Reviewer Guide 🔍(Review updated until commit 724b1c4)Here are some key observations to aid the review process:
|
PR Code Suggestions ✨Latest suggestions up to 724b1c4 Explore these optional code suggestions:
Previous suggestionsSuggestions up to commit 7f6f3a3
Suggestions up to commit ed7284e
Suggestions up to commit 9d798fc
Suggestions up to commit f3aaf4f
Suggestions up to commit 448b3ad
|
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>
|
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.
There was a problem hiding this comment.
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.
| 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 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.
There was a problem hiding this comment.
Added comment on grammar changes. Please check if we can simplify here, especially get rid of the 2 set fields added above.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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>
|
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 |
`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>
|
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>
|
Persistent review updated to latest commit f3aaf4f |
dai-chen
left a comment
There was a problem hiding this comment.
Please check if our doc/doctest already covers this or not.
| : stringLiteral | ||
| | ident |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Yes — that was the only reason, and you're right that they aren't needed. Removed.
| if (args.put(name, visit(arg.bucketArgValue())) != null) { | ||
| throw new SemanticCheckException("Duplicate parameter: " + name); | ||
| } |
There was a problem hiding this comment.
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>
|
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>
|
Persistent review updated to latest commit ed7284e |
|
Hi @dai-chen , for:
Both are gone.
|
`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>
|
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>
|
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>`_ |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
I think no need to mention it here. It should be clear since we've called both bucket or windowing function like other database.
There was a problem hiding this comment.
will remove this as a follow up.
dai-chen
left a comment
There was a problem hiding this comment.
Thanks for the changes! Probably we can need to deep dive into the aliasing issue later.
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.