Skip to content

Commit 41d58c8

Browse files
ESQL: Enhanced DATE_TRUNC with arbitrary intervals (#120302) (#129643)
Originally, `DATE_TRUNC` only supported 1-month and 3-month intervals for months, and 1-year interval for years, while arbitrary intervals were supported for weeks and days. This PR adds support for `DATE_TRUNC` with arbitrary month and year intervals. Closes #120094 Co-authored-by: kanoshiou <[email protected]>
1 parent 166280b commit 41d58c8

File tree

12 files changed

+296
-39
lines changed

12 files changed

+296
-39
lines changed

docs/changelog/120302.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
pr: 120302
2+
summary: "ESQL: Enhanced `DATE_TRUNC` with arbitrary intervals"
3+
area: ES|QL
4+
type: enhancement
5+
issues:
6+
- 120094

docs/reference/esql/functions/kibana/definition/date_trunc.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/reference/esql/functions/kibana/docs/date_trunc.md

Lines changed: 3 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

server/src/main/java/org/elasticsearch/common/Rounding.java

Lines changed: 56 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,9 @@ public enum DateTimeUnit {
5959
WEEK_OF_WEEKYEAR((byte) 1, "week", IsoFields.WEEK_OF_WEEK_BASED_YEAR, true, TimeUnit.DAYS.toMillis(7)) {
6060
private final long extraLocalOffsetLookup = TimeUnit.DAYS.toMillis(7);
6161

62-
long roundFloor(long utcMillis) {
63-
return DateUtils.roundWeekOfWeekYear(utcMillis);
62+
@Override
63+
long roundFloor(long utcMillis, int multiplier) {
64+
return DateUtils.roundWeekIntervalOfWeekYear(utcMillis, multiplier);
6465
}
6566

6667
@Override
@@ -71,50 +72,62 @@ long extraLocalOffsetLookup() {
7172
YEAR_OF_CENTURY((byte) 2, "year", ChronoField.YEAR_OF_ERA, false, 12) {
7273
private final long extraLocalOffsetLookup = TimeUnit.DAYS.toMillis(366);
7374

74-
long roundFloor(long utcMillis) {
75-
return DateUtils.roundYear(utcMillis);
75+
@Override
76+
long roundFloor(long utcMillis, int multiplier) {
77+
return multiplier == 1 ? DateUtils.roundYear(utcMillis) : DateUtils.roundYearInterval(utcMillis, multiplier);
7678
}
7779

80+
@Override
7881
long extraLocalOffsetLookup() {
7982
return extraLocalOffsetLookup;
8083
}
8184
},
8285
QUARTER_OF_YEAR((byte) 3, "quarter", IsoFields.QUARTER_OF_YEAR, false, 3) {
8386
private final long extraLocalOffsetLookup = TimeUnit.DAYS.toMillis(92);
8487

85-
long roundFloor(long utcMillis) {
86-
return DateUtils.roundQuarterOfYear(utcMillis);
88+
@Override
89+
long roundFloor(long utcMillis, int multiplier) {
90+
return multiplier == 1
91+
? DateUtils.roundQuarterOfYear(utcMillis)
92+
: DateUtils.roundIntervalMonthOfYear(utcMillis, multiplier * 3);
8793
}
8894

95+
@Override
8996
long extraLocalOffsetLookup() {
9097
return extraLocalOffsetLookup;
9198
}
9299
},
93100
MONTH_OF_YEAR((byte) 4, "month", ChronoField.MONTH_OF_YEAR, false, 1) {
94101
private final long extraLocalOffsetLookup = TimeUnit.DAYS.toMillis(31);
95102

96-
long roundFloor(long utcMillis) {
97-
return DateUtils.roundMonthOfYear(utcMillis);
103+
@Override
104+
long roundFloor(long utcMillis, int multiplier) {
105+
return multiplier == 1 ? DateUtils.roundMonthOfYear(utcMillis) : DateUtils.roundIntervalMonthOfYear(utcMillis, multiplier);
98106
}
99107

108+
@Override
100109
long extraLocalOffsetLookup() {
101110
return extraLocalOffsetLookup;
102111
}
103112
},
104113
DAY_OF_MONTH((byte) 5, "day", ChronoField.DAY_OF_MONTH, true, ChronoField.DAY_OF_MONTH.getBaseUnit().getDuration().toMillis()) {
105-
long roundFloor(long utcMillis) {
106-
return DateUtils.roundFloor(utcMillis, this.ratio);
114+
@Override
115+
long roundFloor(long utcMillis, int multiplier) {
116+
return DateUtils.roundFloor(utcMillis, this.ratio * multiplier);
107117
}
108118

119+
@Override
109120
long extraLocalOffsetLookup() {
110121
return ratio;
111122
}
112123
},
113124
HOUR_OF_DAY((byte) 6, "hour", ChronoField.HOUR_OF_DAY, true, ChronoField.HOUR_OF_DAY.getBaseUnit().getDuration().toMillis()) {
114-
long roundFloor(long utcMillis) {
115-
return DateUtils.roundFloor(utcMillis, ratio);
125+
@Override
126+
long roundFloor(long utcMillis, int multiplier) {
127+
return DateUtils.roundFloor(utcMillis, ratio * multiplier);
116128
}
117129

130+
@Override
118131
long extraLocalOffsetLookup() {
119132
return ratio;
120133
}
@@ -126,10 +139,12 @@ long extraLocalOffsetLookup() {
126139
true,
127140
ChronoField.MINUTE_OF_HOUR.getBaseUnit().getDuration().toMillis()
128141
) {
129-
long roundFloor(long utcMillis) {
130-
return DateUtils.roundFloor(utcMillis, ratio);
142+
@Override
143+
long roundFloor(long utcMillis, int multiplier) {
144+
return DateUtils.roundFloor(utcMillis, ratio * multiplier);
131145
}
132146

147+
@Override
133148
long extraLocalOffsetLookup() {
134149
return ratio;
135150
}
@@ -141,10 +156,12 @@ long extraLocalOffsetLookup() {
141156
true,
142157
ChronoField.SECOND_OF_MINUTE.getBaseUnit().getDuration().toMillis()
143158
) {
144-
long roundFloor(long utcMillis) {
145-
return DateUtils.roundFloor(utcMillis, ratio);
159+
@Override
160+
long roundFloor(long utcMillis, int multiplier) {
161+
return DateUtils.roundFloor(utcMillis, ratio * multiplier);
146162
}
147163

164+
@Override
148165
long extraLocalOffsetLookup() {
149166
return ratio;
150167
}
@@ -171,10 +188,11 @@ long extraLocalOffsetLookup() {
171188
* This rounds down the supplied milliseconds since the epoch down to the next unit. In order to retain performance this method
172189
* should be as fast as possible and not try to convert dates to java-time objects if possible
173190
*
174-
* @param utcMillis the milliseconds since the epoch
175-
* @return the rounded down milliseconds since the epoch
191+
* @param utcMillis the milliseconds since the epoch
192+
* @param multiplier the factor by which the unit is multiplied
193+
* @return the rounded down milliseconds since the epoch
176194
*/
177-
abstract long roundFloor(long utcMillis);
195+
abstract long roundFloor(long utcMillis, int multiplier);
178196

179197
/**
180198
* When looking up {@link LocalTimeOffset} go this many milliseconds
@@ -329,17 +347,24 @@ public static class Builder {
329347

330348
private final DateTimeUnit unit;
331349
private final long interval;
350+
private final int multiplier;
332351

333352
private ZoneId timeZone = ZoneOffset.UTC;
334353
private long offset = 0;
335354

336355
public Builder(DateTimeUnit unit) {
356+
this(unit, 1);
357+
}
358+
359+
public Builder(DateTimeUnit unit, int multiplier) {
337360
this.unit = unit;
361+
this.multiplier = multiplier;
338362
this.interval = -1;
339363
}
340364

341365
public Builder(TimeValue interval) {
342366
this.unit = null;
367+
this.multiplier = -1;
343368
if (interval.millis() < 1) throw new IllegalArgumentException("Zero or negative time interval not supported");
344369
this.interval = interval.millis();
345370
}
@@ -365,7 +390,7 @@ public Builder offset(long offset) {
365390
public Rounding build() {
366391
Rounding rounding;
367392
if (unit != null) {
368-
rounding = new TimeUnitRounding(unit, timeZone);
393+
rounding = new TimeUnitRounding(unit, multiplier, timeZone);
369394
} else {
370395
rounding = new TimeIntervalRounding(interval, timeZone);
371396
}
@@ -422,11 +447,17 @@ static class TimeUnitRounding extends Rounding {
422447
private final DateTimeUnit unit;
423448
private final ZoneId timeZone;
424449
private final boolean unitRoundsToMidnight;
450+
private final int multiplier;
425451

426452
TimeUnitRounding(DateTimeUnit unit, ZoneId timeZone) {
453+
this(unit, 1, timeZone);
454+
}
455+
456+
TimeUnitRounding(DateTimeUnit unit, int multiplier, ZoneId timeZone) {
427457
this.unit = unit;
428458
this.timeZone = timeZone;
429459
this.unitRoundsToMidnight = this.unit.field.getBaseUnit().getDuration().toMillis() > 3600000L;
460+
this.multiplier = multiplier;
430461
}
431462

432463
TimeUnitRounding(StreamInput in) throws IOException {
@@ -660,7 +691,7 @@ private class FixedToMidnightRounding extends TimeUnitPreparedRounding {
660691

661692
@Override
662693
public long round(long utcMillis) {
663-
return offset.localToUtcInThisOffset(unit.roundFloor(offset.utcToLocalTime(utcMillis)));
694+
return offset.localToUtcInThisOffset(unit.roundFloor(offset.utcToLocalTime(utcMillis), multiplier));
664695
}
665696

666697
@Override
@@ -686,7 +717,7 @@ private class FixedNotToMidnightRounding extends TimeUnitPreparedRounding {
686717

687718
@Override
688719
public long round(long utcMillis) {
689-
return offset.localToUtcInThisOffset(unit.roundFloor(offset.utcToLocalTime(utcMillis)));
720+
return offset.localToUtcInThisOffset(unit.roundFloor(offset.utcToLocalTime(utcMillis), multiplier));
690721
}
691722

692723
@Override
@@ -710,7 +741,7 @@ private class ToMidnightRounding extends TimeUnitPreparedRounding implements Loc
710741
@Override
711742
public long round(long utcMillis) {
712743
LocalTimeOffset offset = lookup.lookup(utcMillis);
713-
return offset.localToUtc(unit.roundFloor(offset.utcToLocalTime(utcMillis)), this);
744+
return offset.localToUtc(unit.roundFloor(offset.utcToLocalTime(utcMillis), multiplier), this);
714745
}
715746

716747
@Override
@@ -764,14 +795,14 @@ private class NotToMidnightRounding extends AbstractNotToMidnightRounding implem
764795
@Override
765796
public long round(long utcMillis) {
766797
LocalTimeOffset offset = lookup.lookup(utcMillis);
767-
long roundedLocalMillis = unit.roundFloor(offset.utcToLocalTime(utcMillis));
798+
long roundedLocalMillis = unit.roundFloor(offset.utcToLocalTime(utcMillis), multiplier);
768799
return offset.localToUtc(roundedLocalMillis, this);
769800
}
770801

771802
@Override
772803
public long inGap(long localMillis, Gap gap) {
773804
// Round from just before the start of the gap
774-
return gap.previous().localToUtc(unit.roundFloor(gap.firstMissingLocalTime() - 1), this);
805+
return gap.previous().localToUtc(unit.roundFloor(gap.firstMissingLocalTime() - 1, multiplier), this);
775806
}
776807

777808
@Override

server/src/main/java/org/elasticsearch/common/time/DateUtils.java

Lines changed: 92 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -354,8 +354,8 @@ public static int compareNanosToMillis(long nanos, long millis) {
354354
}
355355

356356
/**
357-
* Rounds the given utc milliseconds sicne the epoch down to the next unit millis
358-
*
357+
* Rounds the given utc milliseconds since the epoch down to the next unit millis
358+
* <p>
359359
* Note: This does not check for correctness of the result, as this only works with units smaller or equal than a day
360360
* In order to ensure the performance of this methods, there are no guards or checks in it
361361
*
@@ -395,6 +395,49 @@ public static long roundMonthOfYear(final long utcMillis) {
395395
return DateUtils.of(year, month);
396396
}
397397

398+
/**
399+
* Round down to the beginning of the nearest multiple of the specified month interval based on the year
400+
* @param utcMillis the milliseconds since the epoch
401+
* @param monthInterval the interval in months to round down to
402+
*
403+
* @return The milliseconds since the epoch rounded down to the beginning of the nearest multiple of the
404+
* specified month interval based on the year
405+
*/
406+
public static long roundIntervalMonthOfYear(final long utcMillis, final int monthInterval) {
407+
if (monthInterval <= 0) {
408+
throw new IllegalArgumentException("month interval must be strictly positive, got [" + monthInterval + "]");
409+
}
410+
int year = getYear(utcMillis);
411+
int month = getMonthOfYear(utcMillis, year);
412+
413+
// Convert date to total months since epoch reference point (year 1 BCE boundary which is year 0)
414+
// 1. (year-1): Adjusts for 1-based year counting
415+
// 2. * 12: Converts years to months
416+
// 3. (month-1): Converts 1-based month to 0-based index
417+
int totalMonths = (year - 1) * 12 + (month - 1);
418+
419+
// Calculate interval index using floor division to handle negative values correctly
420+
// This ensures proper alignment for BCE dates (negative totalMonths)
421+
int quotient = Math.floorDiv(totalMonths, monthInterval);
422+
423+
// Calculate the starting month of the interval period
424+
int firstMonthOfInterval = quotient * monthInterval;
425+
426+
// Convert back to month-of-year (1-12):
427+
// 1. Calculate modulo 12 to get 0-11 month index
428+
// 2. Add 12 before final modulo to handle negative values
429+
// 3. Convert to 1-based month numbering
430+
int monthInYear = (firstMonthOfInterval % 12 + 12) % 12 + 1;
431+
432+
// Calculate corresponding year:
433+
// 1. Subtract month offset (monthInYear - 1) to get total months at year boundary
434+
// 2. Convert months to years
435+
// 3. Add 1 to adjust back to 1-based year counting
436+
int yearResult = (firstMonthOfInterval - (monthInYear - 1)) / 12 + 1;
437+
438+
return DateUtils.of(yearResult, monthInYear);
439+
}
440+
398441
/**
399442
* Round down to the beginning of the year of the specified time
400443
* @param utcMillis the milliseconds since the epoch
@@ -405,13 +448,59 @@ public static long roundYear(final long utcMillis) {
405448
return utcMillisAtStartOfYear(year);
406449
}
407450

451+
/**
452+
* Round down to the beginning of the nearest multiple of the specified year interval
453+
* @param utcMillis the milliseconds since the epoch
454+
* @param yearInterval the interval in years to round down to
455+
*
456+
* @return The milliseconds since the epoch rounded down to the beginning of the nearest multiple of the specified year interval
457+
*/
458+
public static long roundYearInterval(final long utcMillis, final int yearInterval) {
459+
if (yearInterval <= 0) {
460+
throw new IllegalArgumentException("year interval must be strictly positive, got [" + yearInterval + "]");
461+
}
462+
int year = getYear(utcMillis);
463+
464+
// Convert date to total years since epoch reference point (year 1 BCE boundary which is year 0)
465+
int totalYears = year - 1;
466+
467+
// Calculate interval index using floor division to handle negative values correctly
468+
// This ensures proper alignment for BCE dates (negative totalYears)
469+
int quotient = Math.floorDiv(totalYears, yearInterval);
470+
471+
// Calculate the starting total years of the current interval
472+
int startTotalYears = quotient * yearInterval;
473+
474+
// Convert back to actual calendar year by adding 1 (reverse the base year adjustment)
475+
int startYear = startTotalYears + 1;
476+
477+
return utcMillisAtStartOfYear(startYear);
478+
}
479+
408480
/**
409481
* Round down to the beginning of the week based on week year of the specified time
410482
* @param utcMillis the milliseconds since the epoch
411483
* @return The milliseconds since the epoch rounded down to the beginning of the week based on week year
412484
*/
413485
public static long roundWeekOfWeekYear(final long utcMillis) {
414-
return roundFloor(utcMillis + 3 * 86400 * 1000L, 604800000) - 3 * 86400 * 1000L;
486+
return roundWeekIntervalOfWeekYear(utcMillis, 1);
487+
}
488+
489+
/**
490+
* Round down to the beginning of the nearest multiple of the specified week interval based on week year
491+
* <p>
492+
* Consider Sun Dec 29 1969 00:00:00.000 as the start of the first week.
493+
* @param utcMillis the milliseconds since the epoch
494+
* @param weekInterval the interval in weeks to round down to
495+
*
496+
* @return The milliseconds since the epoch rounded down to the beginning of the nearest multiple of the
497+
* specified week interval based on week year
498+
*/
499+
public static long roundWeekIntervalOfWeekYear(final long utcMillis, final int weekInterval) {
500+
if (weekInterval <= 0) {
501+
throw new IllegalArgumentException("week interval must be strictly positive, got [" + weekInterval + "]");
502+
}
503+
return roundFloor(utcMillis + 3 * 86400 * 1000L, 604800000L * weekInterval) - 3 * 86400 * 1000L;
415504
}
416505

417506
/**

0 commit comments

Comments
 (0)