diff --git a/docs/changelog/136441.yaml b/docs/changelog/136441.yaml new file mode 100644 index 0000000000000..26321096ab3cd --- /dev/null +++ b/docs/changelog/136441.yaml @@ -0,0 +1,6 @@ +pr: 136441 +summary: Add TRANGE ES|QL function +area: ES|QL +type: enhancement +issues: + - 135599 diff --git a/docs/reference/query-languages/esql/_snippets/functions/description/trange.md b/docs/reference/query-languages/esql/_snippets/functions/description/trange.md new file mode 100644 index 0000000000000..18ef351a65e79 --- /dev/null +++ b/docs/reference/query-languages/esql/_snippets/functions/description/trange.md @@ -0,0 +1,6 @@ +% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it. + +**Description** + +Filters data for the given time range using the @timestamp attribute. + diff --git a/docs/reference/query-languages/esql/_snippets/functions/examples/trange.md b/docs/reference/query-languages/esql/_snippets/functions/examples/trange.md new file mode 100644 index 0000000000000..196b5dc22a375 --- /dev/null +++ b/docs/reference/query-languages/esql/_snippets/functions/examples/trange.md @@ -0,0 +1,94 @@ +% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it. + +**Examples** + +```esql +FROM k8s +| WHERE TRANGE(1h) +| KEEP @timestamp +``` + +```esql +FROM k8s +| WHERE TRANGE("2024-05-10T00:17:14.000Z", "2024-05-10T00:18:33.000Z") +| SORT @timestamp +| KEEP @timestamp +| LIMIT 10 +``` + +| @timestamp:datetime | +| --- | +| 2024-05-10T00:17:16.000Z | +| 2024-05-10T00:17:20.000Z | +| 2024-05-10T00:17:30.000Z | +| 2024-05-10T00:17:30.000Z | +| 2024-05-10T00:17:39.000Z | +| 2024-05-10T00:17:39.000Z | +| 2024-05-10T00:17:55.000Z | +| 2024-05-10T00:18:02.000Z | +| 2024-05-10T00:18:02.000Z | +| 2024-05-10T00:18:02.000Z | + +```esql +FROM k8s +| WHERE TRANGE(to_datetime("2024-05-10T00:17:14Z"), to_datetime("2024-05-10T00:18:33Z")) +| SORT @timestamp +| KEEP @timestamp +| LIMIT 10 +``` + +| @timestamp:datetime | +| --- | +| 2024-05-10T00:17:16.000Z | +| 2024-05-10T00:17:20.000Z | +| 2024-05-10T00:17:30.000Z | +| 2024-05-10T00:17:30.000Z | +| 2024-05-10T00:17:39.000Z | +| 2024-05-10T00:17:39.000Z | +| 2024-05-10T00:17:55.000Z | +| 2024-05-10T00:18:02.000Z | +| 2024-05-10T00:18:02.000Z | +| 2024-05-10T00:18:02.000Z | + +```esql +FROM k8s +| WHERE TRANGE(to_datetime("2024-05-10T00:17:14.000Z"), to_datetime("2024-05-10T00:18:33.000Z")) +| SORT @timestamp +| KEEP @timestamp +| LIMIT 10 +``` + +| @timestamp:datetime | +| --- | +| 2024-05-10T00:17:16.000Z | +| 2024-05-10T00:17:20.000Z | +| 2024-05-10T00:17:30.000Z | +| 2024-05-10T00:17:30.000Z | +| 2024-05-10T00:17:39.000Z | +| 2024-05-10T00:17:39.000Z | +| 2024-05-10T00:17:55.000Z | +| 2024-05-10T00:18:02.000Z | +| 2024-05-10T00:18:02.000Z | +| 2024-05-10T00:18:02.000Z | + +```esql +FROM k8s +| WHERE TRANGE(1715300236000, 1715300282000) +| SORT @timestamp +| KEEP @timestamp +| LIMIT 10 +``` + +| @timestamp:datetime | +| --- | +| 2024-05-10T00:17:20.000Z | +| 2024-05-10T00:17:30.000Z | +| 2024-05-10T00:17:30.000Z | +| 2024-05-10T00:17:39.000Z | +| 2024-05-10T00:17:39.000Z | +| 2024-05-10T00:17:55.000Z | +| 2024-05-10T00:18:02.000Z | +| 2024-05-10T00:18:02.000Z | +| 2024-05-10T00:18:02.000Z | + + diff --git a/docs/reference/query-languages/esql/_snippets/functions/layout/trange.md b/docs/reference/query-languages/esql/_snippets/functions/layout/trange.md new file mode 100644 index 0000000000000..df83300d10288 --- /dev/null +++ b/docs/reference/query-languages/esql/_snippets/functions/layout/trange.md @@ -0,0 +1,23 @@ +% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it. + +## `TRANGE` [esql-trange] + +**Syntax** + +:::{image} ../../../images/functions/trange.svg +:alt: Embedded +:class: text-center +::: + + +:::{include} ../parameters/trange.md +::: + +:::{include} ../description/trange.md +::: + +:::{include} ../types/trange.md +::: + +:::{include} ../examples/trange.md +::: diff --git a/docs/reference/query-languages/esql/_snippets/functions/parameters/trange.md b/docs/reference/query-languages/esql/_snippets/functions/parameters/trange.md new file mode 100644 index 0000000000000..b95bbc639ec93 --- /dev/null +++ b/docs/reference/query-languages/esql/_snippets/functions/parameters/trange.md @@ -0,0 +1,10 @@ +% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it. + +**Parameters** + +`start_time_or_offset` +: Offset from NOW for the single parameter mode. Start time for two parameter mode. In two parameter mode, the start time value can be a date string, date, date_nanos or epoch milliseconds. + +`end_time` +: Explicit end time that can be a date string, date, date_nanos or epoch milliseconds. + diff --git a/docs/reference/query-languages/esql/_snippets/functions/types/trange.md b/docs/reference/query-languages/esql/_snippets/functions/types/trange.md new file mode 100644 index 0000000000000..876a0ae43cb91 --- /dev/null +++ b/docs/reference/query-languages/esql/_snippets/functions/types/trange.md @@ -0,0 +1,13 @@ +% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it. + +**Supported types** + +| start_time_or_offset | end_time | result | +| --- | --- | --- | +| date | date | boolean | +| date_nanos | date_nanos | boolean | +| date_period | | boolean | +| keyword | keyword | boolean | +| long | long | boolean | +| time_duration | | boolean | + diff --git a/docs/reference/query-languages/esql/_snippets/lists/date-time-functions.md b/docs/reference/query-languages/esql/_snippets/lists/date-time-functions.md index 137e14e3fa52e..c2576566c615e 100644 --- a/docs/reference/query-languages/esql/_snippets/lists/date-time-functions.md +++ b/docs/reference/query-languages/esql/_snippets/lists/date-time-functions.md @@ -6,3 +6,4 @@ * [`DAY_NAME`](../../functions-operators/date-time-functions.md#esql-day_name) * [`MONTH_NAME`](../../functions-operators/date-time-functions.md#esql-month_name) * [`NOW`](../../functions-operators/date-time-functions.md#esql-now) +* [`TRANGE`](../../functions-operators/date-time-functions.md#esql-trange) diff --git a/docs/reference/query-languages/esql/functions-operators/date-time-functions.md b/docs/reference/query-languages/esql/functions-operators/date-time-functions.md index 28fc56cee5b9b..3566dce5ea5f7 100644 --- a/docs/reference/query-languages/esql/functions-operators/date-time-functions.md +++ b/docs/reference/query-languages/esql/functions-operators/date-time-functions.md @@ -40,3 +40,6 @@ mapped_pages: :::{include} ../_snippets/functions/layout/now.md ::: +:::{include} ../_snippets/functions/layout/trange.md +::: + diff --git a/docs/reference/query-languages/esql/images/functions/trange.svg b/docs/reference/query-languages/esql/images/functions/trange.svg new file mode 100644 index 0000000000000..991fb23a463ed --- /dev/null +++ b/docs/reference/query-languages/esql/images/functions/trange.svg @@ -0,0 +1 @@ +TRANGE(start_time_or_offset,end_time) \ No newline at end of file diff --git a/docs/reference/query-languages/esql/kibana/definition/functions/trange.json b/docs/reference/query-languages/esql/kibana/definition/functions/trange.json new file mode 100644 index 0000000000000..3db860a9de765 --- /dev/null +++ b/docs/reference/query-languages/esql/kibana/definition/functions/trange.json @@ -0,0 +1,113 @@ +{ + "comment" : "This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.", + "type" : "scalar", + "name" : "trange", + "description" : "Filters data for the given time range using the @timestamp attribute.", + "signatures" : [ + { + "params" : [ + { + "name" : "start_time_or_offset", + "type" : "date", + "optional" : false, + "description" : " Offset from NOW for the single parameter mode. Start time for two parameter mode. In two parameter mode, the start time value can be a date string, date, date_nanos or epoch milliseconds. " + }, + { + "name" : "end_time", + "type" : "date", + "optional" : true, + "description" : "Explicit end time that can be a date string, date, date_nanos or epoch milliseconds." + } + ], + "variadic" : false, + "returnType" : "boolean" + }, + { + "params" : [ + { + "name" : "start_time_or_offset", + "type" : "date_nanos", + "optional" : false, + "description" : " Offset from NOW for the single parameter mode. Start time for two parameter mode. In two parameter mode, the start time value can be a date string, date, date_nanos or epoch milliseconds. " + }, + { + "name" : "end_time", + "type" : "date_nanos", + "optional" : true, + "description" : "Explicit end time that can be a date string, date, date_nanos or epoch milliseconds." + } + ], + "variadic" : false, + "returnType" : "boolean" + }, + { + "params" : [ + { + "name" : "start_time_or_offset", + "type" : "date_period", + "optional" : false, + "description" : " Offset from NOW for the single parameter mode. Start time for two parameter mode. In two parameter mode, the start time value can be a date string, date, date_nanos or epoch milliseconds. " + } + ], + "variadic" : false, + "returnType" : "boolean" + }, + { + "params" : [ + { + "name" : "start_time_or_offset", + "type" : "keyword", + "optional" : false, + "description" : " Offset from NOW for the single parameter mode. Start time for two parameter mode. In two parameter mode, the start time value can be a date string, date, date_nanos or epoch milliseconds. " + }, + { + "name" : "end_time", + "type" : "keyword", + "optional" : true, + "description" : "Explicit end time that can be a date string, date, date_nanos or epoch milliseconds." + } + ], + "variadic" : false, + "returnType" : "boolean" + }, + { + "params" : [ + { + "name" : "start_time_or_offset", + "type" : "long", + "optional" : false, + "description" : " Offset from NOW for the single parameter mode. Start time for two parameter mode. In two parameter mode, the start time value can be a date string, date, date_nanos or epoch milliseconds. " + }, + { + "name" : "end_time", + "type" : "long", + "optional" : true, + "description" : "Explicit end time that can be a date string, date, date_nanos or epoch milliseconds." + } + ], + "variadic" : false, + "returnType" : "boolean" + }, + { + "params" : [ + { + "name" : "start_time_or_offset", + "type" : "time_duration", + "optional" : false, + "description" : " Offset from NOW for the single parameter mode. Start time for two parameter mode. In two parameter mode, the start time value can be a date string, date, date_nanos or epoch milliseconds. " + } + ], + "variadic" : false, + "returnType" : "boolean" + } + ], + "examples" : [ + "FROM k8s\n| WHERE TRANGE(1h)\n| KEEP @timestamp", + "FROM k8s\n| WHERE TRANGE(\"2024-05-10T00:17:14.000Z\", \"2024-05-10T00:18:33.000Z\")\n| SORT @timestamp\n| KEEP @timestamp\n| LIMIT 10", + "FROM k8s\n| WHERE TRANGE(to_datetime(\"2024-05-10T00:17:14Z\"), to_datetime(\"2024-05-10T00:18:33Z\"))\n| SORT @timestamp\n| KEEP @timestamp\n| LIMIT 10", + "FROM k8s\n| WHERE TRANGE(to_datetime(\"2024-05-10T00:17:14.000Z\"), to_datetime(\"2024-05-10T00:18:33.000Z\"))\n| SORT @timestamp\n| KEEP @timestamp\n| LIMIT 10", + "FROM k8s\n| WHERE TRANGE(1715300236000, 1715300282000)\n| SORT @timestamp\n| KEEP @timestamp\n| LIMIT 10" + ], + "preview" : false, + "snapshot_only" : false +} diff --git a/docs/reference/query-languages/esql/kibana/docs/functions/trange.md b/docs/reference/query-languages/esql/kibana/docs/functions/trange.md new file mode 100644 index 0000000000000..4d69005fc73ce --- /dev/null +++ b/docs/reference/query-languages/esql/kibana/docs/functions/trange.md @@ -0,0 +1,10 @@ +% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it. + +### TRANGE +Filters data for the given time range using the @timestamp attribute. + +```esql +FROM k8s +| WHERE TRANGE(1h) +| KEEP @timestamp +``` diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/k8s-timeseries-absent-over-time.csv-spec b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/k8s-timeseries-absent-over-time.csv-spec index a9e0363050d89..a057e4f64df88 100644 --- a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/k8s-timeseries-absent-over-time.csv-spec +++ b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/k8s-timeseries-absent-over-time.csv-spec @@ -295,3 +295,23 @@ events_received:integer | pod:keyword | time_bucket:datetime 0 | two | 2024-05-10T00:20:00.000Z 0 | two | 2024-05-10T00:22:00.000Z ; + +trange_absolute_epoch_millis_with_aggregations +required_capability: ts_command_v0 +required_capability: fn_trange + +TS k8s +| WHERE TRANGE("2024-05-10T00:20:00.000Z", "2024-05-10T00:25:00.000Z") +| STATS events_received = max(absent_over_time(events_received)) BY pod, time_bucket = tbucket(1 minute) +| SORT time_bucket +| LIMIT 5 +; +ignoreOrder:true + +events_received:boolean | pod:keyword | time_bucket:datetime +false | one | 2024-05-10T00:20:00.000Z +false | three | 2024-05-10T00:20:00.000Z +false | three | 2024-05-10T00:21:00.000Z +false | two | 2024-05-10T00:20:00.000Z +true | one | 2024-05-10T00:21:00.000Z +; diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/k8s-timeseries-rate.csv-spec b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/k8s-timeseries-rate.csv-spec index 4a940f2e12350..208fc712072c2 100644 --- a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/k8s-timeseries-rate.csv-spec +++ b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/k8s-timeseries-rate.csv-spec @@ -242,3 +242,26 @@ rate_bytes_in:double | cluster:keyword | region:keyword | time_bucket:datetime 5.4539153439153445 | prod | [eu, us] | 2024-05-10T00:00:00.000Z 5.241187469367376 | staging | us | 2024-05-10T00:00:00.000Z ; + +trange_absolute_with_rate +required_capability: ts_command_v0 +required_capability: fn_trange + +TS k8s +| WHERE TRANGE("2024-05-10T00:15:00.000Z", "2024-05-10T00:25:00.000Z") +| STATS rate_bytes_in=AVG(RATE(network.total_bytes_in)) BY cluster, time_bucket = TBUCKET(2minute) +| SORT rate_bytes_in NULLS FIRST, time_bucket, cluster +| LIMIT 10; + +rate_bytes_in:double | cluster:keyword | time_bucket:datetime +null | prod | 2024-05-10T00:14:00.000Z +null | staging | 2024-05-10T00:14:00.000Z +null | qa | 2024-05-10T00:22:00.000Z +0.0371323529411787 | staging | 2024-05-10T00:22:00.000Z +2.27097222222222 | qa | 2024-05-10T00:20:00.000Z +5.374305555555554 | staging | 2024-05-10T00:16:00.000Z +7.513221153846155 | staging | 2024-05-10T00:20:00.000Z +9.45 | prod | 2024-05-10T00:20:00.000Z +9.83125 | prod | 2024-05-10T00:22:00.000Z +10.525 | staging | 2024-05-10T00:18:00.000Z +; diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/trange.csv-spec b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/trange.csv-spec new file mode 100644 index 0000000000000..86ae63e662ed8 --- /dev/null +++ b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/trange.csv-spec @@ -0,0 +1,116 @@ +trangeAbsoluteTimeString +required_capability: fn_trange +// tag::docsTRangeAbsoluteTimeString[] +FROM k8s +| WHERE TRANGE("2024-05-10T00:17:14.000Z", "2024-05-10T00:18:33.000Z") +| SORT @timestamp +| KEEP @timestamp +| LIMIT 10 +// end::docsTRangeAbsoluteTimeString[] +; + +// tag::docsTRangeAbsoluteTimeString-result[] +@timestamp:datetime +2024-05-10T00:17:16.000Z +2024-05-10T00:17:20.000Z +2024-05-10T00:17:30.000Z +2024-05-10T00:17:30.000Z +2024-05-10T00:17:39.000Z +2024-05-10T00:17:39.000Z +2024-05-10T00:17:55.000Z +2024-05-10T00:18:02.000Z +2024-05-10T00:18:02.000Z +2024-05-10T00:18:02.000Z +// end::docsTRangeAbsoluteTimeString-result[] +; + +trangeAbsoluteTimeDateTime +required_capability: fn_trange +// tag::docsTRangeAbsoluteTimeDateTime[] +FROM k8s +| WHERE TRANGE(to_datetime("2024-05-10T00:17:14Z"), to_datetime("2024-05-10T00:18:33Z")) +| SORT @timestamp +| KEEP @timestamp +| LIMIT 10 +// end::docsTRangeAbsoluteTimeDateTime[] +; + +// tag::docsTRangeAbsoluteTimeDateTime-result[] +@timestamp:datetime +2024-05-10T00:17:16.000Z +2024-05-10T00:17:20.000Z +2024-05-10T00:17:30.000Z +2024-05-10T00:17:30.000Z +2024-05-10T00:17:39.000Z +2024-05-10T00:17:39.000Z +2024-05-10T00:17:55.000Z +2024-05-10T00:18:02.000Z +2024-05-10T00:18:02.000Z +2024-05-10T00:18:02.000Z +// end::docsTRangeAbsoluteTimeDateTime-result[] +; + +trangeAbsoluteTimeDateTimeNanos +required_capability: fn_trange +// tag::docsTRangeAbsoluteTimeDateTimeNanos[] +FROM k8s +| WHERE TRANGE(to_datetime("2024-05-10T00:17:14.000Z"), to_datetime("2024-05-10T00:18:33.000Z")) +| SORT @timestamp +| KEEP @timestamp +| LIMIT 10 +// end::docsTRangeAbsoluteTimeDateTimeNanos[] +; + +// tag::docsTRangeAbsoluteTimeDateTimeNanos-result[] +@timestamp:datetime +2024-05-10T00:17:16.000Z +2024-05-10T00:17:20.000Z +2024-05-10T00:17:30.000Z +2024-05-10T00:17:30.000Z +2024-05-10T00:17:39.000Z +2024-05-10T00:17:39.000Z +2024-05-10T00:17:55.000Z +2024-05-10T00:18:02.000Z +2024-05-10T00:18:02.000Z +2024-05-10T00:18:02.000Z +// end::docsTRangeAbsoluteTimeDateTimeNanos-result[] +; + +trangeAbsoluteTimeEpochMillis +required_capability: fn_trange +// tag::docsTRangeAbsoluteTimeEpochMillis[] +FROM k8s +| WHERE TRANGE(1715300236000, 1715300282000) +| SORT @timestamp +| KEEP @timestamp +| LIMIT 10 +// end::docsTRangeAbsoluteTimeEpochMillis[] +; + +// tag::docsTRangeAbsoluteTimeEpochMillis-result[] +@timestamp:datetime +2024-05-10T00:17:20.000Z +2024-05-10T00:17:30.000Z +2024-05-10T00:17:30.000Z +2024-05-10T00:17:39.000Z +2024-05-10T00:17:39.000Z +2024-05-10T00:17:55.000Z +2024-05-10T00:18:02.000Z +2024-05-10T00:18:02.000Z +2024-05-10T00:18:02.000Z +// end::docsTRangeAbsoluteTimeEpochMillis-result[] +; + +trangeOffsetFromNow +required_capability: fn_trange +// tag::docsTRangeOffsetFromNow[] +FROM k8s +| WHERE TRANGE(1h) +| KEEP @timestamp +// end::docsTRangeOffsetFromNow[] +; + +// tag::docsTRangeOffsetFromNow-result[] +@timestamp:datetime +// tag::docsTRangeOffsetFromNow-result[] +; diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java index 84a5103200a14..30f14ae399424 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java @@ -1558,6 +1558,11 @@ public enum Cap { * Temporarily forbid the use of an explicit or implicit LIMIT before INLINE STATS. */ FORBID_LIMIT_BEFORE_INLINE_STATS(INLINE_STATS.enabled), + /** + * Support for the TRANGE function + */ + FN_TRANGE, + // Last capability should still have a comma for fewer merge conflicts when adding new ones :) // This comment prevents the semicolon from being on the previous capability when Spotless formats the file. ; diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/evaluator/EvalMapper.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/evaluator/EvalMapper.java index 0c32fa0dc397a..c83bed8bbbb25 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/evaluator/EvalMapper.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/evaluator/EvalMapper.java @@ -181,7 +181,17 @@ public void close() { Releasables.closeExpectNoException(leftEval, rightEval); } } - return driverContext -> new BooleanLogicExpressionEvaluator(bc, leftEval.get(driverContext), rightEval.get(driverContext)); + return new ExpressionEvaluator.Factory() { + @Override + public ExpressionEvaluator get(DriverContext driverContext) { + return new BooleanLogicExpressionEvaluator(bc, leftEval.get(driverContext), rightEval.get(driverContext)); + } + + @Override + public String toString() { + return "BooleanLogicExpressionEvaluator[" + "bl=" + bc + ", leftEval=" + leftEval + ", rightEval=" + rightEval + ']'; + } + }; } } diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/EsqlFunctionRegistry.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/EsqlFunctionRegistry.java index ba30131b4220b..a44d7cb719a50 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/EsqlFunctionRegistry.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/EsqlFunctionRegistry.java @@ -110,6 +110,7 @@ import org.elasticsearch.xpack.esql.expression.function.scalar.date.DayName; import org.elasticsearch.xpack.esql.expression.function.scalar.date.MonthName; import org.elasticsearch.xpack.esql.expression.function.scalar.date.Now; +import org.elasticsearch.xpack.esql.expression.function.scalar.date.TRange; import org.elasticsearch.xpack.esql.expression.function.scalar.ip.CIDRMatch; import org.elasticsearch.xpack.esql.expression.function.scalar.ip.IpPrefix; import org.elasticsearch.xpack.esql.expression.function.scalar.ip.NetworkDirection; @@ -434,7 +435,8 @@ private static FunctionDefinition[][] functions() { def(DateTrunc.class, DateTrunc::new, "date_trunc"), def(DayName.class, DayName::new, "day_name"), def(MonthName.class, MonthName::new, "month_name"), - def(Now.class, Now::new, "now") }, + def(Now.class, Now::new, "now"), + def(TRange.class, bic(TRange::new), "trange") }, // spatial new FunctionDefinition[] { def(SpatialCentroid.class, SpatialCentroid::new, "st_centroid_agg"), @@ -539,7 +541,6 @@ private static FunctionDefinition[][] functions() { def(PercentileOverTime.class, bi(PercentileOverTime::new), "percentile_over_time"), // dense vector function def(TextEmbedding.class, bi(TextEmbedding::new), "text_embedding") } }; - } private static FunctionDefinition[][] snapshotFunctions() { @@ -1273,6 +1274,10 @@ private static BinaryBuilder bi(BinaryBuilder functio return function; } + private static BinaryConfigurationAwareBuilder bic(BinaryConfigurationAwareBuilder function) { + return function; + } + private static TernaryBuilder tri(TernaryBuilder function) { return function; } diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/date/TRange.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/date/TRange.java new file mode 100644 index 0000000000000..b43b788b80571 --- /dev/null +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/date/TRange.java @@ -0,0 +1,307 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.esql.expression.function.scalar.date; + +import org.apache.lucene.util.BytesRef; +import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.common.time.DateUtils; +import org.elasticsearch.compute.operator.EvalOperator; +import org.elasticsearch.xpack.esql.capabilities.PostAnalysisPlanVerificationAware; +import org.elasticsearch.xpack.esql.common.Failures; +import org.elasticsearch.xpack.esql.core.InvalidArgumentException; +import org.elasticsearch.xpack.esql.core.expression.Expression; +import org.elasticsearch.xpack.esql.core.expression.FoldContext; +import org.elasticsearch.xpack.esql.core.expression.Literal; +import org.elasticsearch.xpack.esql.core.expression.MetadataAttribute; +import org.elasticsearch.xpack.esql.core.expression.Nullability; +import org.elasticsearch.xpack.esql.core.expression.UnresolvedAttribute; +import org.elasticsearch.xpack.esql.core.tree.NodeInfo; +import org.elasticsearch.xpack.esql.core.tree.Source; +import org.elasticsearch.xpack.esql.core.type.DataType; +import org.elasticsearch.xpack.esql.expression.SurrogateExpression; +import org.elasticsearch.xpack.esql.expression.function.Example; +import org.elasticsearch.xpack.esql.expression.function.FunctionInfo; +import org.elasticsearch.xpack.esql.expression.function.OptionalArgument; +import org.elasticsearch.xpack.esql.expression.function.Param; +import org.elasticsearch.xpack.esql.expression.function.scalar.EsqlConfigurationFunction; +import org.elasticsearch.xpack.esql.expression.predicate.logical.And; +import org.elasticsearch.xpack.esql.expression.predicate.operator.comparison.GreaterThan; +import org.elasticsearch.xpack.esql.expression.predicate.operator.comparison.LessThanOrEqual; +import org.elasticsearch.xpack.esql.plan.logical.LogicalPlan; +import org.elasticsearch.xpack.esql.session.Configuration; + +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.time.Period; +import java.time.temporal.TemporalAmount; +import java.util.List; +import java.util.function.BiConsumer; + +import static org.elasticsearch.xpack.esql.common.Failure.fail; +import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.ParamOrdinal.DEFAULT; +import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.ParamOrdinal.FIRST; +import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.ParamOrdinal.SECOND; +import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.isFoldable; +import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.isNotNull; +import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.isType; +import static org.elasticsearch.xpack.esql.core.type.DataType.KEYWORD; +import static org.elasticsearch.xpack.esql.core.type.DataType.LONG; +import static org.elasticsearch.xpack.esql.core.type.DataType.isMillisOrNanos; +import static org.elasticsearch.xpack.esql.type.EsqlDataTypeConverter.dateTimeToLong; + +/** + * In a single-parameter mode, the function always uses the current time as the end of the range. + *
+ * Supported single parameter mode formats: + *
    + *
  • TRANGE(1h) - [now - 1h; now] - supports time_duration (1h, 1min, etc) and period (1day, 1month, etc.)
  • + *
+ * Supported two parameter mode formats: + *
    + *
  • TRANGE(2024-05-12T12:00:00, 2024-05-12T15:30:00) - [explicit start; explicit end]
  • + *
  • TRANGE(1715504400000, 1715517000000) - [explicit start in millis; explicit end in millis]
  • + *
+ */ +public class TRange extends EsqlConfigurationFunction implements OptionalArgument, SurrogateExpression, PostAnalysisPlanVerificationAware { + public static final String NAME = "TRange"; + + public static final String START_TIME_OR_OFFSET_PARAMETER = "start_time_or_offset"; + public static final String END_TIME_PARAMETER = "end_time"; + + private final Expression first; + private final Expression second; + private final Expression timestamp; + + @FunctionInfo( + returnType = "boolean", + description = "Filters data for the given time range using the @timestamp attribute.", + examples = { + @Example(file = "trange", tag = "docsTRangeOffsetFromNow"), + @Example(file = "trange", tag = "docsTRangeAbsoluteTimeString"), + @Example(file = "trange", tag = "docsTRangeAbsoluteTimeDateTime"), + @Example(file = "trange", tag = "docsTRangeAbsoluteTimeDateTimeNanos"), + @Example(file = "trange", tag = "docsTRangeAbsoluteTimeEpochMillis") } + ) + public TRange( + Source source, + @Param( + name = START_TIME_OR_OFFSET_PARAMETER, + type = { "time_duration", "date_period", "date", "date_nanos", "keyword", "long" }, + description = """ + Offset from NOW for the single parameter mode. Start time for two parameter mode. + In two parameter mode, the start time value can be a date string, date, date_nanos or epoch milliseconds. + """ + ) Expression first, + @Param(name = END_TIME_PARAMETER, type = { "keyword", "long", "date", "date_nanos" }, description = """ + Explicit end time that can be a date string, date, date_nanos or epoch milliseconds.""", optional = true) Expression second, + Configuration configuration + ) { + this(source, new UnresolvedAttribute(source, MetadataAttribute.TIMESTAMP_FIELD), first, second, configuration); + } + + public TRange(Source source, Expression timestamp, Expression first, Expression second, Configuration configuration) { + super(source, second != null ? List.of(timestamp, first, second) : List.of(timestamp, first), configuration); + this.timestamp = timestamp; + this.first = first; + this.second = second; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + throw new UnsupportedOperationException("not serialized"); + } + + @Override + public String getWriteableName() { + throw new UnsupportedOperationException("not serialized"); + } + + @Override + public EvalOperator.ExpressionEvaluator.Factory toEvaluator(ToEvaluator toEvaluator) { + throw new UnsupportedOperationException("should be rewritten"); + } + + @Override + public DataType dataType() { + return DataType.BOOLEAN; + } + + @Override + public boolean foldable() { + return timestamp.foldable() && first.foldable() && (second == null || second.foldable()); + } + + @Override + protected TypeResolution resolveType() { + if (childrenResolved() == false) { + return new TypeResolution("Unresolved children"); + } + + String operationName = sourceText(); + TypeResolution resolution = isType(timestamp, DataType::isMillisOrNanos, operationName, DEFAULT, true, "date_nanos", "date"); + + if (resolution.unresolved()) { + return resolution; + } + + // Single parameter mode + if (second == null) { + return isNotNull(first, operationName, FIRST).and(isFoldable(first, operationName, FIRST)) + .and(isType(first, DataType::isTemporalAmount, operationName, FIRST, "time_duration", "date_period")); + } + + // Two parameter mode + resolution = isNotNull(first, operationName, FIRST).and(isFoldable(first, operationName, FIRST)) + .and(isNotNull(second, operationName, SECOND)) + .and(isFoldable(second, operationName, SECOND)); + + if (resolution.unresolved()) { + return resolution; + } + + // the 2nd parameter has the same type as the 1st, which can be string (datetime), long (epoch millis), date or date_nanos + resolution = isType( + first, + dt -> isMillisOrNanos(dt) || dt == KEYWORD || dt == LONG, + operationName, + FIRST, + "string", + "long", + "date", + "date_nanos" + ).and(isType(second, dt -> dt == first.dataType(), operationName, SECOND, first.dataType().esType())); + + if (resolution.unresolved()) { + return resolution; + } + + return TypeResolution.TYPE_RESOLVED; + } + + @Override + public Expression replaceChildren(List newChildren) { + return new TRange( + source(), + newChildren.get(0), + newChildren.get(1), + newChildren.size() == 3 ? newChildren.get(2) : null, + configuration() + ); + } + + @Override + public Expression surrogate() { + long[] range = getRange(FoldContext.small()); + + Expression startLiteral = new Literal(source(), range[0], timestamp.dataType()); + Expression endLiteral = new Literal(source(), range[1], timestamp.dataType()); + + return new And(source(), new GreaterThan(source(), timestamp, startLiteral), new LessThanOrEqual(source(), timestamp, endLiteral)); + } + + @Override + protected NodeInfo info() { + return NodeInfo.create(this, TRange::new, timestamp, first, second, configuration()); + } + + @Override + public Nullability nullable() { + return timestamp.nullable(); + } + + private long[] getRange(FoldContext foldContext) { + Instant rangeStart; + Instant rangeEnd; + + try { + Object foldFirst = first.fold(foldContext); + if (second == null) { + rangeEnd = configuration().now().toInstant(); + rangeStart = timeWithOffset(foldFirst, rangeEnd); + } else { + Object foldSecond = second.fold(foldContext); + rangeStart = parseToInstant(foldFirst, START_TIME_OR_OFFSET_PARAMETER); + rangeEnd = parseToInstant(foldSecond, END_TIME_PARAMETER); + } + } catch (InvalidArgumentException e) { + throw new InvalidArgumentException(e, "invalid time range for [{}]: {}", sourceText(), e.getMessage()); + } + + if (rangeStart.isAfter(rangeEnd)) { + throw new InvalidArgumentException("TRANGE rangeStart time [{}] must be before rangeEnd time [{}]", rangeStart, rangeEnd); + } + + if (timestamp.dataType() == DataType.DATE_NANOS && first.dataType() == DataType.DATE_NANOS) { + return new long[] { DateUtils.toLong(rangeStart), DateUtils.toLong(rangeEnd) }; + } + + boolean convertToNanos = timestamp.dataType() == DataType.DATE_NANOS; + return new long[] { + convertToNanos ? DateUtils.toNanoSeconds(rangeStart.toEpochMilli()) : rangeStart.toEpochMilli(), + convertToNanos ? DateUtils.toNanoSeconds(rangeEnd.toEpochMilli()) : rangeEnd.toEpochMilli() }; + } + + private Instant timeWithOffset(Object offset, Instant base) { + if (offset instanceof TemporalAmount amount) { + return base.minus(amount); + } + throw new InvalidArgumentException("Unsupported offset type [{}]", offset.getClass().getSimpleName()); + } + + private Instant parseToInstant(Object value, String paramName) { + if (value instanceof Literal literal) { + value = literal.fold(FoldContext.small()); + } + + if (value instanceof Instant instantValue) { + return instantValue; + } + + if (value instanceof BytesRef bytesRef) { + try { + long millis = dateTimeToLong(bytesRef.utf8ToString()); + return Instant.ofEpochMilli(millis); + } catch (Exception e) { + throw new InvalidArgumentException("TRANGE {} parameter must be a valid datetime string, got: {}", paramName, value); + } + } + + if (value instanceof Long longValue) { + return Instant.ofEpochMilli(longValue); + } + + throw new InvalidArgumentException( + "Unsupported time value type [{}] for parameter [{}]", + value.getClass().getSimpleName(), + paramName + ); + } + + @Override + public BiConsumer postAnalysisPlanVerification() { + return (logicalPlan, failures) -> { + // single parameter mode + if (second == null) { + Object rangeStartValue = first.fold(FoldContext.small()); + if (rangeStartValue instanceof Duration duration && duration.isNegative() + || rangeStartValue instanceof Period period && period.isNegative()) { + failures.add(fail(first, "{} cannot be negative", START_TIME_OR_OFFSET_PARAMETER)); + } + } + + // two parameter mode + if (second != null) { + Object rangeEndValue = second.fold(FoldContext.small()); + if (rangeEndValue == null) { + failures.add(fail(second, "{} cannot be null", END_TIME_PARAMETER)); + } + } + }; + } +} diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/session/FieldNameUtils.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/session/FieldNameUtils.java index c9b6b32da2742..9906597055796 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/session/FieldNameUtils.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/session/FieldNameUtils.java @@ -22,6 +22,7 @@ import org.elasticsearch.xpack.esql.expression.UnresolvedNamePattern; import org.elasticsearch.xpack.esql.expression.function.UnresolvedFunction; import org.elasticsearch.xpack.esql.expression.function.grouping.TBucket; +import org.elasticsearch.xpack.esql.expression.function.scalar.date.TRange; import org.elasticsearch.xpack.esql.plan.logical.Aggregate; import org.elasticsearch.xpack.esql.plan.logical.Drop; import org.elasticsearch.xpack.esql.plan.logical.Enrich; @@ -56,7 +57,10 @@ public class FieldNameUtils { - private static final Set FUNCTIONS_REQUIRING_TIMESTAMP = Set.of(TBucket.NAME.toLowerCase(Locale.ROOT)); + private static final Set FUNCTIONS_REQUIRING_TIMESTAMP = Set.of( + TBucket.NAME.toLowerCase(Locale.ROOT), + TRange.NAME.toLowerCase(Locale.ROOT) + ); public static PreAnalysisResult resolveFieldNames(LogicalPlan parsed, boolean hasEnriches) { diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/VerifierTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/VerifierTests.java index b35258978b72c..685ded18cf566 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/VerifierTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/VerifierTests.java @@ -2959,6 +2959,26 @@ public void testMvExpandBeforeTSStatsNotAllowed() { [STATS max(network.connections)] is not allowed""")); } + public void testTRangeFailures() { + assertThat(error("TS test | WHERE TRANGE(-1h) | KEEP @timestamp", tsdb), equalTo("1:24: start_time_or_offset cannot be negative")); + + assertThat(error("TS test | WHERE TRANGE(\"2024-05-10T00:17:14.000Z\") | KEEP @timestamp", tsdb), equalTo(""" + 1:17: first argument of [TRANGE("2024-05-10T00:17:14.000Z")] must be [time_duration or date_period], \ + found value ["2024-05-10T00:17:14.000Z"] type [keyword]""")); + + assertThat(error("TS test | WHERE TRANGE(1 hour, 2 hours) | KEEP @timestamp", tsdb), equalTo(""" + 1:17: first argument of [TRANGE(1 hour, 2 hours)] must be [string, long, date or date_nanos], \ + found value [1 hour] type [time_duration]""")); + + assertThat(error("TS test | WHERE TRANGE(1715300236000, \"2024-05-10T00:17:14.000Z\") | KEEP @timestamp", tsdb), equalTo(""" + 1:17: second argument of [TRANGE(1715300236000, "2024-05-10T00:17:14.000Z")] must be [long], \ + found value ["2024-05-10T00:17:14.000Z"] type [keyword]""")); + + assertThat(error("TS test | WHERE TRANGE(\"2024-05-10T00:17:14.000Z\", 1 hour) | KEEP @timestamp", tsdb), equalTo(""" + 1:17: second argument of [TRANGE("2024-05-10T00:17:14.000Z", 1 hour)] must be [keyword], \ + found value [1 hour] type [time_duration]""")); + } + private void checkVectorFunctionsNullArgs(String functionInvocation) throws Exception { query("from test | eval similarity = " + functionInvocation, fullTextAnalyzer); } diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/AbstractFunctionTestCase.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/AbstractFunctionTestCase.java index 61de9ce7601f9..3a5d49c4cc188 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/AbstractFunctionTestCase.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/AbstractFunctionTestCase.java @@ -39,6 +39,7 @@ import org.elasticsearch.xpack.esql.core.expression.FieldAttribute; import org.elasticsearch.xpack.esql.core.expression.FoldContext; import org.elasticsearch.xpack.esql.core.expression.Literal; +import org.elasticsearch.xpack.esql.core.expression.NameId; import org.elasticsearch.xpack.esql.core.expression.TypeResolutions; import org.elasticsearch.xpack.esql.core.tree.Source; import org.elasticsearch.xpack.esql.core.type.DataType; @@ -650,12 +651,18 @@ protected final List rows(List multirowFields) * Those will show up in the layout in whatever order a depth first traversal finds them. */ protected static void buildLayout(Layout.Builder builder, Expression e) { + dedupAndBuildLayout(new HashSet<>(), builder, e); + } + + private static void dedupAndBuildLayout(Set seen, Layout.Builder builder, Expression e) { if (e instanceof FieldAttribute f) { - builder.append(f); + if (seen.add(f.id())) { + builder.append(f); + } return; } for (Expression c : e.children()) { - buildLayout(builder, c); + dedupAndBuildLayout(seen, builder, c); } } diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/AbstractConfigurationFunctionTestCase.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/AbstractConfigurationFunctionTestCase.java index 56e2197ce52ec..239cd3ebfc219 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/AbstractConfigurationFunctionTestCase.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/AbstractConfigurationFunctionTestCase.java @@ -30,6 +30,8 @@ protected Expression build(Source source, List args) { } public void testSerializationWithConfiguration() { + assumeTrue("can't serialize function", canSerialize()); + Configuration config = randomConfiguration(); Expression expr = buildWithConfiguration(testCase.getSource(), testCase.getDataAsFields(), config); diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/date/TRangeErrorTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/date/TRangeErrorTests.java new file mode 100644 index 0000000000000..a7e229cdebc3f --- /dev/null +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/date/TRangeErrorTests.java @@ -0,0 +1,200 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.esql.expression.function.scalar.date; + +import org.elasticsearch.common.lucene.BytesRefs; +import org.elasticsearch.xpack.esql.EsqlTestUtils; +import org.elasticsearch.xpack.esql.core.InvalidArgumentException; +import org.elasticsearch.xpack.esql.core.expression.Expression; +import org.elasticsearch.xpack.esql.core.expression.FieldAttribute; +import org.elasticsearch.xpack.esql.core.expression.Literal; +import org.elasticsearch.xpack.esql.core.expression.TypeResolutions; +import org.elasticsearch.xpack.esql.core.tree.Source; +import org.elasticsearch.xpack.esql.core.type.DataType; +import org.elasticsearch.xpack.esql.core.type.DateEsField; +import org.elasticsearch.xpack.esql.core.util.StringUtils; +import org.elasticsearch.xpack.esql.expression.function.AbstractFunctionTestCase; +import org.elasticsearch.xpack.esql.expression.function.ErrorsForCasesWithoutExamplesTestCase; +import org.elasticsearch.xpack.esql.expression.function.TestCaseSupplier; +import org.hamcrest.Matcher; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +import static org.hamcrest.Matchers.equalTo; + +public class TRangeErrorTests extends ErrorsForCasesWithoutExamplesTestCase { + + private static final String ONE_PARAM_TYPE_ERROR_STRING = "time_duration or date_period"; + private static final String TWO_PARAM_TYPE_ERROR_STRING = "string, long, date or date_nanos"; + + @Override + protected List cases() { + List suppliers = new ArrayList<>(); + // one parameter + suppliers.add(new TestCaseSupplier(List.of(DataType.TIME_DURATION), () -> null)); + suppliers.add(new TestCaseSupplier(List.of(DataType.DATE_PERIOD), () -> null)); + suppliers.add(new TestCaseSupplier(List.of(DataType.KEYWORD), () -> null)); + suppliers.add(new TestCaseSupplier(List.of(DataType.LONG), () -> null)); + suppliers.add(new TestCaseSupplier(List.of(DataType.DATETIME), () -> null)); + suppliers.add(new TestCaseSupplier(List.of(DataType.DATE_NANOS), () -> null)); + + // two parameters + suppliers.add(new TestCaseSupplier(List.of(DataType.KEYWORD, DataType.KEYWORD), () -> null)); + suppliers.add(new TestCaseSupplier(List.of(DataType.LONG, DataType.LONG), () -> null)); + suppliers.add(new TestCaseSupplier(List.of(DataType.DATETIME, DataType.DATETIME), () -> null)); + suppliers.add(new TestCaseSupplier(List.of(DataType.DATE_NANOS, DataType.DATE_NANOS), () -> null)); + + return suppliers; + } + + public void testGetRangeExceptions() { + // Invalid offset type in single parameter mode + expectThrows(InvalidArgumentException.class, equalTo("invalid time range for []: Unsupported offset type [BytesRef]"), () -> { + TRange trange = new TRange( + Source.EMPTY, + new Literal(Source.EMPTY, Instant.now(), DataType.DATETIME), + Literal.keyword(Source.EMPTY, "invalid_offset"), + null, + EsqlTestUtils.configuration(StringUtils.EMPTY) + ); + trange.surrogate(); + }); + + // Invalid datetime string in two parameter mode + expectThrows( + InvalidArgumentException.class, + equalTo( + "invalid time range for []: TRANGE start_time_or_offset parameter must be a valid datetime string, got: " + + BytesRefs.toBytesRef("invalid_offset") + ), + () -> { + TRange trange = new TRange( + Source.EMPTY, + new Literal(Source.EMPTY, Instant.now(), DataType.DATETIME), + Literal.keyword(Source.EMPTY, "invalid_offset"), + Literal.keyword(Source.EMPTY, "2024-01-01T12:00:00Z"), + EsqlTestUtils.configuration(StringUtils.EMPTY) + ); + trange.surrogate(); + } + ); + + // Start time after end time in two parameter mode + expectThrows( + InvalidArgumentException.class, + equalTo("TRANGE rangeStart time [2024-01-01T12:00:00Z] must be before rangeEnd time [2024-01-01T10:00:00Z]"), + () -> { + TRange trange = new TRange( + Source.EMPTY, + new Literal(Source.EMPTY, Instant.now(), DataType.DATETIME), + Literal.keyword(Source.EMPTY, "2024-01-01T12:00:00Z"), + Literal.keyword(Source.EMPTY, "2024-01-01T10:00:00Z"), + EsqlTestUtils.configuration(StringUtils.EMPTY) + ); + trange.surrogate(); + } + ); + + // Unsupported value type in parseToInstant + expectThrows( + InvalidArgumentException.class, + equalTo("invalid time range for []: Unsupported time value type [Double] for parameter [start_time_or_offset]"), + () -> { + TRange trange = new TRange( + Source.EMPTY, + new Literal(Source.EMPTY, Instant.now(), DataType.DATETIME), + Literal.fromDouble(Source.EMPTY, 123.45), + Literal.keyword(Source.EMPTY, "2024-01-01T12:00:00Z"), + EsqlTestUtils.configuration(StringUtils.EMPTY) + ); + trange.surrogate(); + } + ); + + // Invalid offset type in timeWithOffset + expectThrows(InvalidArgumentException.class, equalTo("invalid time range for []: Unsupported offset type [Double]"), () -> { + TRange trange = new TRange( + Source.EMPTY, + new Literal(Source.EMPTY, Instant.now(), DataType.DATETIME), + Literal.fromDouble(Source.EMPTY, 123.45), + null, + EsqlTestUtils.configuration(StringUtils.EMPTY) + ); + trange.surrogate(); + }); + } + + @Override + protected Expression build(Source source, List args) { + String fieldName = "@timestamp"; + FieldAttribute timestamp = new FieldAttribute( + source, + fieldName, + DateEsField.dateEsField(fieldName, Collections.emptyMap(), true, DateEsField.TimeSeriesFieldType.NONE) + ); + + if (args.size() == 1) { + return new TRange(source, timestamp, args.getFirst(), null, EsqlTestUtils.TEST_CFG); + } + + return new TRange(source, timestamp, args.get(0), args.get(1), EsqlTestUtils.TEST_CFG); + } + + @Override + protected Matcher expectedTypeErrorMatcher(List> validPerPosition, List signature) { + return equalTo(errorMessageStringForTRange(signature, validPerPosition, (l, p) -> { + if (signature.size() == 1 && p == 0) { + return ONE_PARAM_TYPE_ERROR_STRING; + } + + if (p == 0) { + return TWO_PARAM_TYPE_ERROR_STRING; + } + + return signature.getFirst().esType(); + })); + } + + private String errorMessageStringForTRange( + List signature, + List> validPerPosition, + AbstractFunctionTestCase.PositionalErrorMessageSupplier positionalErrorMessageSupplier + ) { + for (int i = 0; i < signature.size(); i++) { + if (signature.get(i) == DataType.NULL) { + return TypeResolutions.ParamOrdinal.fromIndex(i).name().toLowerCase(Locale.ROOT) + + " argument of [" + + sourceForSignature(signature) + + "] cannot be null, received []"; + } + } + + if (signature.size() == 1) { + if (validPerPosition.getFirst().contains(signature.getFirst()) == false) { + return typeErrorMessage(true, validPerPosition, signature, positionalErrorMessageSupplier); + } + } else { + // 2nd parameter must have the same type as the first (the 1st one is taken from signature to compare) + validPerPosition = List.of( + Set.of(DataType.KEYWORD, DataType.LONG, DataType.DATETIME, DataType.DATE_NANOS), + Set.of(signature.getFirst()) + ); + for (int i = 0; i < signature.size(); i++) { + if (validPerPosition.get(i).contains(signature.get(i)) == false) { + return typeErrorMessage(true, validPerPosition, signature, positionalErrorMessageSupplier); + } + } + } + return ""; + } +} diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/date/TRangeTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/date/TRangeTests.java new file mode 100644 index 0000000000000..afa36e5d2e1cf --- /dev/null +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/date/TRangeTests.java @@ -0,0 +1,306 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +package org.elasticsearch.xpack.esql.expression.function.scalar.date; + +import com.carrotsearch.randomizedtesting.annotations.Name; +import com.carrotsearch.randomizedtesting.annotations.ParametersFactory; + +import org.elasticsearch.common.time.DateUtils; +import org.elasticsearch.xpack.esql.core.expression.Expression; +import org.elasticsearch.xpack.esql.core.tree.Source; +import org.elasticsearch.xpack.esql.core.type.DataType; +import org.elasticsearch.xpack.esql.expression.function.DocsV3Support; +import org.elasticsearch.xpack.esql.expression.function.TestCaseSupplier; +import org.elasticsearch.xpack.esql.expression.function.scalar.AbstractConfigurationFunctionTestCase; +import org.elasticsearch.xpack.esql.session.Configuration; +import org.elasticsearch.xpack.esql.type.EsqlDataTypeConverter; +import org.hamcrest.Matchers; +import org.mockito.Mockito; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.Period; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; + +import static org.hamcrest.Matchers.anyOf; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; + +public class TRangeTests extends AbstractConfigurationFunctionTestCase { + + private static final ZonedDateTime fixedNow = ZonedDateTime.parse("2024-01-05T15:00:00Z"); + + public TRangeTests(@Name("TestCase") Supplier testCaseSupplier) { + this.testCase = testCaseSupplier.get(); + } + + record TestParameter(DataType dataType, Object value) {} + + record SingleParameterCase( + DataType argumentDataType, + Object argumentValue, + DataType timestampDataType, + long timestampValue, + long expectedStartTime, + long expectedEndTime, + boolean expectedResult + ) {} + + record TwoParameterCase( + DataType argument1DataType, + Object argument1Value, + DataType argument2DataType, + Object argument2Value, + DataType timestampDataType, + long timestampValue, + long expectedStartTime, + long expectedEndTime, + boolean expectedResult + ) {} + + @ParametersFactory + public static Iterable parameters() { + List suppliers = new ArrayList<>(); + + singleParameterTRangeSuppliers(suppliers, singleParameterTestCases()); + twoParameterTRangeSuppliers(suppliers, twoParameterAbsoluteTimeTestCases()); + + return parameterSuppliersFromTypedData(suppliers); + } + + private static List singleParameterTestCases() { + List testParameters = List.of( + new TestParameter(DataType.TIME_DURATION, Duration.ofHours(1)), + new TestParameter(DataType.DATE_PERIOD, Period.ofDays(1)) + ); + + List testCases = new ArrayList<>(); + for (DataType timestampDataType : List.of(DataType.DATETIME, DataType.DATE_NANOS)) { + boolean nanos = timestampDataType == DataType.DATE_NANOS; + + for (TestParameter testParameter : testParameters) { + long expectedStartTime = getExpectedAbsoluteTime(testParameter.value, testParameter.dataType, nanos); + long expectedEndTime = getNow(fixedNow.toInstant().toEpochMilli(), nanos); + + long timestampInsideRange = timestampInRange(expectedStartTime, expectedEndTime); + testCases.add( + new SingleParameterCase( + testParameter.dataType, + testParameter.value, + timestampDataType, + timestampInsideRange, + expectedStartTime, + expectedEndTime, + true + ) + ); + + long timestampOutsideRange = expectedStartTime - Duration.ofMinutes(10).toMillis(); + testCases.add( + new SingleParameterCase( + testParameter.dataType, + testParameter.value, + timestampDataType, + timestampOutsideRange, + expectedStartTime, + expectedEndTime, + false + ) + ); + } + } + return testCases; + } + + private static void singleParameterTRangeSuppliers(List suppliers, List testCases) { + for (SingleParameterCase testCase : testCases) { + suppliers.add( + new TestCaseSupplier( + List.of(testCase.timestampDataType, testCase.argumentDataType), + () -> new TestCaseSupplier.TestCase( + List.of( + new TestCaseSupplier.TypedData(testCase.timestampValue, testCase.timestampDataType, "@timestamp"), + new TestCaseSupplier.TypedData(testCase.argumentValue, testCase.argumentDataType, "start_time_or_interval") + .forceLiteral() + ), + Matchers.equalTo( + "BooleanLogicExpressionEvaluator[bl=source, " + + "leftEval=GreaterThanLongsEvaluator[lhs=Attribute[channel=0], rhs=LiteralsEvaluator[lit=" + + testCase.expectedStartTime + + "]], rightEval=LessThanOrEqualLongsEvaluator[lhs=Attribute[channel=0], rhs=LiteralsEvaluator[lit=" + + testCase.expectedEndTime + + "]]]" + ), + DataType.BOOLEAN, + equalTo(testCase.expectedResult) + ) + ) + ); + } + } + + private static List twoParameterAbsoluteTimeTestCases() { + List testParameters = List.of( + new TestParameter[] { + new TestParameter(DataType.KEYWORD, "2024-01-01T00:00:00"), + new TestParameter(DataType.KEYWORD, "2024-01-01T12:00:00") }, + new TestParameter[] { + new TestParameter(DataType.LONG, ZonedDateTime.parse("2024-01-01T00:00:00Z").toInstant().toEpochMilli()), + new TestParameter(DataType.LONG, ZonedDateTime.parse("2024-01-01T12:00:00Z").toInstant().toEpochMilli()) }, + new TestParameter[] { + new TestParameter(DataType.DATETIME, Instant.parse("2024-01-01T00:00:00Z")), + new TestParameter(DataType.DATETIME, Instant.parse("2024-01-01T12:00:00Z")), }, + new TestParameter[] { + new TestParameter(DataType.DATE_NANOS, Instant.parse("2024-01-01T00:00:00Z")), + new TestParameter(DataType.DATE_NANOS, Instant.parse("2024-01-01T12:00:00Z")), } + ); + + List testCases = new ArrayList<>(); + for (DataType timestampDataType : List.of(DataType.DATETIME, DataType.DATE_NANOS)) { + boolean nanos = timestampDataType == DataType.DATE_NANOS; + + for (TestParameter[] testParameter : testParameters) { + long expectedStartTime = getExpectedAbsoluteTime(testParameter[0].value, testParameter[0].dataType, nanos); + long expectedEndTime = getExpectedAbsoluteTime(testParameter[1].value, testParameter[1].dataType, nanos); + + long timestampInsideRange = timestampInRange(expectedStartTime, expectedEndTime); + testCases.add( + new TwoParameterCase( + testParameter[0].dataType, + testParameter[0].value, + testParameter[1].dataType, + testParameter[1].value, + timestampDataType, + timestampInsideRange, + expectedStartTime, + expectedEndTime, + true + ) + ); + + long timestampOutsideRange = expectedStartTime - Duration.ofMinutes(10).toMillis(); + testCases.add( + new TwoParameterCase( + testParameter[0].dataType, + testParameter[0].value, + testParameter[1].dataType, + testParameter[1].value, + timestampDataType, + timestampOutsideRange, + expectedStartTime, + expectedEndTime, + false + ) + ); + } + } + return testCases; + } + + private static void twoParameterTRangeSuppliers(List suppliers, List testCases) { + for (TwoParameterCase testCase : testCases) { + suppliers.add( + new TestCaseSupplier( + List.of(testCase.timestampDataType, testCase.argument1DataType, testCase.argument2DataType), + () -> new TestCaseSupplier.TestCase( + List.of( + new TestCaseSupplier.TypedData(testCase.timestampValue, testCase.timestampDataType, "@timestamp"), + new TestCaseSupplier.TypedData(testCase.argument1Value, testCase.argument1DataType, "start_time_or_interval") + .forceLiteral(), + new TestCaseSupplier.TypedData(testCase.argument2Value, testCase.argument2DataType, "start_time_or_interval") + .forceLiteral() + ), + Matchers.equalTo( + "BooleanLogicExpressionEvaluator[bl=source, " + + "leftEval=GreaterThanLongsEvaluator[lhs=Attribute[channel=0], rhs=LiteralsEvaluator[lit=" + + testCase.expectedStartTime + + "]], rightEval=LessThanOrEqualLongsEvaluator[lhs=Attribute[channel=0], rhs=LiteralsEvaluator[lit=" + + testCase.expectedEndTime + + "]]]" + ), + DataType.BOOLEAN, + equalTo(testCase.expectedResult) + ) + ) + ); + } + } + + private static long timestampInRange(long min, long max) { + return (min + max) / 2; + } + + private static long getExpectedAbsoluteTime(Object argument, DataType dataType, boolean nanos) { + final Instant now = fixedNow.toInstant(); + switch (dataType) { + case TIME_DURATION -> { + return nanos + ? DateUtils.toNanoSeconds(now.minus((Duration) argument).toEpochMilli()) + : now.minus((Duration) argument).toEpochMilli(); + } + case DATE_PERIOD -> { + return nanos + ? DateUtils.toNanoSeconds(now.minus((Period) argument).toEpochMilli()) + : now.minus((Period) argument).toEpochMilli(); + } + case KEYWORD -> { + long expectedStartTime = EsqlDataTypeConverter.DEFAULT_DATE_TIME_FORMATTER.parseMillis((String) argument); + return nanos ? DateUtils.toNanoSeconds(expectedStartTime) : expectedStartTime; + } + case LONG -> { + long expectedStartTime = (Long) argument; + return nanos ? DateUtils.toNanoSeconds(expectedStartTime) : expectedStartTime; + } + case DATETIME, DATE_NANOS -> { + return nanos ? DateUtils.toLong((Instant) argument) : DateUtils.toLongMillis((Instant) argument); + } + default -> throw new IllegalArgumentException("Unexpected data type: " + dataType); + } + } + + private static long getNow(long now, boolean nanos) { + return nanos == false ? now : DateUtils.toNanoSeconds(now); + } + + @Override + protected Expression buildWithConfiguration(Source source, List args, Configuration configuration) { + Clock fixedClock = Clock.fixed(fixedNow.toInstant(), fixedNow.getZone().normalized()); + ZonedDateTime fixedNow = ZonedDateTime.now(Clock.tick(fixedClock, Duration.ofNanos(1))); + + Configuration spyConfig = Mockito.spy(configuration); + Mockito.doReturn(fixedNow).when(spyConfig).now(); + + if (args.size() == 2) { + return new TRange(source, args.get(0), args.get(1), null, spyConfig); + } else if (args.size() == 3) { + return new TRange(source, args.get(0), args.get(1), args.get(2), spyConfig); + } else { + throw new IllegalArgumentException("Unexpected number of arguments: " + args.size()); + } + } + + public static List signatureTypes(List params) { + if (params.size() == 2) { + assertThat(params.get(0).dataType(), anyOf(equalTo(DataType.DATE_NANOS), equalTo(DataType.DATETIME))); + return List.of(params.get(1)); + } + + assertThat(params, hasSize(3)); + assertThat(params.get(0).dataType(), anyOf(equalTo(DataType.DATE_NANOS), equalTo(DataType.DATETIME))); + return List.of(params.get(1), params.get(2)); + } + + @Override + protected boolean canSerialize() { + return false; + } +} diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LogicalPlanOptimizerTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LogicalPlanOptimizerTests.java index 4bf0b1ae635f5..b098c5cd35ee9 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LogicalPlanOptimizerTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LogicalPlanOptimizerTests.java @@ -42,6 +42,7 @@ import org.elasticsearch.xpack.esql.core.tree.Source; import org.elasticsearch.xpack.esql.core.type.DataType; import org.elasticsearch.xpack.esql.core.type.PotentiallyUnmappedKeywordEsField; +import org.elasticsearch.xpack.esql.core.util.DateUtils; import org.elasticsearch.xpack.esql.core.util.Holder; import org.elasticsearch.xpack.esql.core.util.StringUtils; import org.elasticsearch.xpack.esql.expression.Order; @@ -9322,4 +9323,81 @@ public void LookupJoinSemanticFilterDeupPushdown() { var rightRelation = as(rightFilter.child(), EsRelation.class); } + /** + * EsqlProject[[@timestamp{r}#3]] + * \_Eval[[1715300259000[DATETIME] AS @timestamp#3]] + * \_Limit[1000[INTEGER],false] + * \_EsRelation[k8s][@timestamp{f}#5, client.ip{f}#9, cluster{f}#6, ...] + */ + public void testTranslateTRangeFoldsToLiteralWhenTimestampInsideRange() { + String timestampValue = "2024-05-10T00:17:39.000Z"; + + String query = String.format(Locale.ROOT, """ + TS k8s + | EVAL @timestamp = to_datetime("%s") + | WHERE TRANGE("2024-05-10T00:17:14.000Z", "2024-05-10T00:18:33.000Z") + | KEEP @timestamp + """, timestampValue); + + LogicalPlan statement = parser.createStatement(query); + LogicalPlan analyze = metricsAnalyzer.analyze(statement); + LogicalPlan plan = logicalOptimizerWithLatestVersion.optimize(analyze); + + Project project = as(plan, Project.class); + Eval eval = as(project.child(), Eval.class); + assertThat(eval.fields(), hasSize(1)); + + Literal timestampLiteral = as(Alias.unwrap(eval.fields().getFirst()), Literal.class); + long expectedTimestamp = DateUtils.asDateTimeWithNanos(timestampValue, DateUtils.UTC).toInstant().toEpochMilli(); + assertThat(timestampLiteral.fold(FoldContext.small()), equalTo(expectedTimestamp)); + + Limit limit = asLimit(eval.child(), 1000, false); + assertThat(limit.children(), hasSize(1)); + + EsRelation relation = as(limit.child(), EsRelation.class); + assertThat(relation.children(), hasSize(0)); + } + + /** + * LocalRelation[[@timestamp{r}#3],EMPTY] + */ + public void testTranslateTRangeFoldsToLiteralWhenTimestampOutsideRange() { + String timestampValue = "2024-05-10T00:15:39.000Z"; + + String query = String.format(Locale.ROOT, """ + TS k8s + | EVAL @timestamp = to_datetime("%s") + | WHERE TRANGE("2024-05-10T00:17:14.000Z", "2024-05-10T00:18:33.000Z") + | KEEP @timestamp + """, timestampValue); + + LogicalPlan statement = parser.createStatement(query); + LogicalPlan analyze = metricsAnalyzer.analyze(statement); + LogicalPlan plan = logicalOptimizerWithLatestVersion.optimize(analyze); + + LocalRelation relation = as(plan, LocalRelation.class); + assertThat(relation.output(), hasSize(1)); + assertThat(relation.children(), hasSize(0)); + } + + /** + * LocalRelation[[@timestamp{r}#3],EMPTY] + */ + public void testTranslateTRangeFoldsToLocalRelation() { + LogicalPlan statement = parser.createStatement(""" + TS k8s + | EVAL @timestamp = null::datetime + | WHERE TRANGE("2024-05-10T00:17:14.000Z", "2024-05-10T00:18:33.000Z") + | KEEP @timestamp + """); + LogicalPlan analyze = metricsAnalyzer.analyze(statement); + LogicalPlan plan = logicalOptimizerWithLatestVersion.optimize(analyze); + + LocalRelation relation = as(plan, LocalRelation.class); + assertThat(relation.output(), hasSize(1)); + assertThat(relation.children(), hasSize(0)); + + Attribute attribute = relation.output().get(0); + assertThat(attribute.name(), equalTo("@timestamp")); + } } diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/PhysicalPlanOptimizerTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/PhysicalPlanOptimizerTests.java index 40acba1df02af..89af3be42518e 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/PhysicalPlanOptimizerTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/PhysicalPlanOptimizerTests.java @@ -249,6 +249,7 @@ public class PhysicalPlanOptimizerTests extends ESTestCase { private TestDataSource cartesianMultipolygonsNoDocValues; // cartesian_shape field tests but has no doc values private TestDataSource countriesBbox; // geo_shape field tests private TestDataSource countriesBboxWeb; // cartesian_shape field tests + private TestDataSource metricsData; // k8s metrics index with time-series fields private final Configuration config; private PlannerSettings plannerSettings; @@ -381,6 +382,7 @@ public void init() { functionRegistry, enrichResolution ); + this.metricsData = makeTestDataSource("k8s", "k8s-mappings.json", functionRegistry, enrichResolution); this.plannerSettings = TEST_PLANNER_SETTINGS; } @@ -8700,6 +8702,63 @@ public List output() { assertThat(e.getMessage(), containsString("Output has changed from")); } + /** + * + * LimitExec[1000[INTEGER],1774] + * \_ExchangeExec[[@timestamp{f}#3, client.ip{f}#7, cluster{f}#4, event{f}#9, event_city{f}#12, event_city_boundary{f}#13, event + * _location{f}#15, event_log{f}#10, event_shape{f}#14, events_received{f}#11, network.bytes_in{f}#17, network.cost{f}#20, ...], + * false] + * \_ProjectExec[[@timestamp{f}#3, client.ip{f}#7, cluster{f}#4, event{f}#9, event_city{f}#12, event_city_boundary{f}#13, event + * _location{f}#15, event_log{f}#10, event_shape{f}#14, events_received{f}#11, network.bytes_in{f}#17, network.cost{f}#20, ...]] + * \_FieldExtractExec[@timestamp{f}#3, client.ip{f}#7, cluster{f}#4, even..] + * \_EsQueryExec[k8s], indexMode[standard], [_doc{f}#30], limit[1000], sort[] estimatedRowSize[1778] queryBuilderAndTags + * [[QueryBuilderAndTags{queryBuilder=[{ + * "esql_single_value" : { + * "field" : "@timestamp", + * "next" : { + * "range" : { + * "@timestamp" : { + * "gt" : "2023-10-23T12:00:00.000Z", + * "lte" : "2023-10-23T14:00:00.000Z", + * "format" : "strict_date_optional_time", + * "boost" : 0.0 + * } + * } + * }, + * "source" : "TRANGE(\"2023-10-23T12:00:00.000Z\", \"2023-10-23T14:00:00.000Z\")@2:9" + * } + * }], tags=[]}]] + * + */ + public void testPushTRangeFunction() { + String startRange = "2023-10-23T12:00:00.000Z"; + String endRange = "2023-10-23T14:00:00.000Z"; + + String query = String.format(Locale.ROOT, """ + FROM k8s + | WHERE TRANGE("%s", "%s") + """, startRange, endRange); + + var plan = physicalPlan(query, metricsData); + var optimized = optimizedPlan(plan); + var topLimit = as(optimized, LimitExec.class); + var exchange = asRemoteExchange(topLimit.child()); + var project = as(exchange.child(), ProjectExec.class); + var fieldExtract = as(project.child(), FieldExtractExec.class); + var source = source(fieldExtract.child()); + + var singleValue = as(source.query(), SingleValueQuery.Builder.class); + assertThat(singleValue.fieldName(), equalTo("@timestamp")); + + var rangeQuery = as(sv(singleValue, "@timestamp"), RangeQueryBuilder.class); + + assertThat(rangeQuery.fieldName(), equalTo("@timestamp")); + assertThat(rangeQuery.from(), equalTo(startRange)); + assertThat(rangeQuery.to(), equalTo(endRange)); + assertFalse(rangeQuery.includeLower()); + assertTrue(rangeQuery.includeUpper()); + } + @Override protected List filteredWarnings() { return withDefaultLimitWarning(super.filteredWarnings());