Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/changelog/120302.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
pr: 120302
summary: "ESQL: Enhanced `DATE_TRUNC` with arbitrary intervals"
area: ES|QL
type: enhancement
issues:
- 120094

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion docs/reference/esql/functions/kibana/docs/date_trunc.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

81 changes: 56 additions & 25 deletions server/src/main/java/org/elasticsearch/common/Rounding.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -71,50 +72,62 @@ 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;
}
},
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;
}
},
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;
}
Expand All @@ -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;
}
Expand All @@ -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;
}
Expand All @@ -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
Expand Down Expand Up @@ -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();
}
Expand All @@ -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);
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
95 changes: 92 additions & 3 deletions server/src/main/java/org/elasticsearch/common/time/DateUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
* <p>
* 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
*
Expand Down Expand Up @@ -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
Expand All @@ -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
* <p>
* 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;
}

/**
Expand Down
Loading