Browse Source

[ES|QL] explicit cast a string literal to date_period and time_duration in arithmetic operations (#109193)

explicit cast to date_period and time_duration in arithmic operation
Fang Xing 1 year ago
parent
commit
e8569356ea
37 changed files with 1335 additions and 36 deletions
  1. 6 0
      docs/changelog/109193.yaml
  2. 5 0
      docs/reference/esql/functions/description/to_dateperiod.asciidoc
  3. 5 0
      docs/reference/esql/functions/description/to_timeduration.asciidoc
  4. 13 0
      docs/reference/esql/functions/examples/to_dateperiod.asciidoc
  5. 13 0
      docs/reference/esql/functions/examples/to_timeduration.asciidoc
  6. 47 0
      docs/reference/esql/functions/kibana/definition/to_dateperiod.json
  7. 47 0
      docs/reference/esql/functions/kibana/definition/to_timeduration.json
  8. 10 0
      docs/reference/esql/functions/kibana/docs/to_dateperiod.md
  9. 10 0
      docs/reference/esql/functions/kibana/docs/to_timeduration.md
  10. 2 0
      docs/reference/esql/functions/kibana/inline_cast.json
  11. 15 0
      docs/reference/esql/functions/layout/to_dateperiod.asciidoc
  12. 15 0
      docs/reference/esql/functions/layout/to_timeduration.asciidoc
  13. 6 0
      docs/reference/esql/functions/parameters/to_dateperiod.asciidoc
  14. 6 0
      docs/reference/esql/functions/parameters/to_timeduration.asciidoc
  15. 1 0
      docs/reference/esql/functions/signature/to_dateperiod.svg
  16. 1 0
      docs/reference/esql/functions/signature/to_timeduration.svg
  17. 4 0
      docs/reference/esql/functions/type-conversion-functions.asciidoc
  18. 11 0
      docs/reference/esql/functions/types/to_dateperiod.asciidoc
  19. 11 0
      docs/reference/esql/functions/types/to_timeduration.asciidoc
  20. 35 0
      x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/RestEsqlTestCase.java
  21. 137 0
      x-pack/plugin/esql/qa/testFixtures/src/main/resources/convert.csv-spec
  22. 9 1
      x-pack/plugin/esql/qa/testFixtures/src/main/resources/meta.csv-spec
  23. 6 1
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java
  24. 6 1
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/analysis/Verifier.java
  25. 4 0
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/EsqlFunctionRegistry.java
  26. 1 1
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/convert/AbstractConvertFunction.java
  27. 75 0
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/convert/FoldablesConvertFunction.java
  28. 54 0
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/convert/ToDatePeriod.java
  29. 54 0
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/convert/ToTimeDuration.java
  30. 2 2
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/ExpressionBuilder.java
  31. 130 18
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/type/EsqlDataTypeConverter.java
  32. 233 12
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/VerifierTests.java
  33. 88 0
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/convert/ToDatePeriodTests.java
  34. 87 0
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/convert/ToTimeDurationTests.java
  35. 141 0
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LogicalPlanOptimizerTests.java
  36. 16 0
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/parser/StatementParserTests.java
  37. 29 0
      x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/esql/10_basic.yml

+ 6 - 0
docs/changelog/109193.yaml

@@ -0,0 +1,6 @@
+pr: 109193
+summary: "[ES|QL] explicit cast a string literal to `date_period` and `time_duration`\
+  \ in arithmetic operations"
+area: ES|QL
+type: enhancement
+issues: []

+ 5 - 0
docs/reference/esql/functions/description/to_dateperiod.asciidoc

@@ -0,0 +1,5 @@
+// This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.
+
+*Description*
+
+Converts an input value into a `date_period` value.

+ 5 - 0
docs/reference/esql/functions/description/to_timeduration.asciidoc

@@ -0,0 +1,5 @@
+// This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.
+
+*Description*
+
+Converts an input value into a `time_duration` value.

+ 13 - 0
docs/reference/esql/functions/examples/to_dateperiod.asciidoc

@@ -0,0 +1,13 @@
+// This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.
+
+*Example*
+
+[source.merge.styled,esql]
+----
+include::{esql-specs}/convert.csv-spec[tag=castToDatePeriod]
+----
+[%header.monospaced.styled,format=dsv,separator=|]
+|===
+include::{esql-specs}/convert.csv-spec[tag=castToDatePeriod-result]
+|===
+

+ 13 - 0
docs/reference/esql/functions/examples/to_timeduration.asciidoc

@@ -0,0 +1,13 @@
+// This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.
+
+*Example*
+
+[source.merge.styled,esql]
+----
+include::{esql-specs}/convert.csv-spec[tag=castToTimeDuration]
+----
+[%header.monospaced.styled,format=dsv,separator=|]
+|===
+include::{esql-specs}/convert.csv-spec[tag=castToTimeDuration-result]
+|===
+

+ 47 - 0
docs/reference/esql/functions/kibana/definition/to_dateperiod.json

@@ -0,0 +1,47 @@
+{
+  "comment" : "This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.",
+  "type" : "eval",
+  "name" : "to_dateperiod",
+  "description" : "Converts an input value into a `date_period` value.",
+  "signatures" : [
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "date_period",
+          "optional" : false,
+          "description" : "Input value. The input is a valid constant date period expression."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "date_period"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "keyword",
+          "optional" : false,
+          "description" : "Input value. The input is a valid constant date period expression."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "date_period"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "text",
+          "optional" : false,
+          "description" : "Input value. The input is a valid constant date period expression."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "date_period"
+    }
+  ],
+  "examples" : [
+    "row x = \"2024-01-01\"::datetime | eval y = x + \"3 DAYS\"::date_period, z = x - to_dateperiod(\"3 days\");"
+  ]
+}

+ 47 - 0
docs/reference/esql/functions/kibana/definition/to_timeduration.json

@@ -0,0 +1,47 @@
+{
+  "comment" : "This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.",
+  "type" : "eval",
+  "name" : "to_timeduration",
+  "description" : "Converts an input value into a `time_duration` value.",
+  "signatures" : [
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "keyword",
+          "optional" : false,
+          "description" : "Input value. The input is a valid constant time duration expression."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "time_duration"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "text",
+          "optional" : false,
+          "description" : "Input value. The input is a valid constant time duration expression."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "time_duration"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "time_duration",
+          "optional" : false,
+          "description" : "Input value. The input is a valid constant time duration expression."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "time_duration"
+    }
+  ],
+  "examples" : [
+    "row x = \"2024-01-01\"::datetime | eval y = x + \"3 hours\"::time_duration, z = x - to_timeduration(\"3 hours\");"
+  ]
+}

+ 10 - 0
docs/reference/esql/functions/kibana/docs/to_dateperiod.md

@@ -0,0 +1,10 @@
+<!--
+This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.
+-->
+
+### TO_DATEPERIOD
+Converts an input value into a `date_period` value.
+
+```
+row x = "2024-01-01"::datetime | eval y = x + "3 DAYS"::date_period, z = x - to_dateperiod("3 days");
+```

+ 10 - 0
docs/reference/esql/functions/kibana/docs/to_timeduration.md

@@ -0,0 +1,10 @@
+<!--
+This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.
+-->
+
+### TO_TIMEDURATION
+Converts an input value into a `time_duration` value.
+
+```
+row x = "2024-01-01"::datetime | eval y = x + "3 hours"::time_duration, z = x - to_timeduration("3 hours");
+```

+ 2 - 0
docs/reference/esql/functions/kibana/inline_cast.json

@@ -3,6 +3,7 @@
   "boolean" : "to_boolean",
   "cartesian_point" : "to_cartesianpoint",
   "cartesian_shape" : "to_cartesianshape",
+  "date_period" : "to_dateperiod",
   "datetime" : "to_datetime",
   "double" : "to_double",
   "geo_point" : "to_geopoint",
@@ -14,6 +15,7 @@
   "long" : "to_long",
   "string" : "to_string",
   "text" : "to_string",
+  "time_duration" : "to_timeduration",
   "unsigned_long" : "to_unsigned_long",
   "version" : "to_version"
 }

+ 15 - 0
docs/reference/esql/functions/layout/to_dateperiod.asciidoc

@@ -0,0 +1,15 @@
+// This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.
+
+[discrete]
+[[esql-to_dateperiod]]
+=== `TO_DATEPERIOD`
+
+*Syntax*
+
+[.text-center]
+image::esql/functions/signature/to_dateperiod.svg[Embedded,opts=inline]
+
+include::../parameters/to_dateperiod.asciidoc[]
+include::../description/to_dateperiod.asciidoc[]
+include::../types/to_dateperiod.asciidoc[]
+include::../examples/to_dateperiod.asciidoc[]

+ 15 - 0
docs/reference/esql/functions/layout/to_timeduration.asciidoc

@@ -0,0 +1,15 @@
+// This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.
+
+[discrete]
+[[esql-to_timeduration]]
+=== `TO_TIMEDURATION`
+
+*Syntax*
+
+[.text-center]
+image::esql/functions/signature/to_timeduration.svg[Embedded,opts=inline]
+
+include::../parameters/to_timeduration.asciidoc[]
+include::../description/to_timeduration.asciidoc[]
+include::../types/to_timeduration.asciidoc[]
+include::../examples/to_timeduration.asciidoc[]

+ 6 - 0
docs/reference/esql/functions/parameters/to_dateperiod.asciidoc

@@ -0,0 +1,6 @@
+// This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.
+
+*Parameters*
+
+`field`::
+Input value. The input is a valid constant date period expression.

+ 6 - 0
docs/reference/esql/functions/parameters/to_timeduration.asciidoc

@@ -0,0 +1,6 @@
+// This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.
+
+*Parameters*
+
+`field`::
+Input value. The input is a valid constant time duration expression.

+ 1 - 0
docs/reference/esql/functions/signature/to_dateperiod.svg

@@ -0,0 +1 @@
+<svg version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="360" height="46" viewbox="0 0 360 46"><defs><style type="text/css">#guide .c{fill:none;stroke:#222222;}#guide .k{fill:#000000;font-family:Roboto Mono,Sans-serif;font-size:20px;}#guide .s{fill:#e4f4ff;stroke:#222222;}#guide .syn{fill:#8D8D8D;font-family:Roboto Mono,Sans-serif;font-size:20px;}</style></defs><path class="c" d="M0 31h5m176 0h10m32 0h10m80 0h10m32 0h5"/><rect class="s" x="5" y="5" width="176" height="36"/><text class="k" x="15" y="31">TO_DATEPERIOD</text><rect class="s" x="191" y="5" width="32" height="36" rx="7"/><text class="syn" x="201" y="31">(</text><rect class="s" x="233" y="5" width="80" height="36" rx="7"/><text class="k" x="243" y="31">field</text><rect class="s" x="323" y="5" width="32" height="36" rx="7"/><text class="syn" x="333" y="31">)</text></svg>

+ 1 - 0
docs/reference/esql/functions/signature/to_timeduration.svg

@@ -0,0 +1 @@
+<svg version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="384" height="46" viewbox="0 0 384 46"><defs><style type="text/css">#guide .c{fill:none;stroke:#222222;}#guide .k{fill:#000000;font-family:Roboto Mono,Sans-serif;font-size:20px;}#guide .s{fill:#e4f4ff;stroke:#222222;}#guide .syn{fill:#8D8D8D;font-family:Roboto Mono,Sans-serif;font-size:20px;}</style></defs><path class="c" d="M0 31h5m200 0h10m32 0h10m80 0h10m32 0h5"/><rect class="s" x="5" y="5" width="200" height="36"/><text class="k" x="15" y="31">TO_TIMEDURATION</text><rect class="s" x="215" y="5" width="32" height="36" rx="7"/><text class="syn" x="225" y="31">(</text><rect class="s" x="257" y="5" width="80" height="36" rx="7"/><text class="k" x="267" y="31">field</text><rect class="s" x="347" y="5" width="32" height="36" rx="7"/><text class="syn" x="357" y="31">)</text></svg>

+ 4 - 0
docs/reference/esql/functions/type-conversion-functions.asciidoc

@@ -16,6 +16,7 @@
 * <<esql-to_boolean>>
 * <<esql-to_cartesianpoint>>
 * <<esql-to_cartesianshape>>
+* experimental:[] <<esql-to_dateperiod>>
 * <<esql-to_datetime>>
 * <<esql-to_degrees>>
 * <<esql-to_double>>
@@ -26,6 +27,7 @@
 * <<esql-to_long>>
 * <<esql-to_radians>>
 * <<esql-to_string>>
+* experimental:[] <<esql-to_timeduration>>
 * experimental:[] <<esql-to_unsigned_long>>
 * <<esql-to_version>>
 // end::type_list[]
@@ -33,6 +35,7 @@
 include::layout/to_boolean.asciidoc[]
 include::layout/to_cartesianpoint.asciidoc[]
 include::layout/to_cartesianshape.asciidoc[]
+include::layout/to_dateperiod.asciidoc[]
 include::layout/to_datetime.asciidoc[]
 include::layout/to_degrees.asciidoc[]
 include::layout/to_double.asciidoc[]
@@ -43,5 +46,6 @@ include::layout/to_ip.asciidoc[]
 include::layout/to_long.asciidoc[]
 include::layout/to_radians.asciidoc[]
 include::layout/to_string.asciidoc[]
+include::layout/to_timeduration.asciidoc[]
 include::layout/to_unsigned_long.asciidoc[]
 include::layout/to_version.asciidoc[]

+ 11 - 0
docs/reference/esql/functions/types/to_dateperiod.asciidoc

@@ -0,0 +1,11 @@
+// This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.
+
+*Supported types*
+
+[%header.monospaced.styled,format=dsv,separator=|]
+|===
+field | result
+date_period | date_period
+keyword | date_period
+text | date_period
+|===

+ 11 - 0
docs/reference/esql/functions/types/to_timeduration.asciidoc

@@ -0,0 +1,11 @@
+// This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.
+
+*Supported types*
+
+[%header.monospaced.styled,format=dsv,separator=|]
+|===
+field | result
+keyword | time_duration
+text | time_duration
+time_duration | time_duration
+|===

+ 35 - 0
x-pack/plugin/esql/qa/server/src/main/java/org/elasticsearch/xpack/esql/qa/rest/RestEsqlTestCase.java

@@ -609,6 +609,41 @@ public abstract class RestEsqlTestCase extends ESRestTestCase {
         assertThat(EntityUtils.toString(re.getResponse().getEntity()), containsString("Unknown query parameter [n0], did you mean [n1]"));
     }
 
+    public void testErrorMessageForInvalidIntervalParams() throws IOException {
+        ResponseException re = expectThrows(
+            ResponseException.class,
+            () -> runEsqlSync(
+                requestObjectBuilder().query("row x = ?n1::datetime | eval y = x + ?n2::time_duration")
+                    .params("[{\"n1\": \"2024-01-01\"}, {\"n2\": \"3 days\"}]")
+            )
+        );
+
+        String error = re.getMessage().replaceAll("\\\\\n\s+\\\\", "");
+        assertThat(
+            error,
+            containsString(
+                "Invalid interval value in [?n2::time_duration], expected integer followed by one of "
+                    + "[MILLISECOND, MILLISECONDS, MS, SECOND, SECONDS, SEC, S, MINUTE, MINUTES, MIN, HOUR, HOURS, H] but got [3 days]"
+            )
+        );
+
+        re = expectThrows(
+            ResponseException.class,
+            () -> runEsqlSync(
+                requestObjectBuilder().query("row x = ?n1::datetime | eval y = x - ?n2::date_period")
+                    .params("[{\"n1\": \"2024-01-01\"}, {\"n2\": \"3 hours\"}]")
+            )
+        );
+        error = re.getMessage().replaceAll("\\\\\n\s+\\\\", "");
+        assertThat(
+            error,
+            containsString(
+                "Invalid interval value in [?n2::date_period], expected integer followed by one of "
+                    + "[DAY, DAYS, D, WEEK, WEEKS, W, MONTH, MONTHS, MO, QUARTER, QUARTERS, Q, YEAR, YEARS, YR, Y] but got [3 hours]"
+            )
+        );
+    }
+
     public void testErrorMessageForLiteralDateMathOverflow() throws IOException {
         List<String> dateMathOverflowExpressions = List.of(
             "2147483647 day + 1 day",

+ 137 - 0
x-pack/plugin/esql/qa/testFixtures/src/main/resources/convert.csv-spec

@@ -214,3 +214,140 @@ from employees
 emp_no:integer | languages:integer | height:double
 10037          | 2                 | 2.0
 ;
+
+convertToDatePeriod
+required_capability: cast_string_literal_to_temporal_amount
+//tag::castToDatePeriod[]
+row x = "2024-01-01"::datetime | eval y = x + "3 DAYS"::date_period, z = x - to_dateperiod("3 days");
+//end::castToDatePeriod[]
+
+//tag::castToDatePeriod-result[]
+x:datetime |y:datetime |z:datetime
+2024-01-01 |2024-01-04 |2023-12-29
+//end::castToDatePeriod-result[]
+;
+
+convertToTimeDuration
+required_capability: cast_string_literal_to_temporal_amount
+//tag::castToTimeDuration[]
+row x = "2024-01-01"::datetime | eval y = x + "3 hours"::time_duration, z = x - to_timeduration("3 hours");
+//end::castToTimeDuration[]
+
+//tag::castToTimeDuration-result[]
+x:datetime |y:datetime               |z:datetime
+2024-01-01 |2024-01-01T03:00:00.000Z |2023-12-31T21:00:00.000Z
+//end::castToTimeDuration-result[]
+;
+
+convertToDatePeriodTimeDuration
+required_capability: cast_string_literal_to_temporal_amount
+row x = "2024-01-01"::datetime + "3 hours"::time_duration, y = "2024-01-01"::datetime - to_timeduration("3 hours"),
+z = "2024-01-01"::datetime + "3 DAYS"::date_period, w = "2024-01-01"::datetime - to_dateperiod("3 days"), u = "3 days",
+v = "3 hours"
+| eval a = "2024-01-01" + u::date_period, b = "2024-01-01" - v::time_duration
+| keep x, y, z, w, a, b;
+
+x:datetime               |y:datetime               |z:datetime               |w:datetime               |a:datetime               |b:datetime
+2024-01-01T03:00:00.000Z |2023-12-31T21:00:00.000Z |2024-01-04T00:00:00.000Z |2023-12-29T00:00:00.000Z |2024-01-04T00:00:00.000Z |2023-12-31T21:00:00.000Z
+;
+
+convertToDatePeriodNested
+required_capability: cast_string_literal_to_temporal_amount
+row x = "2024-01-01"::datetime
+| eval y = x + to_dateperiod("3 days"::date_period)
+;
+
+x:datetime |y:datetime
+2024-01-01 |2024-01-04
+;
+
+convertToTimeDurationNested
+required_capability: cast_string_literal_to_temporal_amount
+row x = "2024-01-01"::datetime
+| eval y = x + to_timeduration("3 hours"::time_duration)
+;
+
+x:datetime |y:datetime
+2024-01-01 |2024-01-01T03:00:00.000Z
+;
+
+convertToDatePeriodFromIndex
+required_capability: cast_string_literal_to_temporal_amount
+FROM employees
+| WHERE emp_no == 10001
+| EVAL x = birth_date + "3 days"::date_period, y = birth_date - to_dateperiod("3 days")
+| KEEP birth_date, x, y;
+
+birth_date:datetime  |x:datetime           |y:datetime
+1953-09-02T00:00:00Z |1953-09-05T00:00:00Z |1953-08-30T00:00:00Z
+;
+
+convertToTimeDurationFromIndex
+required_capability: cast_string_literal_to_temporal_amount
+FROM employees
+| WHERE emp_no == 10001
+| EVAL x = birth_date + "3 hours"::time_duration, y = birth_date - to_timeduration("3 hours")
+| KEEP birth_date, x, y;
+
+birth_date:datetime  |x:datetime           |y:datetime
+1953-09-02T00:00:00Z |1953-09-02T03:00:00Z |1953-09-01T21:00:00Z
+;
+
+convertToDatePeriodTimeDurationRef
+required_capability: cast_string_literal_to_temporal_amount
+FROM employees
+| WHERE emp_no == 10001
+| EVAL interval_timeduration = "3 hours", x = birth_date + interval_timeduration::time_duration, y = birth_date - concat("3 ", "hours")::time_duration
+| EVAL interval_dateperiod = "3 months", z = birth_date + interval_dateperiod::date_period, w = birth_date - concat("3 ", "months")::date_period
+| EVAL a = "3", b = "hours", c = birth_date + concat(concat(a, " "), b)::time_duration
+| KEEP birth_date, x, y, z, w, c;
+
+birth_date:datetime  |x:datetime           |y:datetime           |z:datetime           |w:datetime           |c:datetime
+1953-09-02T00:00:00Z |1953-09-02T03:00:00Z |1953-09-01T21:00:00Z |1953-12-02T00:00:00Z |1953-06-02T00:00:00Z |1953-09-02T03:00:00Z
+;
+
+convertToDatePeriodNull
+required_capability: cast_string_literal_to_temporal_amount
+FROM employees
+| WHERE emp_no == 10001
+| EVAL x = birth_date + null::date_period, y = birth_date - to_dateperiod(null), z = birth_date + to_string(null)::date_period
+| KEEP birth_date, x, y, z;
+
+birth_date:datetime  |x:datetime           |y:datetime           |z:datetime
+1953-09-02T00:00:00Z |null                 |null                 |null
+;
+
+convertToTimeDurationNull
+required_capability: cast_string_literal_to_temporal_amount
+FROM employees
+| WHERE emp_no == 10001
+| EVAL x = birth_date + null::time_duration, y = birth_date - to_timeduration(null), z = birth_date + to_string(null)::time_duration
+| KEEP birth_date, x, y, z;
+
+birth_date:datetime  |x:datetime           |y:datetime           |z:datetime
+1953-09-02T00:00:00Z |null                 |null                 |null
+;
+
+convertToDatePeriodIntegerLiteral
+required_capability: cast_string_literal_to_temporal_amount
+FROM employees
+| WHERE emp_no == 10001
+| EVAL x = birth_date + 3 days::date_period, y = birth_date - to_dateperiod(3 days),
+z = birth_date + 3 months + 3 days::date_period, w = birth_date + (3 months + 3 days)::date_period
+| KEEP birth_date, x, y, z, w;
+
+birth_date:datetime  |x:datetime           |y:datetime           |z:datetime           |w:datetime
+1953-09-02T00:00:00Z |1953-09-05T00:00:00Z |1953-08-30T00:00:00Z |1953-12-05T00:00:00Z |1953-12-05T00:00:00Z
+;
+
+convertToTimeDurationIntegerLiteral
+required_capability: cast_string_literal_to_temporal_amount
+FROM employees
+| WHERE emp_no == 10001
+| EVAL x = birth_date + 3 hours::time_duration, y = birth_date - to_timeduration(3 hours),
+z = birth_date + 3 hours + 3 minutes::time_duration, w = birth_date + (3 hours + 3 minutes)::time_duration
+| KEEP birth_date, x, y, z, w;
+
+birth_date:datetime  |x:datetime           |y:datetime           |z:datetime           |w:datetime
+1953-09-02T00:00:00Z |1953-09-02T03:00:00Z |1953-09-01T21:00:00Z |1953-09-02T03:03:00Z |1953-09-02T03:03:00Z
+;

+ 9 - 1
x-pack/plugin/esql/qa/testFixtures/src/main/resources/meta.csv-spec

@@ -95,6 +95,7 @@ double tau()
 "boolean to_boolean(field:boolean|keyword|text|double|long|unsigned_long|integer)"
 "cartesian_point to_cartesianpoint(field:cartesian_point|keyword|text)"
 "cartesian_shape to_cartesianshape(field:cartesian_point|cartesian_shape|keyword|text)"
+"date_period to_dateperiod(field:date_period|keyword|text)"
 "date to_datetime(field:date|date_nanos|keyword|text|double|long|unsigned_long|integer)"
 "double to_dbl(field:boolean|date|keyword|text|double|long|unsigned_long|integer|counter_double|counter_integer|counter_long)"
 "double to_degrees(number:double|integer|long|unsigned_long)"
@@ -110,6 +111,7 @@ double tau()
 "double to_radians(number:double|integer|long|unsigned_long)"
 "keyword to_str(field:boolean|cartesian_point|cartesian_shape|date|date_nanos|double|geo_point|geo_shape|integer|ip|keyword|long|text|unsigned_long|version)"
 "keyword to_string(field:boolean|cartesian_point|cartesian_shape|date|date_nanos|double|geo_point|geo_shape|integer|ip|keyword|long|text|unsigned_long|version)"
+"time_duration to_timeduration(field:time_duration|keyword|text)"
 "unsigned_long to_ul(field:boolean|date|keyword|text|double|long|unsigned_long|integer)"
 "unsigned_long to_ulong(field:boolean|date|keyword|text|double|long|unsigned_long|integer)"
 "unsigned_long to_unsigned_long(field:boolean|date|keyword|text|double|long|unsigned_long|integer)"
@@ -221,6 +223,7 @@ to_bool       |field                               |"boolean|keyword|text|double
 to_boolean    |field                               |"boolean|keyword|text|double|long|unsigned_long|integer"                                                                          |Input value. The input can be a single- or multi-valued column or an expression.
 to_cartesianpo|field                               |"cartesian_point|keyword|text"                                                                                                    |Input value. The input can be a single- or multi-valued column or an expression.
 to_cartesiansh|field                               |"cartesian_point|cartesian_shape|keyword|text"                                                                                    |Input value. The input can be a single- or multi-valued column or an expression.
+to_dateperiod |field                               |"date_period|keyword|text"                                                                                                        |Input value. The input is a valid constant date period expression.
 to_datetime   |field                               |"date|date_nanos|keyword|text|double|long|unsigned_long|integer"                                                                             |Input value. The input can be a single- or multi-valued column or an expression.
 to_dbl        |field                               |"boolean|date|keyword|text|double|long|unsigned_long|integer|counter_double|counter_integer|counter_long"                         |Input value. The input can be a single- or multi-valued column or an expression.
 to_degrees    |number                              |"double|integer|long|unsigned_long"                                                                                               |Input value. The input can be a single- or multi-valued column or an expression.
@@ -236,6 +239,7 @@ to_lower      |str                                 |"keyword|text"
 to_radians    |number                              |"double|integer|long|unsigned_long"                                                                                               |Input value. The input can be a single- or multi-valued column or an expression.
 to_str        |field                               |"boolean|cartesian_point|cartesian_shape|date|date_nanos|double|geo_point|geo_shape|integer|ip|keyword|long|text|unsigned_long|version"      |Input value. The input can be a single- or multi-valued column or an expression.
 to_string     |field                               |"boolean|cartesian_point|cartesian_shape|date|date_nanos|double|geo_point|geo_shape|integer|ip|keyword|long|text|unsigned_long|version"      |Input value. The input can be a single- or multi-valued column or an expression.
+to_timeduratio|field                               |"time_duration|keyword|text"                                                                                                      |Input value. The input is a valid constant time duration expression.
 to_ul         |field                               |"boolean|date|keyword|text|double|long|unsigned_long|integer"                                                                     |Input value. The input can be a single- or multi-valued column or an expression.
 to_ulong      |field                               |"boolean|date|keyword|text|double|long|unsigned_long|integer"                                                                     |Input value. The input can be a single- or multi-valued column or an expression.
 to_unsigned_lo|field                               |"boolean|date|keyword|text|double|long|unsigned_long|integer"                                                                     |Input value. The input can be a single- or multi-valued column or an expression.
@@ -347,6 +351,7 @@ to_bool       |Converts an input value to a boolean value. A string value of *tr
 to_boolean    |Converts an input value to a boolean value. A string value of *true* will be case-insensitive converted to the Boolean *true*. For anything else, including the empty string, the function will return *false*. The numerical value of *0* will be converted to *false*, anything else will be converted to *true*.
 to_cartesianpo|Converts an input value to a `cartesian_point` value. A string will only be successfully converted if it respects the {wikipedia}/Well-known_text_representation_of_geometry[WKT Point] format.
 to_cartesiansh|Converts an input value to a `cartesian_shape` value. A string will only be successfully converted if it respects the {wikipedia}/Well-known_text_representation_of_geometry[WKT] format.
+to_dateperiod |Converts an input value into a `date_period` value.
 to_datetime   |Converts an input value to a date value. A string will only be successfully converted if it's respecting the format `yyyy-MM-dd'T'HH:mm:ss.SSS'Z'`. To convert dates in other formats, use <<esql-date_parse>>.
 to_dbl        |Converts an input value to a double value. If the input parameter is of a date type, its value will be interpreted as milliseconds since the {wikipedia}/Unix_time[Unix epoch], converted to double. Boolean *true* will be converted to double *1.0*, *false* to *0.0*.
 to_degrees    |Converts a number in {wikipedia}/Radian[radians] to {wikipedia}/Degree_(angle)[degrees].
@@ -362,6 +367,7 @@ to_lower      |Returns a new string representing the input string converted to l
 to_radians    |Converts a number in {wikipedia}/Degree_(angle)[degrees] to {wikipedia}/Radian[radians].
 to_str        |Converts an input value into a string.
 to_string     |Converts an input value into a string.
+to_timeduratio|Converts an input value into a `time_duration` value.
 to_ul         |Converts an input value to an unsigned long value. If the input parameter is of a date type, its value will be interpreted as milliseconds since the {wikipedia}/Unix_time[Unix epoch], converted to unsigned long. Boolean *true* will be converted to unsigned long *1*, *false* to *0*.
 to_ulong      |Converts an input value to an unsigned long value. If the input parameter is of a date type, its value will be interpreted as milliseconds since the {wikipedia}/Unix_time[Unix epoch], converted to unsigned long. Boolean *true* will be converted to unsigned long *1*, *false* to *0*.
 to_unsigned_lo|Converts an input value to an unsigned long value. If the input parameter is of a date type, its value will be interpreted as milliseconds since the {wikipedia}/Unix_time[Unix epoch], converted to unsigned long. Boolean *true* will be converted to unsigned long *1*, *false* to *0*.
@@ -475,6 +481,7 @@ to_bool       |boolean
 to_boolean    |boolean                                                                                                                     |false                       |false           |false
 to_cartesianpo|cartesian_point                                                                                                             |false                       |false           |false
 to_cartesiansh|cartesian_shape                                                                                                             |false                       |false           |false
+to_dateperiod |date_period                                                                                                                 |false                       |false           |false
 to_datetime   |date                                                                                                                        |false                       |false           |false
 to_dbl        |double                                                                                                                      |false                       |false           |false
 to_degrees    |double                                                                                                                      |false                       |false           |false
@@ -490,6 +497,7 @@ to_lower      |"keyword|text"
 to_radians    |double                                                                                                                      |false                       |false           |false
 to_str        |keyword                                                                                                                     |false                       |false           |false
 to_string     |keyword                                                                                                                     |false                       |false           |false
+to_timeduratio|time_duration                                                                                                               |false                       |false           |false
 to_ul         |unsigned_long                                                                                                               |false                       |false           |false
 to_ulong      |unsigned_long                                                                                                               |false                       |false           |false
 to_unsigned_lo|unsigned_long                                                                                                               |false                       |false           |false
@@ -516,5 +524,5 @@ countFunctions#[skip:-8.15.99]
 meta functions |  stats  a = count(*), b = count(*), c = count(*) |  mv_expand c;
 
 a:long | b:long | c:long
-117    | 117    | 117
+119    | 119    | 119
 ;

+ 6 - 1
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java

@@ -289,7 +289,12 @@ public class EsqlCapabilities {
         /**
          * Support for requesting the "SPACE" function.
          */
-        SPACE;
+        SPACE,
+
+        /**
+         * Support explicit casting from string literal to DATE_PERIOD or TIME_DURATION.
+         */
+        CAST_STRING_LITERAL_TO_TEMPORAL_AMOUNT;
 
         private final boolean snapshotOnly;
         private final FeatureFlag featureFlag;

+ 6 - 1
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/analysis/Verifier.java

@@ -394,7 +394,12 @@ public class Verifier {
                 DataType dataType = field.dataType();
                 if (DataType.isRepresentable(dataType) == false) {
                     failures.add(
-                        fail(field, "EVAL does not support type [{}] in expression [{}]", dataType.typeName(), field.child().sourceText())
+                        fail(
+                            field,
+                            "EVAL does not support type [{}] as the return data type of expression [{}]",
+                            dataType.typeName(),
+                            field.child().sourceText()
+                        )
                     );
                 }
                 // check no aggregate functions are used

+ 4 - 0
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/EsqlFunctionRegistry.java

@@ -41,6 +41,7 @@ import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToBase64;
 import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToBoolean;
 import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToCartesianPoint;
 import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToCartesianShape;
+import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToDatePeriod;
 import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToDatetime;
 import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToDegrees;
 import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToDouble;
@@ -51,6 +52,7 @@ import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToInteger
 import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToLong;
 import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToRadians;
 import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToString;
+import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToTimeDuration;
 import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToUnsignedLong;
 import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToVersion;
 import org.elasticsearch.xpack.esql.expression.function.scalar.date.DateDiff;
@@ -343,6 +345,7 @@ public class EsqlFunctionRegistry {
                 def(ToBoolean.class, ToBoolean::new, "to_boolean", "to_bool"),
                 def(ToCartesianPoint.class, ToCartesianPoint::new, "to_cartesianpoint"),
                 def(ToCartesianShape.class, ToCartesianShape::new, "to_cartesianshape"),
+                def(ToDatePeriod.class, ToDatePeriod::new, "to_dateperiod"),
                 def(ToDatetime.class, ToDatetime::new, "to_datetime", "to_dt"),
                 def(ToDegrees.class, ToDegrees::new, "to_degrees"),
                 def(ToDouble.class, ToDouble::new, "to_double", "to_dbl"),
@@ -353,6 +356,7 @@ public class EsqlFunctionRegistry {
                 def(ToLong.class, ToLong::new, "to_long"),
                 def(ToRadians.class, ToRadians::new, "to_radians"),
                 def(ToString.class, ToString::new, "to_string", "to_str"),
+                def(ToTimeDuration.class, ToTimeDuration::new, "to_timeduration"),
                 def(ToUnsignedLong.class, ToUnsignedLong::new, "to_unsigned_long", "to_ulong", "to_ul"),
                 def(ToVersion.class, ToVersion::new, "to_version", "to_ver"), },
             // multivalue functions

+ 1 - 1
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/convert/AbstractConvertFunction.java

@@ -72,7 +72,7 @@ public abstract class AbstractConvertFunction extends UnaryScalarFunction {
     }
 
     @Override
-    protected final TypeResolution resolveType() {
+    protected TypeResolution resolveType() {
         if (childrenResolved() == false) {
             return new TypeResolution("Unresolved children");
         }

+ 75 - 0
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/convert/FoldablesConvertFunction.java

@@ -0,0 +1,75 @@
+/*
+ * 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.convert;
+
+import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.xpack.esql.capabilities.Validatable;
+import org.elasticsearch.xpack.esql.common.Failures;
+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 java.util.Locale;
+import java.util.Map;
+
+import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.isType;
+import static org.elasticsearch.xpack.esql.core.type.DataType.isString;
+import static org.elasticsearch.xpack.esql.expression.Validations.isFoldable;
+import static org.elasticsearch.xpack.esql.type.EsqlDataTypeConverter.foldToTemporalAmount;
+
+/**
+ * Base class for functions that converts a constant into an interval type - DATE_PERIOD or TIME_DURATION.
+ * The functions will be folded at the end of LogicalPlanOptimizer by the coordinator, it does not reach data node.
+ */
+public abstract class FoldablesConvertFunction extends AbstractConvertFunction implements Validatable {
+
+    protected FoldablesConvertFunction(Source source, Expression field) {
+        super(source, field);
+    }
+
+    @Override
+    public final void writeTo(StreamOutput out) {
+        throw new UnsupportedOperationException("not serialized");
+    }
+
+    @Override
+    public final String getWriteableName() {
+        throw new UnsupportedOperationException("not serialized");
+    }
+
+    @Override
+    protected final TypeResolution resolveType() {
+        if (childrenResolved() == false) {
+            return new TypeResolution("Unresolved children");
+        }
+        return isType(
+            field(),
+            dt -> isString(dt) || dt == dataType(),
+            sourceText(),
+            null,
+            false,
+            dataType().typeName().toLowerCase(Locale.ROOT) + " or string"
+        );
+    }
+
+    @Override
+    protected final Map<DataType, BuildFactory> factories() {
+        // TODO if a union type field is provided as an input, the correct error message is not shown, #112668 is a follow up
+        return Map.of();
+    }
+
+    @Override
+    public final Object fold() {
+        return foldToTemporalAmount(field(), sourceText(), dataType());
+    }
+
+    @Override
+    public final void validate(Failures failures) {
+        failures.add(isFoldable(field(), sourceText(), null));
+    }
+}

+ 54 - 0
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/convert/ToDatePeriod.java

@@ -0,0 +1,54 @@
+/*
+ * 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.convert;
+
+import org.elasticsearch.xpack.esql.core.expression.Expression;
+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.function.Example;
+import org.elasticsearch.xpack.esql.expression.function.FunctionInfo;
+import org.elasticsearch.xpack.esql.expression.function.Param;
+
+import java.util.List;
+
+import static org.elasticsearch.xpack.esql.core.type.DataType.DATE_PERIOD;
+
+public class ToDatePeriod extends FoldablesConvertFunction {
+
+    @FunctionInfo(
+        returnType = "date_period",
+        description = "Converts an input value into a `date_period` value.",
+        examples = @Example(file = "convert", tag = "castToDatePeriod")
+    )
+    public ToDatePeriod(
+        Source source,
+        @Param(
+            name = "field",
+            type = { "date_period", "keyword", "text" },
+            description = "Input value. The input is a valid constant date period expression."
+        ) Expression v
+    ) {
+        super(source, v);
+    }
+
+    @Override
+    public DataType dataType() {
+        return DATE_PERIOD;
+    }
+
+    @Override
+    public Expression replaceChildren(List<Expression> newChildren) {
+        return new ToDatePeriod(source(), newChildren.get(0));
+    }
+
+    @Override
+    protected NodeInfo<? extends Expression> info() {
+        return NodeInfo.create(this, ToDatePeriod::new, field());
+    }
+}

+ 54 - 0
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/convert/ToTimeDuration.java

@@ -0,0 +1,54 @@
+/*
+ * 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.convert;
+
+import org.elasticsearch.xpack.esql.core.expression.Expression;
+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.function.Example;
+import org.elasticsearch.xpack.esql.expression.function.FunctionInfo;
+import org.elasticsearch.xpack.esql.expression.function.Param;
+
+import java.util.List;
+
+import static org.elasticsearch.xpack.esql.core.type.DataType.TIME_DURATION;
+
+public class ToTimeDuration extends FoldablesConvertFunction {
+
+    @FunctionInfo(
+        returnType = "time_duration",
+        description = "Converts an input value into a `time_duration` value.",
+        examples = @Example(file = "convert", tag = "castToTimeDuration")
+    )
+    public ToTimeDuration(
+        Source source,
+        @Param(
+            name = "field",
+            type = { "time_duration", "keyword", "text" },
+            description = "Input value. The input is a valid constant time duration expression."
+        ) Expression v
+    ) {
+        super(source, v);
+    }
+
+    @Override
+    public DataType dataType() {
+        return TIME_DURATION;
+    }
+
+    @Override
+    public Expression replaceChildren(List<Expression> newChildren) {
+        return new ToTimeDuration(source(), newChildren.get(0));
+    }
+
+    @Override
+    protected NodeInfo<? extends Expression> info() {
+        return NodeInfo.create(this, ToTimeDuration::new, field());
+    }
+}

+ 2 - 2
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/ExpressionBuilder.java

@@ -83,7 +83,7 @@ import static org.elasticsearch.xpack.esql.parser.ParserUtils.source;
 import static org.elasticsearch.xpack.esql.parser.ParserUtils.typedParsing;
 import static org.elasticsearch.xpack.esql.parser.ParserUtils.visitList;
 import static org.elasticsearch.xpack.esql.type.EsqlDataTypeConverter.bigIntegerToUnsignedLong;
-import static org.elasticsearch.xpack.esql.type.EsqlDataTypeConverter.parseTemporalAmout;
+import static org.elasticsearch.xpack.esql.type.EsqlDataTypeConverter.parseTemporalAmount;
 import static org.elasticsearch.xpack.esql.type.EsqlDataTypeConverter.stringToIntegral;
 
 public abstract class ExpressionBuilder extends IdentifierBuilder {
@@ -458,7 +458,7 @@ public abstract class ExpressionBuilder extends IdentifierBuilder {
         String qualifier = ctx.UNQUOTED_IDENTIFIER().getText().toLowerCase(Locale.ROOT);
 
         try {
-            TemporalAmount quantity = parseTemporalAmout(value, qualifier, source);
+            TemporalAmount quantity = parseTemporalAmount(value, qualifier, source);
             return new Literal(source, quantity, quantity instanceof Duration ? TIME_DURATION : DATE_PERIOD);
         } catch (InvalidArgumentException | ArithmeticException e) {
             // the range varies by unit: Duration#ofMinutes(), #ofHours() will Math#multiplyExact() to reduce the unit to seconds;

+ 130 - 18
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/type/EsqlDataTypeConverter.java

@@ -10,12 +10,14 @@ package org.elasticsearch.xpack.esql.type;
 import org.apache.lucene.util.BytesRef;
 import org.elasticsearch.common.io.stream.StreamInput;
 import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.common.logging.LoggerMessageFormat;
 import org.elasticsearch.common.lucene.BytesRefs;
 import org.elasticsearch.common.time.DateFormatter;
 import org.elasticsearch.search.DocValueFormat;
 import org.elasticsearch.xpack.esql.core.InvalidArgumentException;
 import org.elasticsearch.xpack.esql.core.QlIllegalArgumentException;
 import org.elasticsearch.xpack.esql.core.expression.Expression;
+import org.elasticsearch.xpack.esql.core.expression.Expressions;
 import org.elasticsearch.xpack.esql.core.tree.Source;
 import org.elasticsearch.xpack.esql.core.type.Converter;
 import org.elasticsearch.xpack.esql.core.type.DataType;
@@ -26,6 +28,7 @@ import org.elasticsearch.xpack.esql.expression.function.scalar.convert.AbstractC
 import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToBoolean;
 import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToCartesianPoint;
 import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToCartesianShape;
+import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToDatePeriod;
 import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToDatetime;
 import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToDouble;
 import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToGeoPoint;
@@ -34,6 +37,7 @@ import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToIP;
 import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToInteger;
 import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToLong;
 import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToString;
+import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToTimeDuration;
 import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToUnsignedLong;
 import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToVersion;
 import org.elasticsearch.xpack.esql.parser.ParsingException;
@@ -48,6 +52,7 @@ import java.time.ZoneId;
 import java.time.temporal.ChronoField;
 import java.time.temporal.TemporalAccessor;
 import java.time.temporal.TemporalAmount;
+import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import java.util.function.BiFunction;
@@ -110,9 +115,83 @@ public class EsqlDataTypeConverter {
         entry(KEYWORD, ToString::new),
         entry(TEXT, ToString::new),
         entry(UNSIGNED_LONG, ToUnsignedLong::new),
-        entry(VERSION, ToVersion::new)
+        entry(VERSION, ToVersion::new),
+        entry(DATE_PERIOD, ToDatePeriod::new),
+        entry(TIME_DURATION, ToTimeDuration::new)
     );
 
+    public enum INTERVALS {
+        // TIME_DURATION,
+        MILLISECOND,
+        MILLISECONDS,
+        MS,
+        SECOND,
+        SECONDS,
+        SEC,
+        S,
+        MINUTE,
+        MINUTES,
+        MIN,
+        HOUR,
+        HOURS,
+        H,
+        // DATE_PERIOD
+        DAY,
+        DAYS,
+        D,
+        WEEK,
+        WEEKS,
+        W,
+        MONTH,
+        MONTHS,
+        MO,
+        QUARTER,
+        QUARTERS,
+        Q,
+        YEAR,
+        YEARS,
+        YR,
+        Y;
+    }
+
+    public static List<INTERVALS> TIME_DURATIONS = List.of(
+        INTERVALS.MILLISECOND,
+        INTERVALS.MILLISECONDS,
+        INTERVALS.MS,
+        INTERVALS.SECOND,
+        INTERVALS.SECONDS,
+        INTERVALS.SEC,
+        INTERVALS.S,
+        INTERVALS.MINUTE,
+        INTERVALS.MINUTES,
+        INTERVALS.MIN,
+        INTERVALS.HOUR,
+        INTERVALS.HOURS,
+        INTERVALS.H
+    );
+
+    public static List<INTERVALS> DATE_PERIODS = List.of(
+        INTERVALS.DAY,
+        INTERVALS.DAYS,
+        INTERVALS.D,
+        INTERVALS.WEEK,
+        INTERVALS.WEEKS,
+        INTERVALS.W,
+        INTERVALS.MONTH,
+        INTERVALS.MONTHS,
+        INTERVALS.MO,
+        INTERVALS.QUARTER,
+        INTERVALS.QUARTERS,
+        INTERVALS.Q,
+        INTERVALS.YEAR,
+        INTERVALS.YEARS,
+        INTERVALS.YR,
+        INTERVALS.Y
+    );
+
+    public static final String INVALID_INTERVAL_ERROR =
+        "Invalid interval value in [{}], expected integer followed by one of {} but got [{}]";
+
     public static Converter converterFor(DataType from, DataType to) {
         // TODO move EXPRESSION_TO_LONG here if there is no regression
         if (isString(from)) {
@@ -154,6 +233,38 @@ public class EsqlDataTypeConverter {
         return null;
     }
 
+    public static TemporalAmount foldToTemporalAmount(Expression field, String sourceText, DataType expectedType) {
+        if (field.foldable()) {
+            Object v = field.fold();
+            if (v instanceof BytesRef b) {
+                try {
+                    return EsqlDataTypeConverter.parseTemporalAmount(b.utf8ToString(), expectedType);
+                } catch (ParsingException e) {
+                    throw new IllegalArgumentException(
+                        LoggerMessageFormat.format(
+                            null,
+                            INVALID_INTERVAL_ERROR,
+                            sourceText,
+                            expectedType == DATE_PERIOD ? DATE_PERIODS : TIME_DURATIONS,
+                            b.utf8ToString()
+                        )
+                    );
+                }
+            } else if (v instanceof TemporalAmount t) {
+                return t;
+            }
+        }
+
+        throw new IllegalArgumentException(
+            LoggerMessageFormat.format(
+                null,
+                "argument of [{}] must be a constant, received [{}]",
+                field.sourceText(),
+                Expressions.name(field)
+            )
+        );
+    }
+
     public static TemporalAmount parseTemporalAmount(Object val, DataType expectedType) {
         String errorMessage = "Cannot parse [{}] to {}";
         String str = String.valueOf(val);
@@ -181,7 +292,7 @@ public class EsqlDataTypeConverter {
 
         if ((value.isEmpty() || qualifier.isEmpty()) == false) {
             try {
-                TemporalAmount result = parseTemporalAmout(Integer.parseInt(value.toString()), qualifier.toString(), Source.EMPTY);
+                TemporalAmount result = parseTemporalAmount(Integer.parseInt(value.toString()), qualifier.toString(), Source.EMPTY);
                 if (DataType.DATE_PERIOD == expectedType && result instanceof Period
                     || DataType.TIME_DURATION == expectedType && result instanceof Duration) {
                     return result;
@@ -196,7 +307,6 @@ public class EsqlDataTypeConverter {
                 // wrong pattern
             }
         }
-
         throw new ParsingException(Source.EMPTY, errorMessage, val, expectedType);
     }
 
@@ -284,22 +394,24 @@ public class EsqlDataTypeConverter {
     }
 
     // generally supporting abbreviations from https://en.wikipedia.org/wiki/Unit_of_time
-    public static TemporalAmount parseTemporalAmout(Number value, String qualifier, Source source) throws InvalidArgumentException,
+    public static TemporalAmount parseTemporalAmount(Number value, String qualifier, Source source) throws InvalidArgumentException,
         ArithmeticException, ParsingException {
-        return switch (qualifier) {
-            case "millisecond", "milliseconds", "ms" -> Duration.ofMillis(safeToLong(value));
-            case "second", "seconds", "sec", "s" -> Duration.ofSeconds(safeToLong(value));
-            case "minute", "minutes", "min" -> Duration.ofMinutes(safeToLong(value));
-            case "hour", "hours", "h" -> Duration.ofHours(safeToLong(value));
-
-            case "day", "days", "d" -> Period.ofDays(safeToInt(safeToLong(value)));
-            case "week", "weeks", "w" -> Period.ofWeeks(safeToInt(safeToLong(value)));
-            case "month", "months", "mo" -> Period.ofMonths(safeToInt(safeToLong(value)));
-            case "quarter", "quarters", "q" -> Period.ofMonths(safeToInt(Math.multiplyExact(3L, safeToLong(value))));
-            case "year", "years", "yr", "y" -> Period.ofYears(safeToInt(safeToLong(value)));
-
-            default -> throw new ParsingException(source, "Unexpected time interval qualifier: '{}'", qualifier);
-        };
+        try {
+            return switch (INTERVALS.valueOf(qualifier.toUpperCase(Locale.ROOT))) {
+                case MILLISECOND, MILLISECONDS, MS -> Duration.ofMillis(safeToLong(value));
+                case SECOND, SECONDS, SEC, S -> Duration.ofSeconds(safeToLong(value));
+                case MINUTE, MINUTES, MIN -> Duration.ofMinutes(safeToLong(value));
+                case HOUR, HOURS, H -> Duration.ofHours(safeToLong(value));
+
+                case DAY, DAYS, D -> Period.ofDays(safeToInt(safeToLong(value)));
+                case WEEK, WEEKS, W -> Period.ofWeeks(safeToInt(safeToLong(value)));
+                case MONTH, MONTHS, MO -> Period.ofMonths(safeToInt(safeToLong(value)));
+                case QUARTER, QUARTERS, Q -> Period.ofMonths(safeToInt(Math.multiplyExact(3L, safeToLong(value))));
+                case YEAR, YEARS, YR, Y -> Period.ofYears(safeToInt(safeToLong(value)));
+            };
+        } catch (IllegalArgumentException e) {
+            throw new ParsingException(source, "Unexpected time interval qualifier: '{}'", qualifier);
+        }
     }
 
     /**

+ 233 - 12
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/VerifierTests.java

@@ -44,6 +44,9 @@ public class VerifierTests extends ESTestCase {
     private final Analyzer defaultAnalyzer = AnalyzerTestUtils.expandedDefaultAnalyzer();
     private final Analyzer tsdb = AnalyzerTestUtils.analyzer(AnalyzerTestUtils.tsdbIndexResolution());
 
+    private final List<String> TIME_DURATIONS = List.of("millisecond", "second", "minute", "hour");
+    private final List<String> DATE_PERIODS = List.of("day", "week", "month", "year");
+
     public void testIncompatibleTypesInMathOperation() {
         assertEquals(
             "1:40: second argument of [a + c] must be [datetime or numeric], found value [c] type [keyword]",
@@ -677,42 +680,171 @@ public class VerifierTests extends ESTestCase {
     }
 
     public void testPeriodAndDurationInRowAssignment() {
-        for (var unit : List.of("millisecond", "second", "minute", "hour", "day", "week", "month", "year")) {
+        for (var unit : TIME_DURATIONS) {
             assertEquals("1:5: cannot use [1 " + unit + "] directly in a row assignment", error("row a = 1 " + unit));
+            assertEquals(
+                "1:5: cannot use [1 " + unit + "::time_duration] directly in a row assignment",
+                error("row a = 1 " + unit + "::time_duration")
+            );
+            assertEquals(
+                "1:5: cannot use [\"1 " + unit + "\"::time_duration] directly in a row assignment",
+                error("row a = \"1 " + unit + "\"::time_duration")
+            );
+            assertEquals(
+                "1:5: cannot use [to_timeduration(1 " + unit + ")] directly in a row assignment",
+                error("row a = to_timeduration(1 " + unit + ")")
+            );
+            assertEquals(
+                "1:5: cannot use [to_timeduration(\"1 " + unit + "\")] directly in a row assignment",
+                error("row a = to_timeduration(\"1 " + unit + "\")")
+            );
+        }
+        for (var unit : DATE_PERIODS) {
+            assertEquals("1:5: cannot use [1 " + unit + "] directly in a row assignment", error("row a = 1 " + unit));
+            assertEquals(
+                "1:5: cannot use [1 " + unit + "::date_period] directly in a row assignment",
+                error("row a = 1 " + unit + "::date_period")
+            );
+            assertEquals(
+                "1:5: cannot use [\"1 " + unit + "\"::date_period] directly in a row assignment",
+                error("row a = \"1 " + unit + "\"::date_period")
+            );
+            assertEquals(
+                "1:5: cannot use [to_dateperiod(1 " + unit + ")] directly in a row assignment",
+                error("row a = to_dateperiod(1 " + unit + ")")
+            );
+            assertEquals(
+                "1:5: cannot use [to_dateperiod(\"1 " + unit + "\")] directly in a row assignment",
+                error("row a = to_dateperiod(\"1 " + unit + "\")")
+            );
         }
     }
 
     public void testSubtractDateTimeFromTemporal() {
-        for (var unit : List.of("millisecond", "second", "minute", "hour")) {
+        for (var unit : TIME_DURATIONS) {
             assertEquals(
-                "1:5: [-] arguments are in unsupported order: cannot subtract a [DATETIME] value [now()] from a [TIME_DURATION] amount [1 "
+                "1:5: [-] arguments are in unsupported order: cannot subtract a [DATETIME] value [now()] "
+                    + "from a [TIME_DURATION] amount [1 "
                     + unit
                     + "]",
                 error("row 1 " + unit + " - now() ")
             );
+            assertEquals(
+                "1:5: [-] arguments are in unsupported order: cannot subtract a [DATETIME] value [now()] "
+                    + "from a [TIME_DURATION] amount [1 "
+                    + unit
+                    + "::time_duration]",
+                error("row 1 " + unit + "::time_duration" + " - now() ")
+            );
+            assertEquals(
+                "1:5: [-] arguments are in unsupported order: cannot subtract a [DATETIME] value [now()] "
+                    + "from a [TIME_DURATION] amount [\"1 "
+                    + unit
+                    + "\"::time_duration]",
+                error("row \"1 " + unit + "\"::time_duration" + " - now() ")
+            );
+            assertEquals(
+                "1:5: [-] arguments are in unsupported order: cannot subtract a [DATETIME] value [now()] "
+                    + "from a [TIME_DURATION] amount [to_timeduration(1 "
+                    + unit
+                    + ")]",
+                error("row to_timeduration(1 " + unit + ") - now() ")
+            );
+            assertEquals(
+                "1:5: [-] arguments are in unsupported order: cannot subtract a [DATETIME] value [now()] "
+                    + "from a [TIME_DURATION] amount [to_timeduration(\"1 "
+                    + unit
+                    + "\")]",
+                error("row to_timeduration(\"1 " + unit + "\") - now() ")
+            );
         }
-        for (var unit : List.of("day", "week", "month", "year")) {
+        for (var unit : DATE_PERIODS) {
             assertEquals(
-                "1:5: [-] arguments are in unsupported order: cannot subtract a [DATETIME] value [now()] from a [DATE_PERIOD] amount [1 "
+                "1:5: [-] arguments are in unsupported order: cannot subtract a [DATETIME] value [now()] "
+                    + "from a [DATE_PERIOD] amount [1 "
                     + unit
                     + "]",
                 error("row 1 " + unit + " - now() ")
             );
+            assertEquals(
+                "1:5: [-] arguments are in unsupported order: cannot subtract a [DATETIME] value [now()] "
+                    + "from a [DATE_PERIOD] amount [1 "
+                    + unit
+                    + "::date_period]",
+                error("row 1 " + unit + "::date_period" + " - now() ")
+            );
+            assertEquals(
+                "1:5: [-] arguments are in unsupported order: cannot subtract a [DATETIME] value [now()] "
+                    + "from a [DATE_PERIOD] amount [\"1 "
+                    + unit
+                    + "\"::date_period]",
+                error("row \"1 " + unit + "\"::date_period" + " - now() ")
+            );
+            assertEquals(
+                "1:5: [-] arguments are in unsupported order: cannot subtract a [DATETIME] value [now()] "
+                    + "from a [DATE_PERIOD] amount [to_dateperiod(1 "
+                    + unit
+                    + ")]",
+                error("row to_dateperiod(1 " + unit + ") - now() ")
+            );
+            assertEquals(
+                "1:5: [-] arguments are in unsupported order: cannot subtract a [DATETIME] value [now()] "
+                    + "from a [DATE_PERIOD] amount [to_dateperiod(\"1 "
+                    + unit
+                    + "\")]",
+                error("row to_dateperiod(\"1 " + unit + "\") - now() ")
+            );
         }
     }
 
     public void testPeriodAndDurationInEval() {
-        for (var unit : List.of("millisecond", "second", "minute", "hour")) {
+        for (var unit : TIME_DURATIONS) {
             assertEquals(
-                "1:18: EVAL does not support type [time_duration] in expression [1 " + unit + "]",
+                "1:18: EVAL does not support type [time_duration] as the return data type of expression [1 " + unit + "]",
                 error("row x = 1 | eval y = 1 " + unit)
             );
+            assertEquals(
+                "1:18: EVAL does not support type [time_duration] as the return data type of expression [1 " + unit + "::time_duration]",
+                error("row x = 1 | eval y = 1 " + unit + "::time_duration")
+            );
+            assertEquals(
+                "1:18: EVAL does not support type [time_duration] as the return data type of expression [\"1 "
+                    + unit
+                    + "\"::time_duration]",
+                error("row x = 1 | eval y = \"1 " + unit + "\"::time_duration")
+            );
+            assertEquals(
+                "1:18: EVAL does not support type [time_duration] as the return data type of expression [to_timeduration(1 " + unit + ")]",
+                error("row x = 1 | eval y = to_timeduration(1 " + unit + ")")
+            );
+            assertEquals(
+                "1:18: EVAL does not support type [time_duration] as the return data type of expression [to_timeduration(\"1 "
+                    + unit
+                    + "\")]",
+                error("row x = 1 | eval y = to_timeduration(\"1 " + unit + "\")")
+            );
         }
-        for (var unit : List.of("day", "week", "month", "year")) {
+        for (var unit : DATE_PERIODS) {
             assertEquals(
-                "1:18: EVAL does not support type [date_period] in expression [1 " + unit + "]",
+                "1:18: EVAL does not support type [date_period] as the return data type of expression [1 " + unit + "]",
                 error("row x = 1 | eval y = 1 " + unit)
             );
+            assertEquals(
+                "1:18: EVAL does not support type [date_period] as the return data type of expression [1 " + unit + "::date_period]",
+                error("row x = 1 | eval y = 1 " + unit + "::date_period")
+            );
+            assertEquals(
+                "1:18: EVAL does not support type [date_period] as the return data type of expression [\"1 " + unit + "\"::date_period]",
+                error("row x = 1 | eval y = \"1 " + unit + "\"::date_period")
+            );
+            assertEquals(
+                "1:18: EVAL does not support type [date_period] as the return data type of expression [to_dateperiod(1 " + unit + ")]",
+                error("row x = 1 | eval y = to_dateperiod(1 " + unit + ")")
+            );
+            assertEquals(
+                "1:18: EVAL does not support type [date_period] as the return data type of expression [to_dateperiod(\"1 " + unit + "\")]",
+                error("row x = 1 | eval y = to_dateperiod(\"1 " + unit + "\")")
+            );
         }
     }
 
@@ -722,6 +854,14 @@ public class VerifierTests extends ESTestCase {
 
     public void testFilterDateConstant() {
         assertEquals("1:19: Condition expression needs to be boolean, found [DATE_PERIOD]", error("from test | where 1 year"));
+        assertEquals(
+            "1:19: Condition expression needs to be boolean, found [DATE_PERIOD]",
+            error("from test | where \"1 year\"::date_period")
+        );
+        assertEquals(
+            "1:19: Condition expression needs to be boolean, found [DATE_PERIOD]",
+            error("from test | where to_dateperiod(\"1 year\")")
+        );
     }
 
     public void testNestedAggField() {
@@ -1055,10 +1195,91 @@ public class VerifierTests extends ESTestCase {
         );
     }
 
-    public void test() {
+    public void testToDatePeriodTimeDurationInInvalidPosition() {
+        // arithmetic operations in eval
         assertEquals(
-            "1:23: second argument of [coalesce(network.bytes_in, 0)] must be [counter_long], found value [0] type [integer]",
-            error("FROM tests | eval x = coalesce(network.bytes_in, 0)", tsdb)
+            "1:39: EVAL does not support type [date_period] as the return data type of expression [3 months + 5 days]",
+            error("row x = \"2024-01-01\"::datetime | eval y = 3 months + 5 days")
+        );
+
+        assertEquals(
+            "1:39: EVAL does not support type [date_period] as the return data type of expression "
+                + "[\"3 months\"::date_period + \"5 days\"::date_period]",
+            error("row x = \"2024-01-01\"::datetime | eval y = \"3 months\"::date_period + \"5 days\"::date_period")
+        );
+
+        assertEquals(
+            "1:39: EVAL does not support type [time_duration] as the return data type of expression [3 hours + 5 minutes]",
+            error("row x = \"2024-01-01\"::datetime | eval y = 3 hours + 5 minutes")
+        );
+
+        assertEquals(
+            "1:39: EVAL does not support type [time_duration] as the return data type of expression "
+                + "[\"3 hours\"::time_duration + \"5 minutes\"::time_duration]",
+            error("row x = \"2024-01-01\"::datetime | eval y = \"3 hours\"::time_duration + \"5 minutes\"::time_duration")
+        );
+
+        // where
+        assertEquals(
+            "1:26: first argument of [\"3 days\"::date_period == to_dateperiod(\"3 days\")] must be "
+                + "[boolean, cartesian_point, cartesian_shape, date_nanos, datetime, double, geo_point, geo_shape, integer, ip, keyword, "
+                + "long, text, unsigned_long or version], found value [\"3 days\"::date_period] type [date_period]",
+            error("row x = \"3 days\" | where \"3 days\"::date_period == to_dateperiod(\"3 days\")")
+        );
+
+        assertEquals(
+            "1:26: first argument of [\"3 hours\"::time_duration <= to_timeduration(\"3 hours\")] must be "
+                + "[date_nanos, datetime, double, integer, ip, keyword, long, text, unsigned_long or version], "
+                + "found value [\"3 hours\"::time_duration] type [time_duration]",
+            error("row x = \"3 days\" | where \"3 hours\"::time_duration <= to_timeduration(\"3 hours\")")
+        );
+
+        assertEquals(
+            "1:19: second argument of [first_name <= to_timeduration(\"3 hours\")] must be "
+                + "[date_nanos, datetime, double, integer, ip, keyword, long, text, unsigned_long or version], "
+                + "found value [to_timeduration(\"3 hours\")] type [time_duration]",
+            error("from test | where first_name <= to_timeduration(\"3 hours\")")
+        );
+
+        assertEquals(
+            "1:19: 1st argument of [first_name IN ( to_timeduration(\"3 hours\"), \"3 days\"::date_period)] must be [keyword], "
+                + "found value [to_timeduration(\"3 hours\")] type [time_duration]",
+            error("from test | where first_name IN ( to_timeduration(\"3 hours\"), \"3 days\"::date_period)")
+        );
+    }
+
+    public void testToDatePeriodToTimeDurationWithInvalidType() {
+        assertEquals(
+            "1:36: argument of [1.5::date_period] must be [date_period or string], found value [1.5] type [double]",
+            error("from types | EVAL x = birth_date + 1.5::date_period")
+        );
+        assertEquals(
+            "1:37: argument of [to_timeduration(1)] must be [time_duration or string], found value [1] type [integer]",
+            error("from types  | EVAL x = birth_date - to_timeduration(1)")
+        );
+        assertEquals(
+            "1:45: argument of [x::date_period] must be [date_period or string], found value [x] type [double]",
+            error("from types | EVAL x = 1.5, y = birth_date + x::date_period")
+        );
+        assertEquals(
+            "1:44: argument of [to_timeduration(x)] must be [time_duration or string], found value [x] type [integer]",
+            error("from types  | EVAL x = 1, y = birth_date - to_timeduration(x)")
+        );
+        assertEquals(
+            "1:64: argument of [x::date_period] must be [date_period or string], found value [x] type [datetime]",
+            error("from types | EVAL x = \"2024-09-08\"::datetime, y = birth_date + x::date_period")
+        );
+        assertEquals(
+            "1:65: argument of [to_timeduration(x)] must be [time_duration or string], found value [x] type [datetime]",
+            error("from types  | EVAL x = \"2024-09-08\"::datetime, y = birth_date - to_timeduration(x)")
+        );
+        assertEquals(
+            "1:58: argument of [x::date_period] must be [date_period or string], found value [x] type [ip]",
+            error("from types | EVAL x = \"2024-09-08\"::ip, y = birth_date + x::date_period")
+        );
+        assertEquals(
+            "1:59: argument of [to_timeduration(x)] must be [time_duration or string], found value [x] type [ip]",
+            error("from types  | EVAL x = \"2024-09-08\"::ip, y = birth_date - to_timeduration(x)")
         );
     }
 

+ 88 - 0
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/convert/ToDatePeriodTests.java

@@ -0,0 +1,88 @@
+/*
+ * 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.convert;
+
+import com.carrotsearch.randomizedtesting.annotations.Name;
+import com.carrotsearch.randomizedtesting.annotations.ParametersFactory;
+
+import org.apache.lucene.util.BytesRef;
+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.AbstractScalarFunctionTestCase;
+import org.elasticsearch.xpack.esql.expression.function.FunctionName;
+import org.elasticsearch.xpack.esql.expression.function.TestCaseSupplier;
+import org.elasticsearch.xpack.esql.type.EsqlDataTypeConverter;
+
+import java.time.Period;
+import java.time.temporal.TemporalAmount;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Supplier;
+
+import static org.elasticsearch.xpack.esql.EsqlTestUtils.randomLiteral;
+import static org.elasticsearch.xpack.esql.core.type.DataType.DATE_PERIOD;
+import static org.elasticsearch.xpack.esql.core.type.DataType.KEYWORD;
+import static org.elasticsearch.xpack.esql.core.type.DataType.TEXT;
+import static org.elasticsearch.xpack.esql.type.EsqlDataTypeConverter.DATE_PERIODS;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.matchesPattern;
+
+@FunctionName("to_dateperiod")
+public class ToDatePeriodTests extends AbstractScalarFunctionTestCase {
+    public ToDatePeriodTests(@Name("TestCase") Supplier<TestCaseSupplier.TestCase> testCaseSupplier) {
+        this.testCase = testCaseSupplier.get();
+    }
+
+    @ParametersFactory
+    public static Iterable<Object[]> parameters() {
+        List<TestCaseSupplier> suppliers = new ArrayList<>();
+
+        suppliers.add(new TestCaseSupplier(List.of(DATE_PERIOD), () -> {
+            Period field = (Period) randomLiteral(DATE_PERIOD).value();
+            return new TestCaseSupplier.TestCase(
+                List.of(new TestCaseSupplier.TypedData(field, DATE_PERIOD, "field").forceLiteral()),
+                matchesPattern("LiteralsEvaluator.*"),
+                DATE_PERIOD,
+                equalTo(field)
+            );
+        }));
+
+        for (EsqlDataTypeConverter.INTERVALS interval : DATE_PERIODS) {
+            for (DataType inputType : List.of(KEYWORD, TEXT)) {
+                suppliers.add(new TestCaseSupplier(List.of(inputType), () -> {
+                    BytesRef field = new BytesRef(
+                        " ".repeat(randomIntBetween(0, 10)) + (randomBoolean() ? "" : "-") + randomIntBetween(0, 36500000) + " ".repeat(
+                            randomIntBetween(1, 10)
+                        ) + interval.toString() + " ".repeat(randomIntBetween(0, 10))
+                    );
+                    TemporalAmount result = EsqlDataTypeConverter.parseTemporalAmount(field.utf8ToString(), DATE_PERIOD);
+                    return new TestCaseSupplier.TestCase(
+                        List.of(new TestCaseSupplier.TypedData(field, inputType, "field").forceLiteral()),
+                        matchesPattern("LiteralsEvaluator.*"),
+                        DATE_PERIOD,
+                        equalTo(result)
+                    );
+                }));
+            }
+        }
+        return parameterSuppliersFromTypedData(
+            errorsForCasesWithoutExamples(anyNullIsNull(true, suppliers), (v, p) -> "date_period or string")
+        );
+    }
+
+    @Override
+    protected Expression build(Source source, List<Expression> args) {
+        return new ToDatePeriod(source, args.get(0));
+    }
+
+    @Override
+    public void testSerializationOfSimple() {
+        assertTrue("Serialization test does not apply", true);
+    }
+}

+ 87 - 0
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/convert/ToTimeDurationTests.java

@@ -0,0 +1,87 @@
+/*
+ * 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.convert;
+
+import com.carrotsearch.randomizedtesting.annotations.Name;
+import com.carrotsearch.randomizedtesting.annotations.ParametersFactory;
+
+import org.apache.lucene.util.BytesRef;
+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.AbstractScalarFunctionTestCase;
+import org.elasticsearch.xpack.esql.expression.function.FunctionName;
+import org.elasticsearch.xpack.esql.expression.function.TestCaseSupplier;
+import org.elasticsearch.xpack.esql.type.EsqlDataTypeConverter;
+
+import java.time.Duration;
+import java.time.temporal.TemporalAmount;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Supplier;
+
+import static org.elasticsearch.xpack.esql.EsqlTestUtils.randomLiteral;
+import static org.elasticsearch.xpack.esql.core.type.DataType.KEYWORD;
+import static org.elasticsearch.xpack.esql.core.type.DataType.TEXT;
+import static org.elasticsearch.xpack.esql.core.type.DataType.TIME_DURATION;
+import static org.elasticsearch.xpack.esql.type.EsqlDataTypeConverter.TIME_DURATIONS;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.matchesPattern;
+
+@FunctionName("to_timeduration")
+public class ToTimeDurationTests extends AbstractScalarFunctionTestCase {
+    public ToTimeDurationTests(@Name("TestCase") Supplier<TestCaseSupplier.TestCase> testCaseSupplier) {
+        this.testCase = testCaseSupplier.get();
+    }
+
+    @ParametersFactory
+    public static Iterable<Object[]> parameters() {
+        List<TestCaseSupplier> suppliers = new ArrayList<>();
+
+        suppliers.add(new TestCaseSupplier(List.of(TIME_DURATION), () -> {
+            Duration field = (Duration) randomLiteral(TIME_DURATION).value();
+            return new TestCaseSupplier.TestCase(
+                List.of(new TestCaseSupplier.TypedData(field, TIME_DURATION, "field").forceLiteral()),
+                matchesPattern("LiteralsEvaluator.*"),
+                TIME_DURATION,
+                equalTo(field)
+            );
+        }));
+
+        for (EsqlDataTypeConverter.INTERVALS interval : TIME_DURATIONS) {
+            for (DataType inputType : List.of(KEYWORD, TEXT)) {
+                suppliers.add(new TestCaseSupplier(List.of(inputType), () -> {
+                    BytesRef field = new BytesRef(
+                        " ".repeat(randomIntBetween(0, 10)) + (randomBoolean() ? "" : "-") + randomIntBetween(0, Integer.MAX_VALUE) + " "
+                            .repeat(randomIntBetween(1, 10)) + interval.toString() + " ".repeat(randomIntBetween(0, 10))
+                    );
+                    TemporalAmount result = EsqlDataTypeConverter.parseTemporalAmount(field.utf8ToString(), TIME_DURATION);
+                    return new TestCaseSupplier.TestCase(
+                        List.of(new TestCaseSupplier.TypedData(field, inputType, "field").forceLiteral()),
+                        matchesPattern("LiteralsEvaluator.*"),
+                        TIME_DURATION,
+                        equalTo(result)
+                    );
+                }));
+            }
+        }
+        return parameterSuppliersFromTypedData(
+            errorsForCasesWithoutExamples(anyNullIsNull(true, suppliers), (v, p) -> "time_duration or string")
+        );
+    }
+
+    @Override
+    protected Expression build(Source source, List<Expression> args) {
+        return new ToTimeDuration(source, args.get(0));
+    }
+
+    @Override
+    public void testSerializationOfSimple() {
+        assertTrue("Serialization test does not apply", true);
+    }
+}

+ 141 - 0
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LogicalPlanOptimizerTests.java

@@ -5419,6 +5419,147 @@ public class LogicalPlanOptimizerTests extends ESTestCase {
         assertEquals("Invalid order value in [mv_sort(v, o)], expected one of [ASC, DESC] but got [dsc]", iae.getMessage());
     }
 
+    public void testToDatePeriodTimeDurationInvalidIntervals() {
+        IllegalArgumentException e = expectThrows(IllegalArgumentException.class, () -> planTypes("""
+            from types | EVAL interval = "3 dys", x = date + interval::date_period"""));
+        assertEquals(
+            "Invalid interval value in [interval::date_period], expected integer followed by one of "
+                + "[DAY, DAYS, D, WEEK, WEEKS, W, MONTH, MONTHS, MO, QUARTER, QUARTERS, Q, YEAR, YEARS, YR, Y] but got [3 dys]",
+            e.getMessage()
+        );
+
+        e = expectThrows(IllegalArgumentException.class, () -> planTypes("""
+            from types | EVAL interval = "- 3 days", x = date + interval::date_period"""));
+        assertEquals(
+            "Invalid interval value in [interval::date_period], expected integer followed by one of "
+                + "[DAY, DAYS, D, WEEK, WEEKS, W, MONTH, MONTHS, MO, QUARTER, QUARTERS, Q, YEAR, YEARS, YR, Y] but got [- 3 days]",
+            e.getMessage()
+        );
+
+        e = expectThrows(IllegalArgumentException.class, () -> planTypes("""
+            from types  | EVAL interval = "3 dys", x = date - to_dateperiod(interval)"""));
+        assertEquals(
+            "Invalid interval value in [to_dateperiod(interval)], expected integer followed by one of "
+                + "[DAY, DAYS, D, WEEK, WEEKS, W, MONTH, MONTHS, MO, QUARTER, QUARTERS, Q, YEAR, YEARS, YR, Y] but got [3 dys]",
+            e.getMessage()
+        );
+
+        e = expectThrows(IllegalArgumentException.class, () -> planTypes("""
+            from types  | EVAL interval = "- 3 days", x = date - to_dateperiod(interval)"""));
+        assertEquals(
+            "Invalid interval value in [to_dateperiod(interval)], expected integer followed by one of "
+                + "[DAY, DAYS, D, WEEK, WEEKS, W, MONTH, MONTHS, MO, QUARTER, QUARTERS, Q, YEAR, YEARS, YR, Y] but got [- 3 days]",
+            e.getMessage()
+        );
+
+        e = expectThrows(IllegalArgumentException.class, () -> planTypes("""
+            from types  | EVAL interval = "3 ours", x = date + interval::time_duration"""));
+        assertEquals(
+            "Invalid interval value in [interval::time_duration], expected integer followed by one of "
+                + "[MILLISECOND, MILLISECONDS, MS, SECOND, SECONDS, SEC, S, MINUTE, MINUTES, MIN, HOUR, HOURS, H] but got [3 ours]",
+            e.getMessage()
+        );
+
+        e = expectThrows(IllegalArgumentException.class, () -> planTypes("""
+            from types  | EVAL interval = "- 3 hours", x = date + interval::time_duration"""));
+        assertEquals(
+            "Invalid interval value in [interval::time_duration], expected integer followed by one of "
+                + "[MILLISECOND, MILLISECONDS, MS, SECOND, SECONDS, SEC, S, MINUTE, MINUTES, MIN, HOUR, HOURS, H] but got [- 3 hours]",
+            e.getMessage()
+        );
+
+        e = expectThrows(IllegalArgumentException.class, () -> planTypes("""
+            from types  | EVAL interval = "3 ours", x = date - to_timeduration(interval)"""));
+        assertEquals(
+            "Invalid interval value in [to_timeduration(interval)], expected integer followed by one of "
+                + "[MILLISECOND, MILLISECONDS, MS, SECOND, SECONDS, SEC, S, MINUTE, MINUTES, MIN, HOUR, HOURS, H] but got [3 ours]",
+            e.getMessage()
+        );
+
+        e = expectThrows(IllegalArgumentException.class, () -> planTypes("""
+            from types  | EVAL interval = "- 3 hours", x = date - to_timeduration(interval)"""));
+        assertEquals(
+            "Invalid interval value in [to_timeduration(interval)], expected integer followed by one of "
+                + "[MILLISECOND, MILLISECONDS, MS, SECOND, SECONDS, SEC, S, MINUTE, MINUTES, MIN, HOUR, HOURS, H] but got [- 3 hours]",
+            e.getMessage()
+        );
+
+        e = expectThrows(IllegalArgumentException.class, () -> planTypes("""
+            from types  | EVAL interval = "3.5 hours", x = date - to_timeduration(interval)"""));
+        assertEquals(
+            "Invalid interval value in [to_timeduration(interval)], expected integer followed by one of "
+                + "[MILLISECOND, MILLISECONDS, MS, SECOND, SECONDS, SEC, S, MINUTE, MINUTES, MIN, HOUR, HOURS, H] but got [3.5 hours]",
+            e.getMessage()
+        );
+
+        e = expectThrows(IllegalArgumentException.class, () -> planTypes("""
+            row x = "2024-01-01"::datetime | eval y = x + "3 dys"::date_period"""));
+        assertEquals(
+            "Invalid interval value in [\"3 dys\"::date_period], expected integer followed by one of "
+                + "[DAY, DAYS, D, WEEK, WEEKS, W, MONTH, MONTHS, MO, QUARTER, QUARTERS, Q, YEAR, YEARS, YR, Y] but got [3 dys]",
+            e.getMessage()
+        );
+
+        e = expectThrows(IllegalArgumentException.class, () -> planTypes("""
+            row x = "2024-01-01"::datetime | eval y = x - to_dateperiod("3 dys")"""));
+        assertEquals(
+            "Invalid interval value in [to_dateperiod(\"3 dys\")], expected integer followed by one of "
+                + "[DAY, DAYS, D, WEEK, WEEKS, W, MONTH, MONTHS, MO, QUARTER, QUARTERS, Q, YEAR, YEARS, YR, Y] but got [3 dys]",
+            e.getMessage()
+        );
+
+        e = expectThrows(IllegalArgumentException.class, () -> planTypes("""
+            row x = "2024-01-01"::datetime | eval y = x + "3 ours"::time_duration"""));
+        assertEquals(
+            "Invalid interval value in [\"3 ours\"::time_duration], expected integer followed by one of "
+                + "[MILLISECOND, MILLISECONDS, MS, SECOND, SECONDS, SEC, S, MINUTE, MINUTES, MIN, HOUR, HOURS, H] but got [3 ours]",
+            e.getMessage()
+        );
+
+        e = expectThrows(IllegalArgumentException.class, () -> planTypes("""
+            row x = "2024-01-01"::datetime | eval y = x - to_timeduration("3 ours")"""));
+        assertEquals(
+            "Invalid interval value in [to_timeduration(\"3 ours\")], expected integer followed by one of "
+                + "[MILLISECOND, MILLISECONDS, MS, SECOND, SECONDS, SEC, S, MINUTE, MINUTES, MIN, HOUR, HOURS, H] but got [3 ours]",
+            e.getMessage()
+        );
+
+        e = expectThrows(IllegalArgumentException.class, () -> planTypes("""
+            row x = "2024-01-01"::datetime | eval y = x - to_timeduration("3.5 hours")"""));
+        assertEquals(
+            "Invalid interval value in [to_timeduration(\"3.5 hours\")], expected integer followed by one of "
+                + "[MILLISECOND, MILLISECONDS, MS, SECOND, SECONDS, SEC, S, MINUTE, MINUTES, MIN, HOUR, HOURS, H] but got [3.5 hours]",
+            e.getMessage()
+        );
+    }
+
+    public void testToDatePeriodToTimeDurationWithField() {
+        final String header = "Found 1 problem\nline ";
+        VerificationException e = expectThrows(VerificationException.class, () -> planTypes("""
+            from types | EVAL x = date + keyword::date_period"""));
+        assertTrue(e.getMessage().startsWith("Found "));
+        assertEquals(
+            "1:30: argument of [keyword::date_period] must be a constant, received [keyword]",
+            e.getMessage().substring(header.length())
+        );
+
+        e = expectThrows(VerificationException.class, () -> planTypes("""
+            from types  | EVAL x = date - to_timeduration(keyword)"""));
+        assertEquals(
+            "1:47: argument of [to_timeduration(keyword)] must be a constant, received [keyword]",
+            e.getMessage().substring(header.length())
+        );
+
+        e = expectThrows(VerificationException.class, () -> planTypes("""
+            from types | EVAL x = keyword, y = date + x::date_period"""));
+        assertTrue(e.getMessage().startsWith("Found "));
+        assertEquals("1:43: argument of [x::date_period] must be a constant, received [x]", e.getMessage().substring(header.length()));
+
+        e = expectThrows(VerificationException.class, () -> planTypes("""
+            from types  | EVAL x = keyword, y = date - to_timeduration(x)"""));
+        assertEquals("1:60: argument of [to_timeduration(x)] must be a constant, received [x]", e.getMessage().substring(header.length()));
+    }
+
     private Literal nullOf(DataType dataType) {
         return new Literal(Source.EMPTY, null, dataType);
     }

+ 16 - 0
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/parser/StatementParserTests.java

@@ -1413,6 +1413,22 @@ public class StatementParserTests extends AbstractStatementParserTests {
         );
     }
 
+    public void testIntervalParam() {
+        LogicalPlan stm = statement(
+            "row x = ?1::datetime | eval y = ?1::datetime + ?2::date_period",
+            new QueryParams(List.of(new QueryParam("datetime", "2024-01-01", KEYWORD), new QueryParam("date_period", "3 days", KEYWORD)))
+        );
+        assertThat(stm, instanceOf(Eval.class));
+        Eval eval = (Eval) stm;
+        assertThat(eval.fields().size(), is(1));
+
+        NamedExpression field = eval.fields().get(0);
+        assertThat(field.name(), is("y"));
+        assertThat(field, instanceOf(Alias.class));
+        assertThat(((Literal) ((Add) eval.fields().get(0).child()).left().children().get(0)).value(), equalTo("2024-01-01"));
+        assertThat(((Literal) ((Add) eval.fields().get(0).child()).right().children().get(0)).value(), equalTo("3 days"));
+    }
+
     public void testFieldContainingDotsAndNumbers() {
         LogicalPlan where = processingCommand("where `a.b.1m.4321`");
         assertThat(where, instanceOf(Filter.class));

+ 29 - 0
x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/esql/10_basic.yml

@@ -393,6 +393,35 @@ setup:
   - match: {values.1: ["1",2.0,null,true,123,1674835275193]}
   - match: {values.2: ["1",2.0,null,true,123,1674835275193]}
 
+---
+"Test Interval in Input Params":
+  - requires:
+      test_runner_features: [ capabilities ]
+      capabilities:
+        - method: POST
+          path: /_query
+          parameters: [ ]
+          capabilities: [ cast_string_literal_to_temporal_amount ]
+      reason: "interval in parameters"
+
+  - do:
+      allowed_warnings_regex:
+        - "No limit defined, adding default limit of \\[.*\\]"
+      esql.query:
+        body:
+          query: 'row x = ?n1::datetime | eval y = x - ?n2::date_period, z = x + ?n3::time_duration'
+          params: [{"n1" : "2024-08-06"}, {"n2" : "3 days"}, {"n3" : "3 hours"}]
+
+  - length: {columns: 3}
+  - match: {columns.0.name: "x"}
+  - match: {columns.0.type: "date"}
+  - match: {columns.1.name: "y"}
+  - match: {columns.1.type: "date"}
+  - match: {columns.2.name: "z"}
+  - match: {columns.2.type: "date"}
+  - length: {values: 1}
+  - match: {values.0: ["2024-08-06T00:00:00.000Z","2024-08-03T00:00:00.000Z","2024-08-06T03:00:00.000Z"]}
+
 ---
 version is not allowed:
   - requires: