Browse Source

SQL: Extend DATE_TRUNC to also operate on intervals(elastic - #46632 ) (#47720)

The function is extended to operate on intervals according to the PostgreSQL: https://www.postgresql.org/docs/9.1/functions-datetime.html#FUNCTIONS-DATETIME-TRUNC

Closes : #46632
musteaf 5 years ago
parent
commit
2dc7950582
14 changed files with 536 additions and 98 deletions
  1. 21 5
      docs/reference/sql/functions/date-time.asciidoc
  2. 5 5
      x-pack/plugin/ql/src/main/java/org/elasticsearch/xpack/ql/util/DateUtils.java
  3. 71 0
      x-pack/plugin/sql/qa/src/main/resources/datetime.csv-spec
  4. 30 0
      x-pack/plugin/sql/qa/src/main/resources/docs/docs.csv-spec
  5. 5 1
      x-pack/plugin/sql/src/main/java/org/elasticsearch/xpack/sql/expression/SqlTypeResolutions.java
  6. 1 5
      x-pack/plugin/sql/src/main/java/org/elasticsearch/xpack/sql/expression/function/scalar/datetime/BinaryDateTimeFunction.java
  7. 16 0
      x-pack/plugin/sql/src/main/java/org/elasticsearch/xpack/sql/expression/function/scalar/datetime/DatePart.java
  8. 194 68
      x-pack/plugin/sql/src/main/java/org/elasticsearch/xpack/sql/expression/function/scalar/datetime/DateTrunc.java
  9. 19 5
      x-pack/plugin/sql/src/main/java/org/elasticsearch/xpack/sql/expression/function/scalar/datetime/DateTruncProcessor.java
  10. 5 2
      x-pack/plugin/sql/src/main/java/org/elasticsearch/xpack/sql/expression/function/scalar/whitelist/InternalSqlScriptUtils.java
  11. 4 1
      x-pack/plugin/sql/src/main/java/org/elasticsearch/xpack/sql/type/SqlDataTypes.java
  12. 1 1
      x-pack/plugin/sql/src/main/resources/org/elasticsearch/xpack/sql/plugin/sql_whitelist.txt
  13. 4 4
      x-pack/plugin/sql/src/test/java/org/elasticsearch/xpack/sql/analysis/analyzer/VerifierErrorMessagesTests.java
  14. 160 1
      x-pack/plugin/sql/src/test/java/org/elasticsearch/xpack/sql/expression/function/scalar/datetime/DateTruncProcessorTests.java

+ 21 - 5
docs/reference/sql/functions/date-time.asciidoc

@@ -500,18 +500,19 @@ include-tagged::{sql-specs}/docs/docs.csv-spec[datePartDateTimeTzOffsetMinus]
 --------------------------------------------------
 DATE_TRUNC(
     string_exp, <1>
-    datetime_exp) <2>
+    datetime_exp/interval_exp) <2>
 --------------------------------------------------
 
 *Input*:
 
-<1> string expression denoting the unit to which the date/datetime should be truncated to
-<2> date/datetime expression
+<1> string expression denoting the unit to which the date/datetime/interval should be truncated to
+<2> date/datetime/interval expression
 
-*Output*: datetime
+*Output*: datetime/interval
 
-*Description*: Truncate the date/datetime to the specified unit by setting all fields that are less significant than the specified
+*Description*: Truncate the date/datetime/interval to the specified unit by setting all fields that are less significant than the specified
 one to zero (or one, for day, day of week and month). If any of the two arguments is `null` a `null` is returned.
+If the first argument is `week` and the second argument is of `interval` type, an error is thrown since the `interval` data type doesn't support a `week` time unit.
 
 [cols="^,^"]
 |===
@@ -563,6 +564,21 @@ include-tagged::{sql-specs}/docs/docs.csv-spec[truncateDateDecades]
 include-tagged::{sql-specs}/docs/docs.csv-spec[truncateDateQuarter]
 --------------------------------------------------
 
+[source, sql]
+--------------------------------------------------
+include-tagged::{sql-specs}/docs/docs.csv-spec[truncateIntervalCenturies]
+--------------------------------------------------
+
+[source, sql]
+--------------------------------------------------
+include-tagged::{sql-specs}/docs/docs.csv-spec[truncateIntervalHour]
+--------------------------------------------------
+
+[source, sql]
+--------------------------------------------------
+include-tagged::{sql-specs}/docs/docs.csv-spec[truncateIntervalDay]
+--------------------------------------------------
+
 [[sql-functions-datetime-day]]
 ==== `DAY_OF_MONTH/DOM/DAY`
 

+ 5 - 5
x-pack/plugin/ql/src/main/java/org/elasticsearch/xpack/ql/util/DateUtils.java

@@ -34,7 +34,7 @@ public class DateUtils {
     public static final ZoneId UTC = ZoneId.of("Z");
 
     public static final String EMPTY = "";
-    
+
     public static final DateTimeFormatter ISO_DATE_WITH_MILLIS = new DateTimeFormatterBuilder()
             .parseCaseInsensitive()
             .append(ISO_LOCAL_DATE)
@@ -72,9 +72,9 @@ public class DateUtils {
             .appendOffsetId()
             .toFormatter(Locale.ROOT);
 
-    private static final int SECONDS_PER_MINUTE = 60;
-    private static final int SECONDS_PER_HOUR = SECONDS_PER_MINUTE * 60;
-    private static final int SECONDS_PER_DAY = SECONDS_PER_HOUR * 24;
+    public static final int SECONDS_PER_MINUTE = 60;
+    public static final int SECONDS_PER_HOUR = SECONDS_PER_MINUTE * 60;
+    public static final int SECONDS_PER_DAY = SECONDS_PER_HOUR * 24;
 
     private DateUtils() {}
 
@@ -82,7 +82,7 @@ public class DateUtils {
         if (value == null) {
             return "null";
         }
-        
+
         if (value instanceof ZonedDateTime) {
             return ((ZonedDateTime) value).format(ISO_DATE_WITH_MILLIS);
         }

+ 71 - 0
x-pack/plugin/sql/qa/src/main/resources/datetime.csv-spec

@@ -499,6 +499,16 @@ DATE_TRUNC('week', '2019-09-04'::date) as dt_week,  DATE_TRUNC('day', '2019-09-0
 2000-01-01T00:00:00.000Z | 2000-01-01T00:00:00.000Z | 2010-01-01T00:00:00.000Z | 2019-01-01T00:00:00.000Z | 2019-07-01T00:00:00.000Z | 2019-09-01T00:00:00.000Z | 2019-09-02T00:00:00.000Z | 2019-09-04T00:00:00.000Z
 ;
 
+selectDateTruncWithInterval
+SELECT DATE_TRUNC('hour', INTERVAL '1 12:43:21' DAY TO SECONDS) as dt_hour, DATE_TRUNC('minute', INTERVAL '1 12:43:21' DAY TO SECONDS) as dt_min,
+DATE_TRUNC('seconds', INTERVAL '1 12:43:21' DAY TO SECONDS) as dt_sec, DATE_TRUNC('ms', INTERVAL '1 12:43:21' DAY TO SECONDS)::string as dt_millis,
+DATE_TRUNC('mcs', INTERVAL '1 12:43:21' DAY TO SECONDS)::string as dt_micro, DATE_TRUNC('nanoseconds', INTERVAL '1 12:43:21' DAY TO SECONDS)::string as dt_nano;
+
+    dt_hour    |    dt_min     |    dt_sec     |   dt_millis   |   dt_micro    |    dt_nano
+---------------+---------------+---------------+---------------+---------------+---------------
++1 12:00:00    |+1 12:43:00    |+1 12:43:21    |+1 12:43:21    |+1 12:43:21    |+1 12:43:21
+;
+
 selectDateTruncWithField
 schema::emp_no:i|birth_date:ts|dt_mil:ts|dt_cent:ts|dt_dec:ts|dt_year:ts|dt_quarter:ts|dt_month:ts|dt_week:ts|dt_day:ts
 SELECT emp_no, birth_date, DATE_TRUNC('millennium', birth_date) as dt_mil, DATE_TRUNC('centuries', birth_date) as dt_cent,
@@ -585,6 +595,21 @@ SELECT emp_no, hire_date, DATE_TRUNC('quarter', hire_date) as dt FROM test_emp O
 10076   | 1985-07-09 00:00:00.000Z | 1985-07-01 00:00:00.000Z
 ;
 
+dateTruncOrderByWithInterval
+schema::first_name:s|dt:ts|hire_date:ts|languages:byte
+SELECT first_name, hire_date + DATE_TRUNC('centuries', CASE WHEN languages = 5 THEN INTERVAL '18-3' YEAR TO MONTH
+WHEN languages = 4 THEN INTERVAL '108-4' YEAR TO MONTH WHEN languages = 3 THEN INTERVAL '212-3' YEAR TO MONTH
+ELSE INTERVAL '318-6' YEAR TO MONTH END) as dt, hire_date, languages FROM test_emp WHERE emp_no <= 10006 ORDER BY dt NULLS LAST LIMIT 5;
+
+   first_name |            dt            |        hire_date         | languages
+--------------+--------------------------+--------------------------+-----------
+Bezalel       | 1985-11-21 00:00:00.000Z | 1985-11-21T00:00:00.000Z | 5
+Chirstian     | 1986-12-01 00:00:00.000Z | 1986-12-01T00:00:00.000Z | 5
+Parto         | 2086-08-28 00:00:00.000Z | 1986-08-28T00:00:00.000Z | 4
+Anneke        | 2189-06-02 00:00:00.000Z | 1989-06-02T00:00:00.000Z | 3
+Georgi        | 2286-06-26 00:00:00.000Z | 1986-06-26T00:00:00.000Z | 2
+;
+
 dateTruncFilter
 schema::emp_no:i|hire_date:ts|dt:ts
 SELECT emp_no, hire_date, DATE_TRUNC('quarter', hire_date) as dt FROM test_emp WHERE DATE_TRUNC('quarter', hire_date) > '1994-07-01T00:00:00.000Z'::timestamp ORDER BY emp_no;
@@ -601,6 +626,24 @@ SELECT emp_no, hire_date, DATE_TRUNC('quarter', hire_date) as dt FROM test_emp W
 10093   | 1996-11-05 00:00:00.000Z | 1996-10-01 00:00:00.000Z
 ;
 
+dateTruncFilterWithInterval
+schema::first_name:s|hire_date:ts
+SELECT first_name, hire_date FROM test_emp WHERE hire_date > '2090-03-05T10:11:22.123Z'::datetime - DATE_TRUNC('centuries', INTERVAL 190 YEARS) ORDER BY first_name DESC, hire_date ASC LIMIT 10;
+
+  first_name   |          hire_date
+---------------+-------------------------
+null           | 1990-06-20 00:00:00.000Z
+null           | 1990-12-05 00:00:00.000Z
+null           | 1991-09-01 00:00:00.000Z
+null           | 1992-01-03 00:00:00.000Z
+null           | 1994-02-17 00:00:00.000Z
+Yongqiao       | 1995-03-20 00:00:00.000Z
+Yishay         | 1990-10-20 00:00:00.000Z
+Yinghua        | 1990-12-25 00:00:00.000Z
+Weiyi          | 1993-02-14 00:00:00.000Z
+Tuval          | 1995-12-15 00:00:00.000Z
+;
+
 dateTruncGroupBy
 schema::count:l|dt:ts
 SELECT count(*) as count, DATE_TRUNC('decade', hire_date) dt FROM test_emp GROUP BY dt ORDER BY 2;
@@ -611,6 +654,24 @@ SELECT count(*) as count, DATE_TRUNC('decade', hire_date) dt FROM test_emp GROUP
 41      | 1990-01-01 00:00:00.000Z
 ;
 
+dateTruncGroupByWithInterval
+schema::count:l|dt:ts
+SELECT count(*) as count, birth_date + DATE_TRUNC('hour', INTERVAL '1 12:43:21' DAY TO SECONDS) dt FROM test_emp GROUP BY dt ORDER BY 2 LIMIT 10;
+
+ count  |          dt
+--------+-------------------------
+10      | null
+1       | 1952-02-28 12:00:00.000Z
+1       | 1952-04-20 12:00:00.000Z
+1       | 1952-05-16 12:00:00.000Z
+1       | 1952-06-14 12:00:00.000Z
+1       | 1952-07-09 12:00:00.000Z
+1       | 1952-08-07 12:00:00.000Z
+1       | 1952-11-14 12:00:00.000Z
+1       | 1952-12-25 12:00:00.000Z
+1       | 1953-01-08 12:00:00.000Z
+;
+
 dateTruncHaving
 schema::gender:s|dt:ts
 SELECT gender, max(hire_date) AS dt FROM test_emp GROUP BY gender HAVING DATE_TRUNC('year', max(hire_date)) >= '1997-01-01T00:00:00.000Z'::timestamp ORDER BY 1;
@@ -621,6 +682,16 @@ null    | 1999-04-30 00:00:00.000Z
 F       | 1997-05-19 00:00:00.000Z
 ;
 
+// Awaits fix: https://github.com/elastic/elasticsearch/issues/53565
+dateTruncHavingWithInterval-Ignore
+schema::gender:s|dt:ts
+SELECT gender, max(hire_date) AS dt FROM test_emp GROUP BY gender HAVING max(hire_date) - DATE_TRUNC('hour', INTERVAL 1 YEARS) >= '1997-01-01T00:00:00.000Z'::timestamp ORDER BY 1;
+
+ gender |         dt
+--------+-------------------------
+null    | 1999-04-30 00:00:00.000Z
+;
+
 selectDatePartWithDate
 SELECT DATE_PART('year', '2019-09-04'::date) as dp_years, DATE_PART('quarter', '2019-09-04'::date) as dp_quarter, DATE_PART('month', '2019-09-04'::date) as dp_month,
 DATE_PART('dayofyear', '2019-09-04'::date) as dp_doy, DATE_PART('day', '2019-09-04'::date) as dp_day, DATE_PART('week', '2019-09-04'::date) as dp_week,

+ 30 - 0
x-pack/plugin/sql/qa/src/main/resources/docs/docs.csv-spec

@@ -2675,6 +2675,36 @@ SELECT DATETRUNC('quarters', CAST('2019-09-04' AS DATE)) AS quarter;
 // end::truncateDateQuarter
 ;
 
+truncateIntervalCenturies
+// tag::truncateIntervalCenturies
+SELECT DATE_TRUNC('centuries', INTERVAL '199-5' YEAR TO MONTH) AS centuries;
+
+      centuries
+------------------
+ +100-0
+// end::truncateIntervalCenturies
+;
+
+truncateIntervalHour
+// tag::truncateIntervalHour
+SELECT DATE_TRUNC('hours', INTERVAL '17 22:13:12' DAY TO SECONDS) AS hour;
+
+      hour
+------------------
++17 22:00:00
+// end::truncateIntervalHour
+;
+
+truncateIntervalDay
+// tag::truncateIntervalDay
+SELECT DATE_TRUNC('days', INTERVAL '19 15:24:19' DAY TO SECONDS) AS day;
+
+      day
+------------------
++19 00:00:00
+// end::truncateIntervalDay
+;
+
 constantDayOfWeek
 // tag::dayOfWeek
 SELECT DAY_OF_WEEK(CAST('2018-02-19T10:23:27Z' AS TIMESTAMP)) AS day;

+ 5 - 1
x-pack/plugin/sql/src/main/java/org/elasticsearch/xpack/sql/expression/SqlTypeResolutions.java

@@ -16,7 +16,7 @@ import static org.elasticsearch.xpack.ql.expression.TypeResolutions.isType;
 public final class SqlTypeResolutions {
 
     private SqlTypeResolutions() {}
-    
+
     public static TypeResolution isDate(Expression e, String operationName, ParamOrdinal paramOrd) {
         return isType(e, SqlDataTypes::isDateBased, operationName, paramOrd, "date", "datetime");
     }
@@ -25,6 +25,10 @@ public final class SqlTypeResolutions {
         return isType(e, SqlDataTypes::isDateOrTimeBased, operationName, paramOrd, "date", "time", "datetime");
     }
 
+    public static TypeResolution isDateOrInterval(Expression e, String operationName, ParamOrdinal paramOrd) {
+        return isType(e, SqlDataTypes::isDateOrIntervalBased, operationName, paramOrd, "date", "datetime", "an interval data type");
+    }
+
     public static TypeResolution isNumericOrDate(Expression e, String operationName, ParamOrdinal paramOrd) {
         return isType(e, dt -> dt.isNumeric() || SqlDataTypes.isDateBased(dt), operationName, paramOrd,
             "date", "datetime", "numeric");

+ 1 - 5
x-pack/plugin/sql/src/main/java/org/elasticsearch/xpack/sql/expression/function/scalar/datetime/BinaryDateTimeFunction.java

@@ -19,7 +19,6 @@ import java.util.Objects;
 import static org.elasticsearch.common.logging.LoggerMessageFormat.format;
 import static org.elasticsearch.xpack.ql.expression.TypeResolutions.isString;
 import static org.elasticsearch.xpack.ql.expression.gen.script.ParamsBuilder.paramsBuilder;
-import static org.elasticsearch.xpack.sql.expression.SqlTypeResolutions.isDate;
 
 public abstract class BinaryDateTimeFunction extends BinaryScalarFunction {
 
@@ -54,10 +53,7 @@ public abstract class BinaryDateTimeFunction extends BinaryScalarFunction {
                 }
             }
         }
-        resolution = isDate(right(), sourceText(), Expressions.ParamOrdinal.SECOND);
-        if (resolution.unresolved()) {
-            return resolution;
-        }
+
         return TypeResolution.TYPE_RESOLVED;
     }
 

+ 16 - 0
x-pack/plugin/sql/src/main/java/org/elasticsearch/xpack/sql/expression/function/scalar/datetime/DatePart.java

@@ -6,6 +6,7 @@
 package org.elasticsearch.xpack.sql.expression.function.scalar.datetime;
 
 import org.elasticsearch.xpack.ql.expression.Expression;
+import org.elasticsearch.xpack.ql.expression.Expressions;
 import org.elasticsearch.xpack.ql.expression.function.scalar.BinaryScalarFunction;
 import org.elasticsearch.xpack.ql.expression.gen.pipeline.Pipe;
 import org.elasticsearch.xpack.ql.tree.NodeInfo;
@@ -23,6 +24,8 @@ import java.util.Map;
 import java.util.Set;
 import java.util.function.ToIntFunction;
 
+import static org.elasticsearch.xpack.sql.expression.SqlTypeResolutions.isDate;
+
 public class DatePart extends BinaryDateTimeFunction {
 
     public enum Part implements DateTimeField {
@@ -84,6 +87,19 @@ public class DatePart extends BinaryDateTimeFunction {
         return DataTypes.INTEGER;
     }
 
+    @Override
+    protected TypeResolution resolveType() {
+        TypeResolution resolution = super.resolveType();
+        if (resolution.unresolved()) {
+            return resolution;
+        }
+        resolution = isDate(right(), sourceText(), Expressions.ParamOrdinal.SECOND);
+        if (resolution.unresolved()) {
+            return resolution;
+        }
+        return TypeResolution.TYPE_RESOLVED;
+    }
+
     @Override
     protected BinaryScalarFunction replaceChildren(Expression newDateTimePart, Expression newTimestamp) {
         return new DatePart(source(), newDateTimePart, newTimestamp, zoneId());

+ 194 - 68
x-pack/plugin/sql/src/main/java/org/elasticsearch/xpack/sql/expression/function/scalar/datetime/DateTrunc.java

@@ -6,96 +6,158 @@
 package org.elasticsearch.xpack.sql.expression.function.scalar.datetime;
 
 import org.elasticsearch.xpack.ql.expression.Expression;
+import org.elasticsearch.xpack.ql.expression.Expressions;
 import org.elasticsearch.xpack.ql.expression.function.scalar.BinaryScalarFunction;
 import org.elasticsearch.xpack.ql.expression.gen.pipeline.Pipe;
 import org.elasticsearch.xpack.ql.tree.NodeInfo;
 import org.elasticsearch.xpack.ql.tree.Source;
 import org.elasticsearch.xpack.ql.type.DataType;
 import org.elasticsearch.xpack.ql.type.DataTypes;
-
+import org.elasticsearch.xpack.sql.expression.literal.interval.IntervalDayTime;
+import org.elasticsearch.xpack.sql.expression.literal.interval.IntervalYearMonth;
+import java.time.Duration;
+import java.time.Period;
 import java.time.ZoneId;
 import java.time.ZonedDateTime;
 import java.time.temporal.ChronoField;
+import java.time.temporal.ChronoUnit;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.concurrent.TimeUnit;
 import java.util.function.UnaryOperator;
 
+import static org.elasticsearch.xpack.ql.util.DateUtils.SECONDS_PER_DAY;
+import static org.elasticsearch.xpack.ql.util.DateUtils.SECONDS_PER_HOUR;
+import static org.elasticsearch.xpack.ql.util.DateUtils.SECONDS_PER_MINUTE;
+import static org.elasticsearch.xpack.sql.expression.SqlTypeResolutions.isDateOrInterval;
+import static org.elasticsearch.xpack.sql.type.SqlDataTypes.isInterval;
+
 public class DateTrunc extends BinaryDateTimeFunction {
 
     public enum Part implements DateTimeField {
 
         MILLENNIUM(dt -> {
-            int year = dt.getYear();
-            int firstYearOfMillenium = year - (year % 1000);
-            return dt
-                .with(ChronoField.YEAR, firstYearOfMillenium)
-                .with(ChronoField.MONTH_OF_YEAR, 1)
-                .with(ChronoField.DAY_OF_MONTH, 1)
-                .toLocalDate().atStartOfDay(dt.getZone());
-        },"millennia"),
+                int year = dt.getYear();
+                int firstYearOfMillennium = year - (year % 1000);
+                return dt
+                    .with(ChronoField.YEAR, firstYearOfMillennium)
+                    .with(ChronoField.MONTH_OF_YEAR, 1)
+                    .with(ChronoField.DAY_OF_MONTH, 1)
+                    .toLocalDate().atStartOfDay(dt.getZone());
+            },
+            idt -> new IntervalDayTime(Duration.ZERO, idt.dataType()),
+            iym -> {
+                Period period = iym.interval();
+                int year = period.getYears();
+                int firstYearOfMillennium = year - (year % 1000);
+                return new IntervalYearMonth(Period.ZERO.plusYears(firstYearOfMillennium), iym.dataType());
+            }, "millennia"),
         CENTURY(dt -> {
-            int year = dt.getYear();
-            int firstYearOfCentury = year - (year % 100);
-            return dt
-                .with(ChronoField.YEAR, firstYearOfCentury)
-                .with(ChronoField.MONTH_OF_YEAR, 1)
-                .with(ChronoField.DAY_OF_MONTH, 1)
-                .toLocalDate().atStartOfDay(dt.getZone());
-        }, "centuries"),
+                int year = dt.getYear();
+                int firstYearOfCentury = year - (year % 100);
+                return dt
+                    .with(ChronoField.YEAR, firstYearOfCentury)
+                    .with(ChronoField.MONTH_OF_YEAR, 1)
+                    .with(ChronoField.DAY_OF_MONTH, 1)
+                    .toLocalDate().atStartOfDay(dt.getZone());
+            },
+            idt -> new IntervalDayTime(Duration.ZERO, idt.dataType()),
+            iym -> {
+                Period period = iym.interval();
+                int year = period.getYears();
+                int firstYearOfCentury = year - (year % 100);
+                return new IntervalYearMonth(Period.ZERO.plusYears(firstYearOfCentury), iym.dataType());
+            }, "centuries"),
         DECADE(dt -> {
-            int year = dt.getYear();
-            int firstYearOfDecade = year - (year % 10);
-            return dt
-                .with(ChronoField.YEAR, firstYearOfDecade)
-                .with(ChronoField.MONTH_OF_YEAR, 1)
-                .with(ChronoField.DAY_OF_MONTH, 1)
-                .toLocalDate().atStartOfDay(dt.getZone());
-        }, "decades"),
-        YEAR(dt -> dt
-            .with(ChronoField.MONTH_OF_YEAR, 1)
-            .with(ChronoField.DAY_OF_MONTH, 1)
-            .toLocalDate().atStartOfDay(dt.getZone()),
-            "years", "yy", "yyyy"),
+                int year = dt.getYear();
+                int firstYearOfDecade = year - (year % 10);
+                return dt
+                    .with(ChronoField.YEAR, firstYearOfDecade)
+                    .with(ChronoField.MONTH_OF_YEAR, 1)
+                    .with(ChronoField.DAY_OF_MONTH, 1)
+                    .toLocalDate().atStartOfDay(dt.getZone());
+            },
+            idt -> new IntervalDayTime(Duration.ZERO, idt.dataType()),
+            iym -> {
+                Period period = iym.interval();
+                int year = period.getYears();
+                int firstYearOfDecade = year - (year % 10);
+                return new IntervalYearMonth(Period.ZERO.plusYears(firstYearOfDecade), iym.dataType());
+            }, "decades"),
+        YEAR(dt -> {
+                return dt.with(ChronoField.MONTH_OF_YEAR, 1)
+                    .with(ChronoField.DAY_OF_MONTH, 1)
+                    .toLocalDate().atStartOfDay(dt.getZone());
+            },
+            idt -> new IntervalDayTime(Duration.ZERO, idt.dataType()),
+            iym -> {
+                Period period = iym.interval();
+                int year = period.getYears();
+                return new IntervalYearMonth(Period.ZERO.plusYears(year), iym.dataType());
+            }, "years", "yy", "yyyy"),
         QUARTER(dt -> {
-            int month = dt.getMonthValue();
-            int firstMonthOfQuarter = (((month - 1) / 3) * 3) + 1;
-            return dt
-                .with(ChronoField.MONTH_OF_YEAR, firstMonthOfQuarter)
-                .with(ChronoField.DAY_OF_MONTH, 1)
-                .toLocalDate().atStartOfDay(dt.getZone());
-        }, "quarters", "qq", "q"),
-        MONTH(dt -> dt
-            .with(ChronoField.DAY_OF_MONTH, 1)
-            .toLocalDate().atStartOfDay(dt.getZone()),
-            "months", "mm", "m"),
-        WEEK(dt -> dt
-            .with(ChronoField.DAY_OF_WEEK, 1)
-            .toLocalDate().atStartOfDay(dt.getZone()),
-            "weeks", "wk", "ww"),
-        DAY(dt -> dt.toLocalDate().atStartOfDay(dt.getZone()), "days", "dd", "d"),
+                int month = dt.getMonthValue();
+                int firstMonthOfQuarter = (((month - 1) / 3) * 3) + 1;
+                return dt
+                    .with(ChronoField.MONTH_OF_YEAR, firstMonthOfQuarter)
+                    .with(ChronoField.DAY_OF_MONTH, 1)
+                    .toLocalDate().atStartOfDay(dt.getZone());
+            },
+            idt -> new IntervalDayTime(Duration.ZERO, (idt.dataType())),
+            iym -> {
+                Period period = iym.interval();
+                int month = period.getMonths();
+                int year = period.getYears();
+                int firstMonthOfQuarter = (month / 3) * 3;
+                return new IntervalYearMonth(Period.ZERO.plusYears(year).plusMonths(firstMonthOfQuarter), iym.dataType());
+            }, "quarters", "qq", "q"),
+        MONTH(dt -> {
+                return dt.with(ChronoField.DAY_OF_MONTH, 1)
+                    .toLocalDate().atStartOfDay(dt.getZone());
+            },
+            idt -> new IntervalDayTime(Duration.ZERO, idt.dataType()),
+            iym -> iym, "months", "mm", "m"),
+        WEEK(dt -> {
+                return dt.with(ChronoField.DAY_OF_WEEK, 1)
+                    .toLocalDate().atStartOfDay(dt.getZone());
+            },
+            idt -> new IntervalDayTime(Duration.ZERO, idt.dataType()),
+            iym -> iym, "weeks", "wk", "ww"),
+        DAY(dt -> dt.toLocalDate().atStartOfDay(dt.getZone()),
+            idt -> truncateIntervalSmallerThanWeek(idt, ChronoUnit.DAYS),
+            iym -> iym, "days", "dd", "d"),
         HOUR(dt -> {
-            int hour = dt.getHour();
-            return dt.toLocalDate().atStartOfDay(dt.getZone())
-                .with(ChronoField.HOUR_OF_DAY, hour);
-        }, "hours", "hh"),
+                int hour = dt.getHour();
+                return dt.toLocalDate().atStartOfDay(dt.getZone())
+                    .with(ChronoField.HOUR_OF_DAY, hour);
+            },
+            idt -> truncateIntervalSmallerThanWeek(idt, ChronoUnit.HOURS),
+            iym -> iym, "hours", "hh"),
         MINUTE(dt -> {
-            int hour = dt.getHour();
-            int minute = dt.getMinute();
-            return dt.toLocalDate().atStartOfDay(dt.getZone())
-                .with(ChronoField.HOUR_OF_DAY, hour)
-                .with(ChronoField.MINUTE_OF_HOUR, minute);
-        }, "minutes", "mi", "n"),
-        SECOND(dt -> dt.with(ChronoField.NANO_OF_SECOND, 0), "seconds", "ss", "s"),
+                int hour = dt.getHour();
+                int minute = dt.getMinute();
+                return dt.toLocalDate().atStartOfDay(dt.getZone())
+                    .with(ChronoField.HOUR_OF_DAY, hour)
+                    .with(ChronoField.MINUTE_OF_HOUR, minute);
+            },
+            idt -> truncateIntervalSmallerThanWeek(idt, ChronoUnit.MINUTES),
+            iym -> iym, "minutes", "mi", "n"),
+            SECOND(dt -> dt.with(ChronoField.NANO_OF_SECOND, 0),
+            idt -> truncateIntervalSmallerThanWeek(idt, ChronoUnit.SECONDS),
+            iym -> iym, "seconds", "ss", "s"),
         MILLISECOND(dt -> {
-            int micros = dt.get(ChronoField.MICRO_OF_SECOND);
-            return dt.with(ChronoField.MILLI_OF_SECOND, (micros / 1000));
-        }, "milliseconds", "ms"),
+                int micros = dt.get(ChronoField.MICRO_OF_SECOND);
+                return dt.with(ChronoField.MILLI_OF_SECOND, (micros / 1000));
+            },
+            idt -> truncateIntervalSmallerThanWeek(idt, ChronoUnit.MILLIS),
+            iym -> iym, "milliseconds", "ms"),
         MICROSECOND(dt -> {
-            int nanos = dt.getNano();
-            return dt.with(ChronoField.MICRO_OF_SECOND, (nanos / 1000));
-        }, "microseconds", "mcs"),
-        NANOSECOND(dt -> dt, "nanoseconds", "ns");
+                int nanos = dt.getNano();
+                return dt.with(ChronoField.MICRO_OF_SECOND, (nanos / 1000));
+            },
+            idt -> idt, iym -> iym, "microseconds", "mcs"),
+        NANOSECOND(dt -> dt, idt -> idt, iym -> iym, "nanoseconds", "ns");
 
         private static final Map<String, Part> NAME_TO_PART;
         private static final List<String> VALID_VALUES;
@@ -105,11 +167,16 @@ public class DateTrunc extends BinaryDateTimeFunction {
             VALID_VALUES = DateTimeField.initializeValidValues(values());
         }
 
-        private UnaryOperator<ZonedDateTime> truncateFunction;
+        private UnaryOperator<IntervalYearMonth> truncateFunctionIntervalYearMonth;
+        private UnaryOperator<ZonedDateTime> truncateFunctionZonedDateTime;
+        private UnaryOperator<IntervalDayTime> truncateFunctionIntervalDayTime;
         private Set<String> aliases;
 
-        Part(UnaryOperator<ZonedDateTime> truncateFunction, String... aliases) {
-            this.truncateFunction = truncateFunction;
+        Part(UnaryOperator<ZonedDateTime> truncateFunctionZonedDateTime, UnaryOperator<IntervalDayTime> truncateFunctionIntervalDayTime,
+             UnaryOperator<IntervalYearMonth> truncateFunctionIntervalYearMonth, String... aliases) {
+            this.truncateFunctionIntervalYearMonth = truncateFunctionIntervalYearMonth;
+            this.truncateFunctionZonedDateTime = truncateFunctionZonedDateTime;
+            this.truncateFunctionIntervalDayTime = truncateFunctionIntervalDayTime;
             this.aliases = Set.of(aliases);
         }
 
@@ -127,7 +194,50 @@ public class DateTrunc extends BinaryDateTimeFunction {
         }
 
         public ZonedDateTime truncate(ZonedDateTime dateTime) {
-            return truncateFunction.apply(dateTime);
+            return truncateFunctionZonedDateTime.apply(dateTime);
+        }
+
+        public IntervalDayTime truncate(IntervalDayTime dateTime) {
+            return truncateFunctionIntervalDayTime.apply(dateTime);
+        }
+
+        public IntervalYearMonth truncate(IntervalYearMonth dateTime) {
+            return truncateFunctionIntervalYearMonth.apply(dateTime);
+        }
+
+        private static IntervalDayTime truncateIntervalSmallerThanWeek(IntervalDayTime r, ChronoUnit unit) {
+            Duration d = r.interval();
+            int isNegative = 1;
+            if (d.isNegative()) {
+                d = d.negated();
+                isNegative = -1;
+            }
+            long durationInSec = d.getSeconds();
+            long day = durationInSec / SECONDS_PER_DAY;
+            durationInSec = durationInSec % SECONDS_PER_DAY;
+            long hour = durationInSec / SECONDS_PER_HOUR;
+            durationInSec = durationInSec % SECONDS_PER_HOUR;
+            long min = durationInSec / SECONDS_PER_MINUTE;
+            durationInSec = durationInSec % SECONDS_PER_MINUTE;
+            long sec = durationInSec;
+            long miliseccond = TimeUnit.NANOSECONDS.toMillis(d.getNano());
+            Duration newDuration = Duration.ZERO;
+            if (unit.ordinal() <= ChronoUnit.DAYS.ordinal()) {
+                newDuration = newDuration.plusDays(day * isNegative);
+            }
+            if (unit.ordinal() <= ChronoUnit.HOURS.ordinal()) {
+                newDuration = newDuration.plusHours(hour * isNegative);
+            }
+            if (unit.ordinal() <= ChronoUnit.MINUTES.ordinal()) {
+                newDuration = newDuration.plusMinutes(min * isNegative);
+            }
+            if (unit.ordinal() <= ChronoUnit.SECONDS.ordinal()) {
+                newDuration = newDuration.plusSeconds(sec * isNegative);
+            }
+            if (unit.ordinal() <= ChronoUnit.MILLIS.ordinal()) {
+                newDuration = newDuration.plusMillis(miliseccond * isNegative);
+            }
+            return new IntervalDayTime(newDuration, r.dataType());
         }
     }
 
@@ -137,9 +247,25 @@ public class DateTrunc extends BinaryDateTimeFunction {
 
     @Override
     public DataType dataType() {
+        if (isInterval(right().dataType())) {
+            return right().dataType();
+        }
         return DataTypes.DATETIME;
     }
 
+    @Override
+    protected TypeResolution resolveType() {
+        TypeResolution resolution = super.resolveType();
+        if (resolution.unresolved()) {
+            return resolution;
+        }
+        resolution = isDateOrInterval(right(), sourceText(), Expressions.ParamOrdinal.SECOND);
+        if (resolution.unresolved()) {
+            return resolution;
+        }
+        return TypeResolution.TYPE_RESOLVED;
+    }
+
     @Override
     protected BinaryScalarFunction replaceChildren(Expression newTruncateTo, Expression newTimestamp) {
         return new DateTrunc(source(), newTruncateTo, newTimestamp, zoneId());

+ 19 - 5
x-pack/plugin/sql/src/main/java/org/elasticsearch/xpack/sql/expression/function/scalar/datetime/DateTruncProcessor.java

@@ -6,15 +6,18 @@
 package org.elasticsearch.xpack.sql.expression.function.scalar.datetime;
 
 import org.elasticsearch.common.io.stream.StreamInput;
-import org.elasticsearch.xpack.ql.expression.gen.processor.Processor;
 import org.elasticsearch.xpack.sql.SqlIllegalArgumentException;
-import org.elasticsearch.xpack.sql.expression.function.scalar.datetime.DateTrunc.Part;
+import org.elasticsearch.xpack.ql.expression.gen.processor.Processor;
+import org.elasticsearch.xpack.sql.expression.literal.interval.IntervalDayTime;
+import org.elasticsearch.xpack.sql.expression.literal.interval.IntervalYearMonth;
 
 import java.io.IOException;
 import java.time.ZoneId;
 import java.time.ZonedDateTime;
 import java.util.List;
 
+import static org.elasticsearch.xpack.sql.expression.function.scalar.datetime.DateTrunc.Part;
+
 public class DateTruncProcessor extends BinaryDateTimeProcessor {
 
     public static final String NAME = "dtrunc";
@@ -59,10 +62,21 @@ public class DateTruncProcessor extends BinaryDateTimeProcessor {
             }
         }
 
-        if (timestamp instanceof ZonedDateTime == false) {
-            throw new SqlIllegalArgumentException("A date/datetime is required; received [{}]", timestamp);
+        if (timestamp instanceof ZonedDateTime == false && timestamp instanceof IntervalYearMonth == false
+            && timestamp instanceof IntervalDayTime == false) {
+            throw new SqlIllegalArgumentException("A date/datetime/interval is required; received [{}]", timestamp);
+        }
+        if (truncateDateField == Part.WEEK && (timestamp instanceof IntervalDayTime || timestamp instanceof IntervalYearMonth)) {
+            throw new SqlIllegalArgumentException("Truncating intervals is not supported for {} units", truncateTo);
+        }
+
+        if (timestamp instanceof ZonedDateTime) {
+            return truncateDateField.truncate(((ZonedDateTime) timestamp).withZoneSameInstant(zoneId));
+        } else if (timestamp instanceof IntervalYearMonth) {
+            return truncateDateField.truncate((IntervalYearMonth) timestamp);
+        } else {
+            return truncateDateField.truncate((IntervalDayTime) timestamp);
         }
 
-        return truncateDateField.truncate(((ZonedDateTime) timestamp).withZoneSameInstant(zoneId));
     }
 }

+ 5 - 2
x-pack/plugin/sql/src/main/java/org/elasticsearch/xpack/sql/expression/function/scalar/whitelist/InternalSqlScriptUtils.java

@@ -281,8 +281,11 @@ public class InternalSqlScriptUtils extends InternalQlScriptUtils {
         return (Integer) DateDiffProcessor.process(dateField, asDateTime(dateTime1), asDateTime(dateTime2) , ZoneId.of(tzId));
     }
 
-    public static ZonedDateTime dateTrunc(String truncateTo, Object dateTime, String tzId) {
-        return (ZonedDateTime) DateTruncProcessor.process(truncateTo, asDateTime(dateTime) , ZoneId.of(tzId));
+    public static Object dateTrunc(String truncateTo, Object dateTimeOrInterval, String tzId) {
+        if (dateTimeOrInterval instanceof IntervalDayTime || dateTimeOrInterval instanceof IntervalYearMonth) {
+           return DateTruncProcessor.process(truncateTo, dateTimeOrInterval, ZoneId.of(tzId));
+        }
+        return DateTruncProcessor.process(truncateTo, asDateTime(dateTimeOrInterval), ZoneId.of(tzId));
     }
 
     public static Integer datePart(String dateField, Object dateTime, String tzId) {

+ 4 - 1
x-pack/plugin/sql/src/main/java/org/elasticsearch/xpack/sql/type/SqlDataTypes.java

@@ -255,6 +255,10 @@ public class SqlDataTypes {
         return isDateBased(type) || isTimeBased(type);
     }
 
+    public static boolean isDateOrIntervalBased(DataType type) {
+        return isDateBased(type) || isInterval(type);
+    }
+
     public static boolean isGeo(DataType type) {
         return type == GEO_POINT || type == GEO_SHAPE || type == SHAPE;
     }
@@ -262,7 +266,6 @@ public class SqlDataTypes {
     public static String format(DataType type) {
         return isDateOrTimeBased(type) ? "epoch_millis" : null;
     }
-    
 
     public static boolean isFromDocValuesOnly(DataType dataType) {
         return dataType == KEYWORD // because of ignore_above. Extracting this from _source wouldn't make sense

+ 1 - 1
x-pack/plugin/sql/src/main/resources/org/elasticsearch/xpack/sql/plugin/sql_whitelist.txt

@@ -118,7 +118,7 @@ class org.elasticsearch.xpack.sql.expression.function.scalar.whitelist.InternalS
   Integer weekOfYear(Object, String)
   ZonedDateTime dateAdd(String, Integer, Object, String)
   Integer dateDiff(String, Object, Object, String)
-  ZonedDateTime dateTrunc(String, Object, String)
+  def dateTrunc(String, Object, String)
   Integer datePart(String, Object, String)
   IntervalDayTime intervalDayTime(String, String)
   IntervalYearMonth intervalYearMonth(String, String)

+ 4 - 4
x-pack/plugin/sql/src/test/java/org/elasticsearch/xpack/sql/analysis/analyzer/VerifierErrorMessagesTests.java

@@ -215,8 +215,8 @@ public class VerifierErrorMessagesTests extends ESTestCase {
     public void testDateTruncInvalidArgs() {
         assertEquals("1:8: first argument of [DATE_TRUNC(int, date)] must be [string], found value [int] type [integer]",
             error("SELECT DATE_TRUNC(int, date) FROM test"));
-        assertEquals("1:8: second argument of [DATE_TRUNC(keyword, keyword)] must be [date or datetime], found value [keyword] " +
-                "type [keyword]", error("SELECT DATE_TRUNC(keyword, keyword) FROM test"));
+        assertEquals("1:8: second argument of [DATE_TRUNC(keyword, keyword)] must be [date, datetime or an interval data type]," +
+            " found value [keyword] type [keyword]", error("SELECT DATE_TRUNC(keyword, keyword) FROM test"));
         assertEquals("1:8: first argument of [DATE_TRUNC('invalid', keyword)] must be one of [MILLENNIUM, CENTURY, DECADE, " + "" +
                 "YEAR, QUARTER, MONTH, WEEK, DAY, HOUR, MINUTE, SECOND, MILLISECOND, MICROSECOND, NANOSECOND] " +
                 "or their aliases; found value ['invalid']",
@@ -535,11 +535,11 @@ public class VerifierErrorMessagesTests extends ESTestCase {
         assertEquals("1:26: Cannot use field [unsupported] with unsupported type [ip_range]",
                 error("SELECT * FROM test WHERE unsupported > 1"));
     }
-    
+
     public void testValidRootFieldWithUnsupportedChildren() {
         accept("SELECT x FROM test");
     }
-    
+
     public void testUnsupportedTypeInHierarchy() {
         assertEquals("1:8: Cannot use field [x.y.z.w] with unsupported type [foobar] in hierarchy (field [y])",
                 error("SELECT x.y.z.w FROM test"));

+ 160 - 1
x-pack/plugin/sql/src/test/java/org/elasticsearch/xpack/sql/expression/function/scalar/datetime/DateTruncProcessorTests.java

@@ -11,18 +11,29 @@ import org.elasticsearch.test.ESTestCase;
 import org.elasticsearch.xpack.ql.expression.Literal;
 import org.elasticsearch.xpack.ql.expression.gen.processor.ConstantProcessor;
 import org.elasticsearch.xpack.ql.tree.Source;
+import org.elasticsearch.xpack.ql.type.DataType;
 import org.elasticsearch.xpack.sql.AbstractSqlWireSerializingTestCase;
 import org.elasticsearch.xpack.sql.SqlIllegalArgumentException;
+import org.elasticsearch.xpack.sql.expression.literal.interval.IntervalDayTime;
+import org.elasticsearch.xpack.sql.expression.literal.interval.IntervalYearMonth;
+import org.elasticsearch.xpack.sql.proto.StringUtils;
+import org.elasticsearch.xpack.sql.type.SqlDataTypes;
 import org.elasticsearch.xpack.sql.util.DateUtils;
 
+import java.time.Duration;
+import java.time.Period;
 import java.time.ZoneId;
 import java.time.ZonedDateTime;
+import java.time.temporal.TemporalAmount;
 
 import static org.elasticsearch.xpack.ql.expression.Literal.NULL;
 import static org.elasticsearch.xpack.ql.expression.function.scalar.FunctionTestUtils.l;
 import static org.elasticsearch.xpack.ql.expression.function.scalar.FunctionTestUtils.randomDatetimeLiteral;
+import static org.elasticsearch.xpack.ql.tree.Source.EMPTY;
 import static org.elasticsearch.xpack.sql.expression.function.scalar.datetime.DateTimeTestUtils.dateTime;
 import static org.elasticsearch.xpack.sql.proto.StringUtils.ISO_DATE_WITH_NANOS;
+import static org.elasticsearch.xpack.sql.type.SqlDataTypes.INTERVAL_DAY_TO_SECOND;
+import static org.elasticsearch.xpack.sql.type.SqlDataTypes.INTERVAL_YEAR_TO_MONTH;
 
 public class DateTruncProcessorTests extends AbstractSqlWireSerializingTestCase<DateTruncProcessor> {
 
@@ -57,13 +68,18 @@ public class DateTruncProcessorTests extends AbstractSqlWireSerializingTestCase<
     }
 
     public void testInvalidInputs() {
+        TemporalAmount period = Period.ofYears(2018).plusMonths(11);
+        Literal yearToMonth = intervalLiteral(period, INTERVAL_YEAR_TO_MONTH);
+        TemporalAmount duration = Duration.ofDays(42).plusHours(12).plusMinutes(23).plusSeconds(12).plusNanos(143000000);
+        Literal dayToSecond = intervalLiteral(duration, INTERVAL_DAY_TO_SECOND);
+
         SqlIllegalArgumentException siae = expectThrows(SqlIllegalArgumentException.class,
             () -> new DateTrunc(Source.EMPTY, l(5), randomDatetimeLiteral(), randomZone()).makePipe().asProcessor().process(null));
         assertEquals("A string is required; received [5]", siae.getMessage());
 
         siae = expectThrows(SqlIllegalArgumentException.class,
             () -> new DateTrunc(Source.EMPTY, l("days"), l("foo"), randomZone()).makePipe().asProcessor().process(null));
-        assertEquals("A date/datetime is required; received [foo]", siae.getMessage());
+        assertEquals("A date/datetime/interval is required; received [foo]", siae.getMessage());
 
         siae = expectThrows(SqlIllegalArgumentException.class,
             () -> new DateTrunc(Source.EMPTY, l("invalid"), randomDatetimeLiteral(), randomZone()).makePipe().asProcessor().process(null));
@@ -75,6 +91,16 @@ public class DateTruncProcessorTests extends AbstractSqlWireSerializingTestCase<
             () -> new DateTrunc(Source.EMPTY, l("dacede"), randomDatetimeLiteral(), randomZone()).makePipe().asProcessor().process(null));
         assertEquals("Received value [dacede] is not valid date part for truncation; did you mean [decade, decades]?",
             siae.getMessage());
+
+        siae = expectThrows(SqlIllegalArgumentException.class,
+            () -> new DateTrunc(Source.EMPTY, l("weeks"), yearToMonth, null).makePipe().asProcessor().process(null));
+        assertEquals("Truncating intervals is not supported for weeks units",
+            siae.getMessage());
+
+        siae = expectThrows(SqlIllegalArgumentException.class,
+            () -> new DateTrunc(Source.EMPTY, l("week"), dayToSecond, null).makePipe().asProcessor().process(null));
+        assertEquals("Truncating intervals is not supported for week units",
+            siae.getMessage());
     }
 
     public void testWithNulls() {
@@ -86,6 +112,10 @@ public class DateTruncProcessorTests extends AbstractSqlWireSerializingTestCase<
     public void testTruncation() {
         ZoneId zoneId = ZoneId.of("Etc/GMT-10");
         Literal dateTime = l(dateTime(2019, 9, 3, 18, 10, 37, 123456789));
+        TemporalAmount period = Period.ofYears(2019).plusMonths(10);
+        Literal yearToMonth = intervalLiteral(period, INTERVAL_YEAR_TO_MONTH);
+        TemporalAmount duration = Duration.ofDays(105).plusHours(2).plusMinutes(45).plusSeconds(55).plusNanos(123456789);
+        Literal dayToSecond = intervalLiteral(duration, INTERVAL_DAY_TO_SECOND);
 
         assertEquals("2000-01-01T00:00:00.000+10:00",
             DateUtils.toString((ZonedDateTime) new DateTrunc(Source.EMPTY, l("millennia"), dateTime, zoneId)
@@ -129,6 +159,86 @@ public class DateTruncProcessorTests extends AbstractSqlWireSerializingTestCase<
         assertEquals("2019-09-04T04:10:37.123456789+10:00",
             toString((ZonedDateTime) new DateTrunc(Source.EMPTY, l("nanoseconds"), dateTime, zoneId)
                 .makePipe().asProcessor().process(null)));
+
+        assertEquals("+2000-0",
+            toString((IntervalYearMonth) new DateTrunc(Source.EMPTY, l("millennia"), yearToMonth, null)
+                .makePipe().asProcessor().process(null)));
+        assertEquals("+2000-0",
+            toString((IntervalYearMonth) new DateTrunc(Source.EMPTY, l("CENTURY"), yearToMonth, null)
+                .makePipe().asProcessor().process(null)));
+        assertEquals("+2010-0",
+            toString((IntervalYearMonth) new DateTrunc(Source.EMPTY, l("decades"), yearToMonth, null)
+                .makePipe().asProcessor().process(null)));
+        assertEquals("+2019-0",
+            toString((IntervalYearMonth) new DateTrunc(Source.EMPTY, l("years"), yearToMonth, null)
+                .makePipe().asProcessor().process(null)));
+        assertEquals("+2019-9",
+            toString((IntervalYearMonth) new DateTrunc(Source.EMPTY, l("quarters"), yearToMonth, null)
+                .makePipe().asProcessor().process(null)));
+        assertEquals("+2019-10",
+            toString((IntervalYearMonth) new DateTrunc(Source.EMPTY, l("month"), yearToMonth, null)
+                .makePipe().asProcessor().process(null)));
+        assertEquals("+2019-10",
+            toString((IntervalYearMonth) new DateTrunc(Source.EMPTY, l("days"), yearToMonth, null)
+                .makePipe().asProcessor().process(null)));
+        assertEquals("+2019-10",
+            toString((IntervalYearMonth) new DateTrunc(Source.EMPTY, l("hh"), yearToMonth, null)
+                .makePipe().asProcessor().process(null)));
+        assertEquals("+2019-10",
+            toString((IntervalYearMonth) new DateTrunc(Source.EMPTY, l("mi"), yearToMonth, null)
+                .makePipe().asProcessor().process(null)));
+        assertEquals("+2019-10",
+            toString((IntervalYearMonth) new DateTrunc(Source.EMPTY, l("second"), yearToMonth, null)
+                .makePipe().asProcessor().process(null)));
+        assertEquals("+2019-10",
+            toString((IntervalYearMonth) new DateTrunc(Source.EMPTY, l("ms"), yearToMonth, null)
+                .makePipe().asProcessor().process(null)));
+        assertEquals("+2019-10",
+            toString((IntervalYearMonth) new DateTrunc(Source.EMPTY, l("mcs"), yearToMonth, null)
+                .makePipe().asProcessor().process(null)));
+        assertEquals("+2019-10",
+            toString((IntervalYearMonth) new DateTrunc(Source.EMPTY, l("nanoseconds"), yearToMonth, null)
+                .makePipe().asProcessor().process(null)));
+
+        assertEquals("+0 00:00:00",
+            toString((IntervalDayTime) new DateTrunc(Source.EMPTY, l("millennia"), dayToSecond, null)
+                .makePipe().asProcessor().process(null)));
+        assertEquals("+0 00:00:00",
+            toString((IntervalDayTime) new DateTrunc(Source.EMPTY, l("CENTURY"), dayToSecond, null)
+                .makePipe().asProcessor().process(null)));
+        assertEquals("+0 00:00:00",
+            toString((IntervalDayTime) new DateTrunc(Source.EMPTY, l("decades"), dayToSecond, null)
+                .makePipe().asProcessor().process(null)));
+        assertEquals("+0 00:00:00",
+            toString((IntervalDayTime) new DateTrunc(Source.EMPTY, l("years"), dayToSecond, null)
+                .makePipe().asProcessor().process(null)));
+        assertEquals("+0 00:00:00",
+            toString((IntervalDayTime) new DateTrunc(Source.EMPTY, l("quarters"), dayToSecond, null)
+                .makePipe().asProcessor().process(null)));
+        assertEquals("+0 00:00:00",
+            toString((IntervalDayTime) new DateTrunc(Source.EMPTY, l("month"), dayToSecond, null)
+                .makePipe().asProcessor().process(null)));
+        assertEquals("+105 00:00:00",
+            toString((IntervalDayTime) new DateTrunc(Source.EMPTY, l("days"), dayToSecond, null)
+                .makePipe().asProcessor().process(null)));
+        assertEquals("+105 02:00:00",
+            toString((IntervalDayTime) new DateTrunc(Source.EMPTY, l("hh"), dayToSecond, null)
+                .makePipe().asProcessor().process(null)));
+        assertEquals("+105 02:45:00",
+            toString((IntervalDayTime) new DateTrunc(Source.EMPTY, l("mi"), dayToSecond, null)
+                .makePipe().asProcessor().process(null)));
+        assertEquals("+105 02:45:55",
+            toString((IntervalDayTime) new DateTrunc(Source.EMPTY, l("second"), dayToSecond, null)
+                .makePipe().asProcessor().process(null)));
+        assertEquals("+105 02:45:55.123",
+            toString((IntervalDayTime) new DateTrunc(Source.EMPTY, l("ms"), dayToSecond, null)
+                .makePipe().asProcessor().process(null)));
+        assertEquals("+105 02:45:55.123",
+            toString((IntervalDayTime) new DateTrunc(Source.EMPTY, l("microseconds"), dayToSecond, null)
+                .makePipe().asProcessor().process(null)));
+        assertEquals("+105 02:45:55.123",
+            toString((IntervalDayTime) new DateTrunc(Source.EMPTY, l("nanoseconds"), dayToSecond, null)
+                .makePipe().asProcessor().process(null)));
     }
 
     public void testTruncationEdgeCases() {
@@ -152,9 +262,58 @@ public class DateTruncProcessorTests extends AbstractSqlWireSerializingTestCase<
         assertEquals("-1234-08-29T00:00:00.000+10:00",
             DateUtils.toString((ZonedDateTime) new DateTrunc(Source.EMPTY, l("week"), dateTime, zoneId)
                 .makePipe().asProcessor().process(null)));
+
+        Literal yearToMonth = intervalLiteral(Period.ofYears(-12523).minusMonths(10), INTERVAL_YEAR_TO_MONTH);
+        assertEquals("-12000-0", toString((IntervalYearMonth) new DateTrunc(Source.EMPTY, l("millennia"), yearToMonth, null)
+            .makePipe().asProcessor().process(null)));
+
+        yearToMonth = intervalLiteral(Period.ofYears(-32543).minusMonths(10), INTERVAL_YEAR_TO_MONTH);
+        assertEquals("-32500-0", toString((IntervalYearMonth) new DateTrunc(Source.EMPTY, l("centuries"), yearToMonth, null)
+            .makePipe().asProcessor().process(null)));
+
+        yearToMonth = intervalLiteral(Period.ofYears(-24321).minusMonths(10), INTERVAL_YEAR_TO_MONTH);
+        assertEquals("-24320-0", toString((IntervalYearMonth) new DateTrunc(Source.EMPTY, l("decades"), yearToMonth, null)
+            .makePipe().asProcessor().process(null)));
+
+        Literal dayToSecond = intervalLiteral(Duration.ofDays(-435).minusHours(23).minusMinutes(45).minusSeconds(55).minusNanos(123000000),
+            INTERVAL_DAY_TO_SECOND);
+        assertEquals("-435 00:00:00", toString((IntervalDayTime) new DateTrunc(Source.EMPTY, l("days"), dayToSecond, null)
+            .makePipe().asProcessor().process(null)));
+
+        dayToSecond = intervalLiteral(Duration.ofDays(-4231).minusHours(23).minusMinutes(45).minusSeconds(55).minusNanos(234000000),
+            INTERVAL_DAY_TO_SECOND);
+        assertEquals("-4231 23:00:00", toString((IntervalDayTime) new DateTrunc(Source.EMPTY, l("hh"), dayToSecond, null)
+            .makePipe().asProcessor().process(null)));
+
+        dayToSecond = intervalLiteral(Duration.ofDays(-124).minusHours(0).minusMinutes(59).minusSeconds(11).minusNanos(564000000),
+            INTERVAL_DAY_TO_SECOND);
+        assertEquals("-124 00:59:00", toString((IntervalDayTime) new DateTrunc(Source.EMPTY, l("mi"), dayToSecond, null)
+            .makePipe().asProcessor().process(null)));
+
+        dayToSecond = intervalLiteral(Duration.ofDays(-534).minusHours(23).minusMinutes(59).minusSeconds(59).minusNanos(245000000),
+            INTERVAL_DAY_TO_SECOND);
+        assertEquals("-534 23:59:59", toString((IntervalDayTime) new DateTrunc(Source.EMPTY, l("seconds"), dayToSecond, null)
+            .makePipe().asProcessor().process(null)));
+
+        dayToSecond = intervalLiteral(Duration.ofDays(-127).minusHours(17).minusMinutes(59).minusSeconds(59).minusNanos(987654321),
+            INTERVAL_DAY_TO_SECOND);
+        assertEquals("-127 17:59:59.987", toString((IntervalDayTime) new DateTrunc(Source.EMPTY, l("ms"), dayToSecond, null)
+            .makePipe().asProcessor().process(null)));
+    }
+    private String toString(IntervalYearMonth intervalYearMonth) {
+        return StringUtils.toString(intervalYearMonth);
     }
 
+    private String toString(IntervalDayTime intervalDayTime) {
+        return StringUtils.toString(intervalDayTime);
+    }
     private String toString(ZonedDateTime dateTime) {
         return ISO_DATE_WITH_NANOS.format(dateTime);
     }
+
+    private static Literal intervalLiteral(TemporalAmount value, DataType intervalType) {
+        Object interval = value instanceof Period ? new IntervalYearMonth((Period) value, intervalType)
+            : new IntervalDayTime((Duration) value, intervalType);
+        return new Literal(EMPTY, interval, SqlDataTypes.fromJava(interval));
+    }
 }