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 @@
+
\ 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 extends Expression> 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