Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
6573786
Add SQL histogram and date_histogram bucket functions
RyanL1997 Aug 17, 2026
cc420ab
Defer every unlowerable bucket call to the legacy engine
RyanL1997 Aug 17, 2026
7072d03
Make the bucket-function ITs behave the same with and without analyti…
RyanL1997 Aug 18, 2026
510eaaf
Gate the legacy-only bucket tests behind a capability instead of drop…
RyanL1997 Aug 18, 2026
b2cdea1
Report a bad argument instead of deferring to the legacy engine
RyanL1997 Aug 19, 2026
b954e10
Handle bucket functions the way this parser handles its other functions
RyanL1997 Aug 19, 2026
981a438
Make the missing parameter substitute a value the engine can evaluate
RyanL1997 Aug 19, 2026
cb0023a
Always lower a bucket function to a span
RyanL1997 Aug 19, 2026
bb83e90
Let V2 answer bucket calls written with bare argument names
RyanL1997 Aug 19, 2026
3a2b1e6
Report a misspelled bucket parameter instead of deferring it
RyanL1997 Aug 19, 2026
3f6b51c
Fold the bucket argument checks into the visitor
RyanL1997 Aug 20, 2026
7950169
Drop an unreachable bucket argument name
RyanL1997 Aug 20, 2026
448b3ad
Keep deferring the bucket parameters legacy implements
RyanL1997 Aug 20, 2026
f3aaf4f
Follow the conventions this repo already has for the bucket tests
RyanL1997 Aug 20, 2026
9d798fc
Let the grammar decide which bucket parameters we answer
RyanL1997 Aug 20, 2026
ed7284e
Build the bucket span the way PPL builds its own
RyanL1997 Aug 20, 2026
7f6f3a3
Document the bucket functions where GROUP BY is documented
RyanL1997 Aug 20, 2026
724b1c4
Point at the bucket functions from the function list
RyanL1997 Aug 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions docs/user/dql/aggregations.rst
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,40 @@ The group by expression could be expression::
+---------------------+----------+


Bucket Function
---------------

The group by expression could be a bucket function, which splits a field into
fixed-width buckets. ``date_histogram`` takes a time interval, ``histogram`` a
numeric width, and the field is given first. The interval parameter is one of
``interval``, ``fixed_interval`` or ``calendar_interval``. A bucket has to be
projected in a subquery before it can be grouped on::

os> SELECT b, count(*) FROM (SELECT date_histogram(field=timestamp, interval='1w') AS b FROM nyc_taxi) sub GROUP BY b ORDER BY b;
fetched rows / total rows = 4/4
+---------------------+----------+
| b | count(*) |
|---------------------+----------|
| 2014-06-30 00:00:00 | 288 |
| 2014-07-07 00:00:00 | 336 |
| 2014-07-14 00:00:00 | 336 |
| 2014-07-21 00:00:00 | 13 |
+---------------------+----------+

The time units are millisecond (``ms``), second (``s``), minute (``m``), hour
(``h``), day (``d``), week (``w``), month (``M``), quarter (``q``) and year
(``y``). A numeric field is bucketed the same way, with the width as a number::

os> SELECT b, count(*) FROM (SELECT histogram(field=age, interval=10) AS b FROM accounts) sub GROUP BY b ORDER BY b;
fetched rows / total rows = 2/2
+----+----------+
| b | count(*) |
|----+----------|
| 20 | 1 |
| 30 | 3 |
+----+----------+


Aggregation
===========

Expand Down
2 changes: 2 additions & 0 deletions docs/user/dql/functions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ There is support for a wide variety of functions shared by SQL/PPL. We are inten

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.



Type Conversion
===============
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -994,7 +994,12 @@ public enum Index {
"timewrap_test",
"timewrap_test",
"{\"mappings\":{\"properties\":{\"@timestamp\":{\"type\":\"date\"},\"host\":{\"type\":\"keyword\"},\"requests\":{\"type\":\"integer\"},\"errors\":{\"type\":\"integer\"}}}}",
"src/test/resources/timewrap_test.json");
"src/test/resources/timewrap_test.json"),
DATE_HISTOGRAM_TEST(
"date_histogram_test",
"date_histogram_test",
getMappingFile("date_histogram_test_index_mapping.json"),
"src/test/resources/date_histogram_test.json");

private final String name;
private final String type;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

package org.opensearch.sql.sql;

import static org.junit.Assert.assertThrows;
import static org.opensearch.sql.util.Capability.LEGACY_ENGINE_FALLBACK;
import static org.opensearch.sql.util.MatcherUtils.rows;
import static org.opensearch.sql.util.MatcherUtils.verifyDataRows;
import static org.opensearch.sql.util.MatcherUtils.verifyDataRowsInOrder;

import java.io.IOException;
import org.json.JSONObject;
import org.junit.Test;
import org.opensearch.client.ResponseException;
import org.opensearch.sql.legacy.SQLIntegTestCase;
import org.opensearch.sql.util.RequiresCapability;

/**
* Execution coverage for {@code date_histogram} and {@code histogram}. The expander unit tests
* assert the AST that gets built; these assert what comes back after analysis, planning and
* pushdown, against 72 documents on fixed timestamps:
*
* <pre>
* 00:00 x5 alpha 00:30 x7 beta 01:00 x11 alpha
* 01:45 x13 gamma 02:00 x17 beta 03:00 x19 alpha
* </pre>
*
* so hourly grouping must yield 12/24/17/19 and half-hourly 5/7/11/13/17/19.
*/
public class DateHistogramBucketFunctionIT extends SQLIntegTestCase {

private static final String IDX = "date_histogram_test";

@Override
protected void init() throws Exception {
super.init();
loadIndex(Index.DATE_HISTOGRAM_TEST);
}

/**
* The bucket has to be projected in a derived table before it can be grouped on; see {@link
* #groupingOnTheBucketWithoutADerivedTableIsRejected}. This is also the shape Dashboards emits.
*/
private static String bucketed(String bucketExpr) {
return "SELECT b, COUNT(*) FROM (SELECT "
+ bucketExpr
+ " AS b FROM "
+ IDX
+ ") sub GROUP BY b ORDER BY b";
}

@Test
public void hourlyBucketsCarryKeysAndCounts() throws IOException {
JSONObject response = executeQuery(bucketed("date_histogram(field=ts, interval='1h')"));

verifyDataRowsInOrder(
response,
rows("2026-01-01 00:00:00", 12),
rows("2026-01-01 01:00:00", 24),
rows("2026-01-01 02:00:00", 17),
rows("2026-01-01 03:00:00", 19));
}

/** A sub-hour interval must split 00:00/00:30 and 01:00/01:45 rather than merge them. */
@Test
public void halfHourlyBucketsSplitWithinTheHour() throws IOException {
JSONObject response = executeQuery(bucketed("date_histogram(field=ts, interval='30m')"));

verifyDataRowsInOrder(
response,
rows("2026-01-01 00:00:00", 5),
rows("2026-01-01 00:30:00", 7),
rows("2026-01-01 01:00:00", 11),
rows("2026-01-01 01:30:00", 13),
rows("2026-01-01 02:00:00", 17),
rows("2026-01-01 03:00:00", 19));
}

@Test
public void dailyIntervalCollapsesEverythingIntoOneBucket() throws IOException {
JSONObject response = executeQuery(bucketed("date_histogram(field=ts, interval='1d')"));

verifyDataRows(response, rows("2026-01-01 00:00:00", 72));
}

/** {@code fixed_interval} and {@code calendar_interval} are accepted as synonyms of interval. */
@Test
public void intervalSynonymsProduceTheSameBuckets() throws IOException {
JSONObject viaFixed = executeQuery(bucketed("date_histogram(field=ts, fixed_interval='1h')"));
JSONObject viaCalendar =
executeQuery(bucketed("date_histogram(field=ts, calendar_interval='1h')"));

for (JSONObject response : new JSONObject[] {viaFixed, viaCalendar}) {
verifyDataRowsInOrder(
response,
rows("2026-01-01 00:00:00", 12),
rows("2026-01-01 01:00:00", 24),
rows("2026-01-01 02:00:00", 17),
rows("2026-01-01 03:00:00", 19));
}
}

/** A second grouping key needs the scan in a derived table of its own as well. */
@Test
public void bucketsCombineWithAnAdditionalGroupingKey() throws IOException {
JSONObject response =
executeQuery(
"SELECT b, c, COUNT(*) FROM (SELECT date_histogram(field=ts, interval='1h') AS b,"
+ " category AS c FROM (SELECT * FROM "
+ IDX
+ ") inner_scan) sub GROUP BY b, c ORDER BY b, c");

verifyDataRowsInOrder(
response,
rows("2026-01-01 00:00:00", "alpha", 5),
rows("2026-01-01 00:00:00", "beta", 7),
rows("2026-01-01 01:00:00", "alpha", 11),
rows("2026-01-01 01:00:00", "gamma", 13),
rows("2026-01-01 02:00:00", "beta", 17),
rows("2026-01-01 03:00:00", "alpha", 19));
}

@Test
public void bucketsRespectAWhereClause() throws IOException {
JSONObject response =
executeQuery(
"SELECT b, COUNT(*) FROM (SELECT date_histogram(field=ts, interval='1h') AS b FROM "
+ IDX
+ " WHERE category = 'alpha') sub GROUP BY b ORDER BY b");

verifyDataRowsInOrder(
response,
rows("2026-01-01 00:00:00", 5),
rows("2026-01-01 01:00:00", 11),
rows("2026-01-01 03:00:00", 19));
}

@Test
public void numericHistogramBucketsByInterval() throws IOException {
JSONObject response = executeQuery(bucketed("histogram(field=value, interval=20)"));

// value runs 1..72, so the 20-wide buckets hold 19, 20, 20 and 13 documents.
verifyDataRowsInOrder(response, rows(0, 19), rows(20, 20), rows(40, 20), rows(60, 13));
}

/**
* The legacy engine implements alias, format, time_zone, min_doc_count and order through the
* native date_histogram aggregation; this lowering has no equivalent, so those queries still have
* to reach it. CsvFormatResponseIT.dateHistogramTest has asserted this shape for years.
*/
@Test
@RequiresCapability(LEGACY_ENGINE_FALLBACK)
public void callWithAliasParameterReturnsHourlyBuckets() throws IOException {
JSONObject response =
executeQuery(
"SELECT COUNT(*) FROM "
+ IDX
+ " GROUP BY date_histogram(field='ts',fixed_interval='1h','alias'='hours')");

verifyDataRows(response, rows(12), rows(24), rows(17), rows(19));
}

@Test
public void unquotedArgumentNamesReturnNumericBuckets() throws IOException {
JSONObject response = executeQuery(bucketed("histogram(field=value, interval=20)"));

verifyDataRowsInOrder(response, rows(0, 19), rows(20, 20), rows(40, 20), rows(60, 13));
}

/**
* A span over a bare table scan cannot resolve its field, with or without a select alias, so the
* bucket always has to be projected in a derived table first. Both routes reject this; only the
* message differs, so the assertion is on the rejection alone.
*/
@Test
public void groupingOnTheBucketWithoutADerivedTableIsRejected() {
assertThrows(
ResponseException.class,
() ->
executeQuery(
"SELECT date_histogram(field=ts, interval='1h') AS b, COUNT(*) FROM "
+ IDX
+ " GROUP BY date_histogram(field=ts, interval='1h')"));
}

/** Calendar units: Dashboards emits 1M and 1y at the wider zoom levels. */
@Test
public void calendarIntervalsBucketByMonthAndYear() throws IOException {
verifyDataRows(
executeQuery(bucketed("date_histogram(field=ts, interval='1M')")),
rows("2026-01-01 00:00:00", 72));
verifyDataRows(
executeQuery(bucketed("date_histogram(field=ts, interval='1y')")),
rows("2026-01-01 00:00:00", 72));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,17 @@ public enum Capability {
PREPARED_STATEMENT(
"Prepared statements are unsupported on the analytics-engine route (Calcite path)."),

/**
* FRONTEND: the legacy V1 engine answers call shapes the V2 grammar declines, but only on the
* default route. Requests reach it when RestSQLQueryAction catches a SyntaxCheckException; the
* analytics-engine route enters through RestUnifiedQueryAction, which has no such fallback.
*/
LEGACY_ENGINE_FALLBACK(
"A call shape only the legacy V1 engine understands (e.g. a date_histogram `alias`"
+ " parameter) can't be answered on the analytics-engine route: reaching that engine"
+ " depends on RestSQLQueryAction's SyntaxCheckException fallback, and the analytics"
+ " route does not go through it."),

/**
* FRONTEND: legacy method-query syntax (regexp_query/wildcard_query) is not in the Calcite
* grammar.
Expand Down
Loading
Loading