diff --git a/docs/changelog/120302.yaml b/docs/changelog/120302.yaml new file mode 100644 index 0000000000000..29202b4858f80 --- /dev/null +++ b/docs/changelog/120302.yaml @@ -0,0 +1,6 @@ +pr: 120302 +summary: "ESQL: Enhanced `DATE_TRUNC` with arbitrary intervals" +area: ES|QL +type: enhancement +issues: + - 120094 diff --git a/docs/reference/esql/functions/kibana/definition/date_trunc.json b/docs/reference/esql/functions/kibana/definition/date_trunc.json index cdda984a0ce7e..cc6bd15de76d8 100644 --- a/docs/reference/esql/functions/kibana/definition/date_trunc.json +++ b/docs/reference/esql/functions/kibana/definition/date_trunc.json @@ -2,7 +2,7 @@ "comment" : "This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.", "type" : "eval", "name" : "date_trunc", - "description" : "Rounds down a date to the closest interval.", + "description": "Rounds down a date to the closest interval since epoch, which starts at `0001-01-01T00:00:00Z`.", "signatures" : [ { "params" : [ diff --git a/docs/reference/esql/functions/kibana/docs/date_trunc.md b/docs/reference/esql/functions/kibana/docs/date_trunc.md index 6aa81ebbac3c3..baa36d79d2f93 100644 --- a/docs/reference/esql/functions/kibana/docs/date_trunc.md +++ b/docs/reference/esql/functions/kibana/docs/date_trunc.md @@ -3,7 +3,9 @@ This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../READ --> ### DATE_TRUNC -Rounds down a date to the closest interval. + +Rounds down a date to the closest interval since epoch, which starts +at `0001-01-01T00:00:00Z`. ``` FROM employees diff --git a/server/src/main/java/org/elasticsearch/common/Rounding.java b/server/src/main/java/org/elasticsearch/common/Rounding.java index f37f045fae41d..9c8a2e989f8cb 100644 --- a/server/src/main/java/org/elasticsearch/common/Rounding.java +++ b/server/src/main/java/org/elasticsearch/common/Rounding.java @@ -59,8 +59,9 @@ public enum DateTimeUnit { WEEK_OF_WEEKYEAR((byte) 1, "week", IsoFields.WEEK_OF_WEEK_BASED_YEAR, true, TimeUnit.DAYS.toMillis(7)) { private final long extraLocalOffsetLookup = TimeUnit.DAYS.toMillis(7); - long roundFloor(long utcMillis) { - return DateUtils.roundWeekOfWeekYear(utcMillis); + @Override + long roundFloor(long utcMillis, int multiplier) { + return DateUtils.roundWeekIntervalOfWeekYear(utcMillis, multiplier); } @Override @@ -71,10 +72,12 @@ long extraLocalOffsetLookup() { YEAR_OF_CENTURY((byte) 2, "year", ChronoField.YEAR_OF_ERA, false, 12) { private final long extraLocalOffsetLookup = TimeUnit.DAYS.toMillis(366); - long roundFloor(long utcMillis) { - return DateUtils.roundYear(utcMillis); + @Override + long roundFloor(long utcMillis, int multiplier) { + return multiplier == 1 ? DateUtils.roundYear(utcMillis) : DateUtils.roundYearInterval(utcMillis, multiplier); } + @Override long extraLocalOffsetLookup() { return extraLocalOffsetLookup; } @@ -82,10 +85,14 @@ long extraLocalOffsetLookup() { QUARTER_OF_YEAR((byte) 3, "quarter", IsoFields.QUARTER_OF_YEAR, false, 3) { private final long extraLocalOffsetLookup = TimeUnit.DAYS.toMillis(92); - long roundFloor(long utcMillis) { - return DateUtils.roundQuarterOfYear(utcMillis); + @Override + long roundFloor(long utcMillis, int multiplier) { + return multiplier == 1 + ? DateUtils.roundQuarterOfYear(utcMillis) + : DateUtils.roundIntervalMonthOfYear(utcMillis, multiplier * 3); } + @Override long extraLocalOffsetLookup() { return extraLocalOffsetLookup; } @@ -93,28 +100,34 @@ long extraLocalOffsetLookup() { MONTH_OF_YEAR((byte) 4, "month", ChronoField.MONTH_OF_YEAR, false, 1) { private final long extraLocalOffsetLookup = TimeUnit.DAYS.toMillis(31); - long roundFloor(long utcMillis) { - return DateUtils.roundMonthOfYear(utcMillis); + @Override + long roundFloor(long utcMillis, int multiplier) { + return multiplier == 1 ? DateUtils.roundMonthOfYear(utcMillis) : DateUtils.roundIntervalMonthOfYear(utcMillis, multiplier); } + @Override long extraLocalOffsetLookup() { return extraLocalOffsetLookup; } }, DAY_OF_MONTH((byte) 5, "day", ChronoField.DAY_OF_MONTH, true, ChronoField.DAY_OF_MONTH.getBaseUnit().getDuration().toMillis()) { - long roundFloor(long utcMillis) { - return DateUtils.roundFloor(utcMillis, this.ratio); + @Override + long roundFloor(long utcMillis, int multiplier) { + return DateUtils.roundFloor(utcMillis, this.ratio * multiplier); } + @Override long extraLocalOffsetLookup() { return ratio; } }, HOUR_OF_DAY((byte) 6, "hour", ChronoField.HOUR_OF_DAY, true, ChronoField.HOUR_OF_DAY.getBaseUnit().getDuration().toMillis()) { - long roundFloor(long utcMillis) { - return DateUtils.roundFloor(utcMillis, ratio); + @Override + long roundFloor(long utcMillis, int multiplier) { + return DateUtils.roundFloor(utcMillis, ratio * multiplier); } + @Override long extraLocalOffsetLookup() { return ratio; } @@ -126,10 +139,12 @@ long extraLocalOffsetLookup() { true, ChronoField.MINUTE_OF_HOUR.getBaseUnit().getDuration().toMillis() ) { - long roundFloor(long utcMillis) { - return DateUtils.roundFloor(utcMillis, ratio); + @Override + long roundFloor(long utcMillis, int multiplier) { + return DateUtils.roundFloor(utcMillis, ratio * multiplier); } + @Override long extraLocalOffsetLookup() { return ratio; } @@ -141,10 +156,12 @@ long extraLocalOffsetLookup() { true, ChronoField.SECOND_OF_MINUTE.getBaseUnit().getDuration().toMillis() ) { - long roundFloor(long utcMillis) { - return DateUtils.roundFloor(utcMillis, ratio); + @Override + long roundFloor(long utcMillis, int multiplier) { + return DateUtils.roundFloor(utcMillis, ratio * multiplier); } + @Override long extraLocalOffsetLookup() { return ratio; } @@ -171,10 +188,11 @@ long extraLocalOffsetLookup() { * This rounds down the supplied milliseconds since the epoch down to the next unit. In order to retain performance this method * should be as fast as possible and not try to convert dates to java-time objects if possible * - * @param utcMillis the milliseconds since the epoch - * @return the rounded down milliseconds since the epoch + * @param utcMillis the milliseconds since the epoch + * @param multiplier the factor by which the unit is multiplied + * @return the rounded down milliseconds since the epoch */ - abstract long roundFloor(long utcMillis); + abstract long roundFloor(long utcMillis, int multiplier); /** * When looking up {@link LocalTimeOffset} go this many milliseconds @@ -329,17 +347,24 @@ public static class Builder { private final DateTimeUnit unit; private final long interval; + private final int multiplier; private ZoneId timeZone = ZoneOffset.UTC; private long offset = 0; public Builder(DateTimeUnit unit) { + this(unit, 1); + } + + public Builder(DateTimeUnit unit, int multiplier) { this.unit = unit; + this.multiplier = multiplier; this.interval = -1; } public Builder(TimeValue interval) { this.unit = null; + this.multiplier = -1; if (interval.millis() < 1) throw new IllegalArgumentException("Zero or negative time interval not supported"); this.interval = interval.millis(); } @@ -365,7 +390,7 @@ public Builder offset(long offset) { public Rounding build() { Rounding rounding; if (unit != null) { - rounding = new TimeUnitRounding(unit, timeZone); + rounding = new TimeUnitRounding(unit, multiplier, timeZone); } else { rounding = new TimeIntervalRounding(interval, timeZone); } @@ -422,11 +447,17 @@ static class TimeUnitRounding extends Rounding { private final DateTimeUnit unit; private final ZoneId timeZone; private final boolean unitRoundsToMidnight; + private final int multiplier; TimeUnitRounding(DateTimeUnit unit, ZoneId timeZone) { + this(unit, 1, timeZone); + } + + TimeUnitRounding(DateTimeUnit unit, int multiplier, ZoneId timeZone) { this.unit = unit; this.timeZone = timeZone; this.unitRoundsToMidnight = this.unit.field.getBaseUnit().getDuration().toMillis() > 3600000L; + this.multiplier = multiplier; } TimeUnitRounding(StreamInput in) throws IOException { @@ -660,7 +691,7 @@ private class FixedToMidnightRounding extends TimeUnitPreparedRounding { @Override public long round(long utcMillis) { - return offset.localToUtcInThisOffset(unit.roundFloor(offset.utcToLocalTime(utcMillis))); + return offset.localToUtcInThisOffset(unit.roundFloor(offset.utcToLocalTime(utcMillis), multiplier)); } @Override @@ -686,7 +717,7 @@ private class FixedNotToMidnightRounding extends TimeUnitPreparedRounding { @Override public long round(long utcMillis) { - return offset.localToUtcInThisOffset(unit.roundFloor(offset.utcToLocalTime(utcMillis))); + return offset.localToUtcInThisOffset(unit.roundFloor(offset.utcToLocalTime(utcMillis), multiplier)); } @Override @@ -710,7 +741,7 @@ private class ToMidnightRounding extends TimeUnitPreparedRounding implements Loc @Override public long round(long utcMillis) { LocalTimeOffset offset = lookup.lookup(utcMillis); - return offset.localToUtc(unit.roundFloor(offset.utcToLocalTime(utcMillis)), this); + return offset.localToUtc(unit.roundFloor(offset.utcToLocalTime(utcMillis), multiplier), this); } @Override @@ -764,14 +795,14 @@ private class NotToMidnightRounding extends AbstractNotToMidnightRounding implem @Override public long round(long utcMillis) { LocalTimeOffset offset = lookup.lookup(utcMillis); - long roundedLocalMillis = unit.roundFloor(offset.utcToLocalTime(utcMillis)); + long roundedLocalMillis = unit.roundFloor(offset.utcToLocalTime(utcMillis), multiplier); return offset.localToUtc(roundedLocalMillis, this); } @Override public long inGap(long localMillis, Gap gap) { // Round from just before the start of the gap - return gap.previous().localToUtc(unit.roundFloor(gap.firstMissingLocalTime() - 1), this); + return gap.previous().localToUtc(unit.roundFloor(gap.firstMissingLocalTime() - 1, multiplier), this); } @Override diff --git a/server/src/main/java/org/elasticsearch/common/time/DateUtils.java b/server/src/main/java/org/elasticsearch/common/time/DateUtils.java index 46866fa30a4ff..8f368d7fb35f3 100644 --- a/server/src/main/java/org/elasticsearch/common/time/DateUtils.java +++ b/server/src/main/java/org/elasticsearch/common/time/DateUtils.java @@ -354,8 +354,8 @@ public static int compareNanosToMillis(long nanos, long millis) { } /** - * Rounds the given utc milliseconds sicne the epoch down to the next unit millis - * + * Rounds the given utc milliseconds since the epoch down to the next unit millis + *

* Note: This does not check for correctness of the result, as this only works with units smaller or equal than a day * In order to ensure the performance of this methods, there are no guards or checks in it * @@ -395,6 +395,49 @@ public static long roundMonthOfYear(final long utcMillis) { return DateUtils.of(year, month); } + /** + * Round down to the beginning of the nearest multiple of the specified month interval based on the year + * @param utcMillis the milliseconds since the epoch + * @param monthInterval the interval in months to round down to + * + * @return The milliseconds since the epoch rounded down to the beginning of the nearest multiple of the + * specified month interval based on the year + */ + public static long roundIntervalMonthOfYear(final long utcMillis, final int monthInterval) { + if (monthInterval <= 0) { + throw new IllegalArgumentException("month interval must be strictly positive, got [" + monthInterval + "]"); + } + int year = getYear(utcMillis); + int month = getMonthOfYear(utcMillis, year); + + // Convert date to total months since epoch reference point (year 1 BCE boundary which is year 0) + // 1. (year-1): Adjusts for 1-based year counting + // 2. * 12: Converts years to months + // 3. (month-1): Converts 1-based month to 0-based index + int totalMonths = (year - 1) * 12 + (month - 1); + + // Calculate interval index using floor division to handle negative values correctly + // This ensures proper alignment for BCE dates (negative totalMonths) + int quotient = Math.floorDiv(totalMonths, monthInterval); + + // Calculate the starting month of the interval period + int firstMonthOfInterval = quotient * monthInterval; + + // Convert back to month-of-year (1-12): + // 1. Calculate modulo 12 to get 0-11 month index + // 2. Add 12 before final modulo to handle negative values + // 3. Convert to 1-based month numbering + int monthInYear = (firstMonthOfInterval % 12 + 12) % 12 + 1; + + // Calculate corresponding year: + // 1. Subtract month offset (monthInYear - 1) to get total months at year boundary + // 2. Convert months to years + // 3. Add 1 to adjust back to 1-based year counting + int yearResult = (firstMonthOfInterval - (monthInYear - 1)) / 12 + 1; + + return DateUtils.of(yearResult, monthInYear); + } + /** * Round down to the beginning of the year of the specified time * @param utcMillis the milliseconds since the epoch @@ -405,13 +448,59 @@ public static long roundYear(final long utcMillis) { return utcMillisAtStartOfYear(year); } + /** + * Round down to the beginning of the nearest multiple of the specified year interval + * @param utcMillis the milliseconds since the epoch + * @param yearInterval the interval in years to round down to + * + * @return The milliseconds since the epoch rounded down to the beginning of the nearest multiple of the specified year interval + */ + public static long roundYearInterval(final long utcMillis, final int yearInterval) { + if (yearInterval <= 0) { + throw new IllegalArgumentException("year interval must be strictly positive, got [" + yearInterval + "]"); + } + int year = getYear(utcMillis); + + // Convert date to total years since epoch reference point (year 1 BCE boundary which is year 0) + int totalYears = year - 1; + + // Calculate interval index using floor division to handle negative values correctly + // This ensures proper alignment for BCE dates (negative totalYears) + int quotient = Math.floorDiv(totalYears, yearInterval); + + // Calculate the starting total years of the current interval + int startTotalYears = quotient * yearInterval; + + // Convert back to actual calendar year by adding 1 (reverse the base year adjustment) + int startYear = startTotalYears + 1; + + return utcMillisAtStartOfYear(startYear); + } + /** * Round down to the beginning of the week based on week year of the specified time * @param utcMillis the milliseconds since the epoch * @return The milliseconds since the epoch rounded down to the beginning of the week based on week year */ public static long roundWeekOfWeekYear(final long utcMillis) { - return roundFloor(utcMillis + 3 * 86400 * 1000L, 604800000) - 3 * 86400 * 1000L; + return roundWeekIntervalOfWeekYear(utcMillis, 1); + } + + /** + * Round down to the beginning of the nearest multiple of the specified week interval based on week year + *

+ * Consider Sun Dec 29 1969 00:00:00.000 as the start of the first week. + * @param utcMillis the milliseconds since the epoch + * @param weekInterval the interval in weeks to round down to + * + * @return The milliseconds since the epoch rounded down to the beginning of the nearest multiple of the + * specified week interval based on week year + */ + public static long roundWeekIntervalOfWeekYear(final long utcMillis, final int weekInterval) { + if (weekInterval <= 0) { + throw new IllegalArgumentException("week interval must be strictly positive, got [" + weekInterval + "]"); + } + return roundFloor(utcMillis + 3 * 86400 * 1000L, 604800000L * weekInterval) - 3 * 86400 * 1000L; } /** diff --git a/server/src/test/java/org/elasticsearch/common/time/DateUtilsTests.java b/server/src/test/java/org/elasticsearch/common/time/DateUtilsTests.java index 08da6f0dfc957..a5f7f9c183345 100644 --- a/server/src/test/java/org/elasticsearch/common/time/DateUtilsTests.java +++ b/server/src/test/java/org/elasticsearch/common/time/DateUtilsTests.java @@ -267,6 +267,17 @@ public void testRoundMonthOfYear() { assertThat(DateUtils.roundMonthOfYear(1), is(0L)); long dec1969 = LocalDate.of(1969, 12, 1).atStartOfDay().toInstant(ZoneOffset.UTC).toEpochMilli(); assertThat(DateUtils.roundMonthOfYear(-1), is(dec1969)); + + IllegalArgumentException exc = expectThrows(IllegalArgumentException.class, () -> DateUtils.roundIntervalMonthOfYear(0, -1)); + assertThat(exc.getMessage(), is("month interval must be strictly positive, got [-1]")); + long epochMilli = LocalDate.of(1969, 10, 1).atStartOfDay().toInstant(ZoneOffset.UTC).toEpochMilli(); + assertThat(DateUtils.roundIntervalMonthOfYear(1, 5), is(epochMilli)); + epochMilli = LocalDate.of(1969, 6, 1).atStartOfDay().toInstant(ZoneOffset.UTC).toEpochMilli(); + assertThat(DateUtils.roundIntervalMonthOfYear(-1, 13), is(epochMilli)); + epochMilli = LocalDate.of(2024, 8, 1).atStartOfDay().toInstant(ZoneOffset.UTC).toEpochMilli(); + assertThat(DateUtils.roundIntervalMonthOfYear(1737378896000L, 7), is(epochMilli)); + epochMilli = LocalDate.of(-2026, 4, 1).atStartOfDay().toInstant(ZoneOffset.UTC).toEpochMilli(); + assertThat(DateUtils.roundIntervalMonthOfYear(-126068400000000L, 11), is(epochMilli)); } public void testRoundYear() { @@ -276,9 +287,41 @@ public void testRoundYear() { assertThat(DateUtils.roundYear(-1), is(startOf1969)); long endOf1970 = ZonedDateTime.of(1970, 12, 31, 23, 59, 59, 999_999_999, ZoneOffset.UTC).toInstant().toEpochMilli(); assertThat(DateUtils.roundYear(endOf1970), is(0L)); - // test with some leapyear + // test with some leap year long endOf1996 = ZonedDateTime.of(1996, 12, 31, 23, 59, 59, 999_999_999, ZoneOffset.UTC).toInstant().toEpochMilli(); long startOf1996 = Year.of(1996).atDay(1).atStartOfDay().toInstant(ZoneOffset.UTC).toEpochMilli(); assertThat(DateUtils.roundYear(endOf1996), is(startOf1996)); + + IllegalArgumentException exc = expectThrows(IllegalArgumentException.class, () -> DateUtils.roundYearInterval(0, -1)); + assertThat(exc.getMessage(), is("year interval must be strictly positive, got [-1]")); + assertThat(DateUtils.roundYearInterval(0, 2), is(startOf1969)); + long startOf1968 = Year.of(1968).atDay(1).atStartOfDay().toInstant(ZoneOffset.UTC).toEpochMilli(); + assertThat(DateUtils.roundYearInterval(0, 7), is(startOf1968)); + long startOf1966 = Year.of(1966).atDay(1).atStartOfDay().toInstant(ZoneOffset.UTC).toEpochMilli(); + assertThat(DateUtils.roundYearInterval(1, 5), is(startOf1966)); + long startOf1961 = Year.of(1961).atDay(1).atStartOfDay().toInstant(ZoneOffset.UTC).toEpochMilli(); + assertThat(DateUtils.roundYearInterval(-1, 10), is(startOf1961)); + long startOf1992 = Year.of(1992).atDay(1).atStartOfDay().toInstant(ZoneOffset.UTC).toEpochMilli(); + assertThat(DateUtils.roundYearInterval(endOf1996, 11), is(startOf1992)); + long epochMilli = Year.of(-2034).atDay(1).atStartOfDay().toInstant(ZoneOffset.UTC).toEpochMilli(); + assertThat(DateUtils.roundYearInterval(-126068400000000L, 11), is(epochMilli)); + } + + public void testRoundWeek() { + long epochMilli = Year.of(1969).atMonth(12).atDay(29).atStartOfDay().toInstant(ZoneOffset.UTC).toEpochMilli(); + assertThat(DateUtils.roundWeekOfWeekYear(0), is(epochMilli)); + assertThat(DateUtils.roundWeekOfWeekYear(1), is(epochMilli)); + assertThat(DateUtils.roundWeekOfWeekYear(-1), is(epochMilli)); + + IllegalArgumentException exc = expectThrows(IllegalArgumentException.class, () -> DateUtils.roundWeekIntervalOfWeekYear(0, -1)); + assertThat(exc.getMessage(), is("week interval must be strictly positive, got [-1]")); + assertThat(DateUtils.roundWeekIntervalOfWeekYear(0, 3), is(epochMilli)); + assertThat(DateUtils.roundWeekIntervalOfWeekYear(1, 3), is(epochMilli)); + assertThat(DateUtils.roundWeekIntervalOfWeekYear(-1, 2), is(epochMilli)); + + epochMilli = Year.of(2025).atMonth(1).atDay(20).atStartOfDay().toInstant(ZoneOffset.UTC).toEpochMilli(); + assertThat(DateUtils.roundWeekOfWeekYear(1737378896000L), is(epochMilli)); + epochMilli = Year.of(2025).atMonth(1).atDay(13).atStartOfDay().toInstant(ZoneOffset.UTC).toEpochMilli(); + assertThat(DateUtils.roundWeekIntervalOfWeekYear(1737378896000L, 4), is(epochMilli)); } } diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/bucket.csv-spec b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/bucket.csv-spec index b5d3182bfb11f..497485216df7f 100644 --- a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/bucket.csv-spec +++ b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/bucket.csv-spec @@ -849,3 +849,39 @@ c:long | b:datetime | yr:datetime 9 | 1989-01-01T00:00:00.000Z | 1988-01-01T00:00:00.000Z 13 | 1990-01-01T00:00:00.000Z | 1989-01-01T00:00:00.000Z ; + + +bucketByYearInArbitraryIntervals +required_capability: date_trunc_with_arbitrary_intervals + +FROM employees +| STATS c = COUNT(*) BY b = BUCKET(birth_date, 4 year) +| SORT c DESC, b +| LIMIT 5 +; + +c:long | b:date +28 | 1953-01-01T00:00:00.000Z +28 | 1957-01-01T00:00:00.000Z +25 | 1961-01-01T00:00:00.000Z +10 | null +8 | 1949-01-01T00:00:00.000Z +; + + +bucketByMonthInArbitraryIntervals +required_capability: date_trunc_with_arbitrary_intervals + +FROM employees +| STATS c = COUNT(*) BY b = BUCKET(hire_date, 20 month) +| SORT c DESC, b +| LIMIT 5 +; + +c:long | b:date +23 | 1986-01-01T00:00:00.000Z +22 | 1989-05-01T00:00:00.000Z +15 | 1987-09-01T00:00:00.000Z +11 | 1984-05-01T00:00:00.000Z +11 | 1991-01-01T00:00:00.000Z +; diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/date.csv-spec b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/date.csv-spec index f809bdb48e382..391c5a36f0bfc 100644 --- a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/date.csv-spec +++ b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/date.csv-spec @@ -1520,3 +1520,41 @@ FROM employees result:boolean null ; + +evalDateTruncYearInArbitraryIntervals +required_capability: date_trunc_with_arbitrary_intervals + +ROW x = ["1963-01-01", "1973-04-11", "1978-04-12", "0000-01-01", "-0006-01-01", "-0007-01-01"]::DATETIME +| MV_EXPAND x +| EVAL y = DATE_TRUNC(7 years, x) +; + +x:date | y:date +1963-01-01T00:00:00.000Z | 1961-01-01T00:00:00.000Z +1973-04-11T00:00:00.000Z | 1968-01-01T00:00:00.000Z +1978-04-12T00:00:00.000Z | 1975-01-01T00:00:00.000Z +0000-01-01T00:00:00.000Z | -0006-01-01T00:00:00.000Z +-0006-01-01T00:00:00.000Z | -0006-01-01T00:00:00.000Z +-0007-01-01T00:00:00.000Z | -0013-01-01T00:00:00.000Z +; + +evalDateTruncMonthInArbitraryIntervals +required_capability: date_trunc_with_arbitrary_intervals + +ROW x = ["1969-11-12", "1970-05-01", "1970-12-31", "1972-01-12", "0001-01-01", "0000-12-01", "-0001-12-01"]::DATETIME +| MV_EXPAND x +| EVAL y = DATE_TRUNC(7 months, x) +; + +x:date | y:date +1969-11-12T00:00:00.000Z | 1969-10-01T00:00:00.000Z +1970-05-01T00:00:00.000Z | 1970-05-01T00:00:00.000Z +1970-12-31T00:00:00.000Z | 1970-12-01T00:00:00.000Z +1972-01-12T00:00:00.000Z | 1971-07-01T00:00:00.000Z +0001-01-01T00:00:00.000Z | 0001-01-01T00:00:00.000Z +0000-12-01T00:00:00.000Z | 0000-06-01T00:00:00.000Z +-0001-12-01T00:00:00.000Z | -0001-11-01T00:00:00.000Z + +; + + diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java index 38876c938f917..d382001972a01 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java @@ -501,6 +501,11 @@ public enum Cap { */ BUCKET_INCLUSIVE_UPPER_BOUND, + /** + * Enhanced DATE_TRUNC with arbitrary month and year intervals. (#120302) + */ + DATE_TRUNC_WITH_ARBITRARY_INTERVALS, + /** * Changed error messages for fields with conflicting types in different indices. */ diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/date/DateTrunc.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/date/DateTrunc.java index 7983c38cc4288..4e3e65b7989f7 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/date/DateTrunc.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/date/DateTrunc.java @@ -63,7 +63,7 @@ public interface DateTruncFactoryProvider { @FunctionInfo( returnType = { "date", "date_nanos" }, - description = "Rounds down a date to the closest interval.", + description = "Rounds down a date to the closest interval since epoch, which starts at `0001-01-01T00:00:00Z`.", examples = { @Example(file = "date", tag = "docsDateTrunc"), @Example( @@ -190,14 +190,14 @@ private static Rounding.Prepared createRounding(final Period period, final ZoneI rounding = new Rounding.Builder(Rounding.DateTimeUnit.WEEK_OF_WEEKYEAR); } else if (period.getDays() > 1) { rounding = new Rounding.Builder(new TimeValue(period.getDays(), TimeUnit.DAYS)); - } else if (period.getMonths() == 1) { - rounding = new Rounding.Builder(Rounding.DateTimeUnit.MONTH_OF_YEAR); } else if (period.getMonths() == 3) { - // java.time.Period does not have a QUATERLY period, so a period of 3 months + // java.time.Period does not have a QUARTERLY period, so a period of 3 months // returns a quarterly rounding rounding = new Rounding.Builder(Rounding.DateTimeUnit.QUARTER_OF_YEAR); - } else if (period.getYears() == 1) { - rounding = new Rounding.Builder(Rounding.DateTimeUnit.YEAR_OF_CENTURY); + } else if (period.getMonths() > 0) { + rounding = new Rounding.Builder(Rounding.DateTimeUnit.MONTH_OF_YEAR, period.getMonths()); + } else if (period.getYears() > 0) { + rounding = new Rounding.Builder(Rounding.DateTimeUnit.YEAR_OF_CENTURY, period.getYears()); } else { throw new IllegalArgumentException("Time interval is not supported"); } diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/date/DateTruncRoundingTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/date/DateTruncRoundingTests.java index b5e89fc41f368..aed602cd8991b 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/date/DateTruncRoundingTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/date/DateTruncRoundingTests.java @@ -82,11 +82,14 @@ public void testCreateRoundingPeriod() { rounding = createRounding(Period.ofMonths(3)); assertEquals(1, rounding.roundingSize(Rounding.DateTimeUnit.QUARTER_OF_YEAR), 0d); + rounding = createRounding(Period.ofMonths(5)); + assertEquals(1, rounding.roundingSize(Rounding.DateTimeUnit.MONTH_OF_YEAR), 0d); + rounding = createRounding(Period.ofYears(1)); assertEquals(1, rounding.roundingSize(Rounding.DateTimeUnit.YEAR_OF_CENTURY), 0d); - e = expectThrows(IllegalArgumentException.class, () -> createRounding(Period.ofYears(3))); - assertThat(e.getMessage(), containsString("Time interval is not supported")); + rounding = createRounding(Period.ofYears(3)); + assertEquals(1, rounding.roundingSize(Rounding.DateTimeUnit.YEAR_OF_CENTURY), 0d); } public void testCreateRoundingNullInterval() { diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/date/DateTruncTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/date/DateTruncTests.java index 50de64ff8b173..7752b789a0b89 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/date/DateTruncTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/date/DateTruncTests.java @@ -56,6 +56,10 @@ public static Iterable parameters() { suppliers.addAll(ofDuration(Duration.ofSeconds(30), ts, "2023-02-17T10:25:30.00Z")); suppliers.add(randomSecond()); + // arbitrary period of months and years + suppliers.addAll(ofDatePeriod(Period.ofMonths(7), ts, "2022-11-01T00:00:00.00Z")); + suppliers.addAll(ofDatePeriod(Period.ofYears(5), ts, "2021-01-01T00:00:00.00Z")); + return parameterSuppliersFromTypedDataWithDefaultChecksNoErrors(true, suppliers); }