Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
f95c2a8
Add capability and YAML tests for request and SET time_zone parameters
ivancea Oct 17, 2025
569d9e5
Add SET CSV tests, and adapted CsvTests to work with SET statements
ivancea Oct 17, 2025
cc15c9f
Escape ; in csv tests with backslash
ivancea Oct 17, 2025
449abe6
Randomize configuration in function tests, and make it static for the…
ivancea Oct 17, 2025
5dec7c3
[CI] Auto commit changes from spotless
Oct 17, 2025
fedbf4f
Merge branch 'main' into esql-time-zone-tests
ivancea Oct 27, 2025
b088abf
Moved custom config cases to another method
ivancea Oct 27, 2025
ee1316c
Add comment on function with config dserialization randomization
ivancea Oct 27, 2025
13d8b4b
Fix multi cluster tests to accept SET statements
ivancea Oct 27, 2025
d794329
Merge branch 'main' into esql-time-zone-tests
ivancea Oct 27, 2025
2713280
Fix configuration and source matching in random tests
ivancea Oct 28, 2025
f76f5ec
Added function tests for timezones
ivancea Oct 28, 2025
9005c78
Fix typo
ivancea Oct 28, 2025
fad189f
Fixed capability requirement
ivancea Oct 28, 2025
4f62f23
Fix ScalbTests
ivancea Oct 28, 2025
4c5adc0
[CI] Auto commit changes from spotless
Oct 28, 2025
3f233bf
Add configuration to DATE_TRUNC and related functions
ivancea Oct 28, 2025
d81729b
Merge branch 'main' into esql-time-zone-tests
ivancea Oct 29, 2025
8b8a115
Merge branch 'esql-time-zone-tests' into esql-datetrunc-timezone
ivancea Oct 29, 2025
a11d4b8
Merge branch 'main' into esql-datetrunc-timezone
ivancea Oct 29, 2025
ebd00dd
Added a ConfigurationFUnction interface and fixed tests with static c…
ivancea Oct 29, 2025
de30da7
WIP: Initial DateTrunc unit tests
ivancea Oct 29, 2025
dcce704
Reuse DateTrunc tests in Bucket and TBucket
ivancea Oct 30, 2025
24e99c1
Added unit tests for timezones
ivancea Oct 31, 2025
dd523d5
Extracted matchers
ivancea Oct 31, 2025
ad32a98
Moved matchers to a common place
ivancea Oct 31, 2025
539b7a7
Extracted date_trunc csv tests to date
ivancea Oct 31, 2025
5053983
Update docs/changelog/137450.yaml
ivancea Oct 31, 2025
a75301c
Fix benchmark
ivancea Oct 31, 2025
7e5c01d
Added CSV tests for timezones on affected functions
ivancea Oct 31, 2025
544c4cf
Fixed Rounding builders being reused
ivancea Oct 31, 2025
e499df4
Use DateTrunc zoneId instead of configuration in rule
ivancea Oct 31, 2025
205a15c
Undo roundings test
ivancea Oct 31, 2025
46debec
Fix tests
ivancea Oct 31, 2025
29707e5
Fixed tests and their testcase names
ivancea Nov 3, 2025
a40f913
Merge branch 'main' into esql-datetrunc-timezone
ivancea Nov 3, 2025
00e2d67
Merge branch 'main' into esql-datetrunc-timezone
ivancea Nov 3, 2025
f6d6d2c
Merge branch 'main' into esql-datetrunc-timezone
ivancea Nov 5, 2025
de3dd41
Moved matchers to server.test
ivancea Nov 5, 2025
ee0af20
Avoid renaming timestamp in TS functions
ivancea Nov 5, 2025
44ad461
Format
ivancea Nov 5, 2025
84b882e
Add extra cases to dateCases
ivancea Nov 5, 2025
2f0b404
[CI] Auto commit changes from spotless
Nov 5, 2025
096d9b3
Add tests for every combination of cases
ivancea Nov 6, 2025
fdc0cf8
Merge branch 'main' into esql-datetrunc-timezone
ivancea Nov 6, 2025
4dc641f
[CI] Auto commit changes from spotless
Nov 6, 2025
d46fc5b
Fixed date intervals with +1 units, and added negative years tests
ivancea Nov 6, 2025
bdd7138
Updated duration tests to cover all timezone kinds
ivancea Nov 7, 2025
55c4efd
Reorganized tests
ivancea Nov 7, 2025
d25ab57
Remove redundant test cases
ivancea Nov 7, 2025
b7d63b9
Added midnight DST tests
ivancea Nov 7, 2025
a3e592c
[CI] Auto commit changes from spotless
Nov 7, 2025
3b34ef6
Fixed midnight DST test
ivancea Nov 7, 2025
e76d5b2
Merge branch 'esql-datetrunc-timezone' of github.com:ivancea/elastics…
ivancea Nov 7, 2025
2194e83
Merge branch 'main' into esql-datetrunc-timezone
ivancea Nov 7, 2025
4723f43
Simplified timezones for tests
ivancea Nov 10, 2025
561f99c
Fixed signature generation failing because of nanos assumptions
ivancea Nov 10, 2025
c6d9dc0
Merge branch 'main' into esql-datetrunc-timezone
ivancea Nov 10, 2025
bb1a653
Cleanup after PR comments
ivancea Nov 10, 2025
c5f5292
Added test comparing different timezone setting methods and functions
ivancea Nov 12, 2025
50239c0
Add capability check on rest test
ivancea Nov 12, 2025
111421a
Merge branch 'main' into esql-datetrunc-timezone
ivancea Nov 12, 2025
5539fca
Added GooseBay examples with 24h
ivancea Nov 13, 2025
a33e952
Merge branch 'main' into esql-datetrunc-timezone
ivancea Nov 13, 2025
d6b22d4
[CI] Auto commit changes from spotless
Nov 13, 2025
bebc906
Fixed tests timeout
ivancea Nov 14, 2025
8315885
Merge branch 'main' into esql-datetrunc-timezone
ivancea Nov 14, 2025
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
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,12 @@ private static EvalOperator.ExpressionEvaluator evaluator(String operation) {
);
yield EvalMapper.toEvaluator(
FOLD_CONTEXT,
new DateTrunc(Source.EMPTY, new Literal(Source.EMPTY, Duration.ofHours(24), DataType.TIME_DURATION), timestamp),
new DateTrunc(
Source.EMPTY,
new Literal(Source.EMPTY, Duration.ofHours(24), DataType.TIME_DURATION),
timestamp,
configuration()
),
layout(timestamp)
).get(driverContext);
}
Expand Down
5 changes: 5 additions & 0 deletions docs/changelog/137450.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
pr: 137450
summary: "Timezone support in DATE_TRUNC, BUCKET and TBUCKET"
area: ES|QL
type: feature
issues: []
20 changes: 9 additions & 11 deletions server/src/main/java/org/elasticsearch/common/Rounding.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import org.elasticsearch.common.io.stream.StreamOutput;
import org.elasticsearch.common.io.stream.Writeable;
import org.elasticsearch.common.time.DateUtils;
import org.elasticsearch.common.time.LocalDateTimeUtils;
import org.elasticsearch.core.TimeValue;

import java.io.IOException;
Expand All @@ -29,7 +30,6 @@
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.temporal.ChronoField;
import java.time.temporal.ChronoUnit;
import java.time.temporal.IsoFields;
import java.time.temporal.TemporalField;
import java.time.temporal.TemporalQueries;
Expand Down Expand Up @@ -546,16 +546,16 @@ private LocalDateTime truncateLocalDateTime(LocalDateTime localDateTime) {
return LocalDateTime.of(localDateTime.getYear(), localDateTime.getMonthValue(), 1, 0, 0);

case QUARTER_OF_YEAR:
return LocalDateTime.of(localDateTime.getYear(), localDateTime.getMonth().firstMonthOfQuarter(), 1, 0, 0);
return LocalDateTime.of(localDateTime.getYear(), (((localDateTime.getMonthValue() - 1) / 3) * 3) + 1, 1, 0, 0);

case YEAR_OF_CENTURY:
return LocalDateTime.of(LocalDate.of(localDateTime.getYear(), 1, 1), LocalTime.MIDNIGHT);

case YEARS_OF_CENTURY:
return LocalDateTime.of(LocalDate.of(localDateTime.getYear(), 1, 1), LocalTime.MIDNIGHT);
return LocalDateTimeUtils.truncateToYears(localDateTime, multiplier);

case MONTHS_OF_YEAR:
return LocalDateTime.of(localDateTime.getYear(), localDateTime.getMonthValue(), 1, 0, 0);
return LocalDateTimeUtils.truncateToMonths(localDateTime, multiplier);

default:
throw new IllegalArgumentException("NOT YET IMPLEMENTED for unit " + unit);
Expand Down Expand Up @@ -914,13 +914,11 @@ private LocalDateTime nextRelevantMidnight(LocalDateTime localMidnight) {
assert localMidnight.toLocalTime().equals(LocalTime.MIDNIGHT) : "nextRelevantMidnight should only be called at midnight";

return switch (unit) {
case DAY_OF_MONTH -> localMidnight.plus(1, ChronoUnit.DAYS);
case WEEK_OF_WEEKYEAR -> localMidnight.plus(7, ChronoUnit.DAYS);
case MONTH_OF_YEAR -> localMidnight.plus(1, ChronoUnit.MONTHS);
case QUARTER_OF_YEAR -> localMidnight.plus(3, ChronoUnit.MONTHS);
case YEAR_OF_CENTURY -> localMidnight.plus(1, ChronoUnit.YEARS);
case YEARS_OF_CENTURY -> localMidnight.plus(1, ChronoUnit.YEARS);
case MONTHS_OF_YEAR -> localMidnight.plus(1, ChronoUnit.MONTHS);
case DAY_OF_MONTH -> localMidnight.plusDays(multiplier);
case WEEK_OF_WEEKYEAR -> localMidnight.plusDays(7L * multiplier);
case MONTH_OF_YEAR, MONTHS_OF_YEAR -> localMidnight.plusMonths(multiplier);
case QUARTER_OF_YEAR -> localMidnight.plusMonths(3L * multiplier);
case YEAR_OF_CENTURY, YEARS_OF_CENTURY -> localMidnight.plusYears(multiplier);
default -> throw new IllegalArgumentException("Unknown round-to-midnight unit: " + unit);
};
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/

package org.elasticsearch.common.time;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;

public class LocalDateTimeUtils {
public static LocalDateTime truncateToMonths(LocalDateTime dateTime, int months) {
int totalMonths = (dateTime.getYear() - 1) * 12 + dateTime.getMonthValue() - 1;
int truncatedMonths = Math.floorDiv(totalMonths, months) * months;
return LocalDateTime.of(LocalDate.of(truncatedMonths / 12 + 1, truncatedMonths % 12 + 1, 1), LocalTime.MIDNIGHT);
}

public static LocalDateTime truncateToYears(LocalDateTime dateTime, int years) {
int truncatedYear = Math.floorDiv(dateTime.getYear() - 1, years) * years + 1;
return LocalDateTime.of(LocalDate.of(truncatedYear, 1, 1), LocalTime.MIDNIGHT);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/

package org.elasticsearch.common.time;

import org.elasticsearch.test.ESTestCase;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.Month;
import java.time.Year;

import static org.hamcrest.Matchers.equalTo;

public class LocalDateTimeUtilsTests extends ESTestCase {
public void testTruncateToYears() {
int randomYear = randomIntBetween(1, 3000);
assertTruncateToYears(1, randomYear, randomYear);

assertTruncateToYears(10, 1, 1);
assertTruncateToYears(10, 11, 11);
assertTruncateToYears(10, 10, 1);
assertTruncateToYears(10, 11, 11);
assertTruncateToYears(10, 2015, 2011);

assertTruncateToYears(4, 2000, 1997);
assertTruncateToYears(4, 2003, 2001);
assertTruncateToYears(4, 2004, 2001);
assertTruncateToYears(4, 2005, 2005);

assertTruncateToYears(4, 1, 1);
assertTruncateToYears(4, -1, -3);
assertTruncateToYears(4, -3, -3);
assertTruncateToYears(4, -4, -7);
}

private void assertTruncateToYears(int interval, int year, int expectedYear) {
int inputMonth = randomIntBetween(1, 12);
LocalDateTime inputDate = LocalDateTime.of(
year,
inputMonth,
Month.of(inputMonth).length(Year.isLeap(year)),
randomIntBetween(0, 23),
randomIntBetween(0, 59),
randomIntBetween(0, 59)
);

LocalDateTime expectedResult = LocalDateTime.of(LocalDate.of(expectedYear, 1, 1), LocalTime.MIDNIGHT);

LocalDateTime resultYears = LocalDateTimeUtils.truncateToYears(inputDate, interval);
assertThat(resultYears, equalTo(expectedResult));

// Also tests the same with months
LocalDateTime resultMonths = LocalDateTimeUtils.truncateToMonths(inputDate, interval * 12);
assertThat(resultMonths, equalTo(expectedResult));
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/

package org.elasticsearch.test;

import org.elasticsearch.common.time.DateFormatter;
import org.elasticsearch.common.time.DateUtils;
import org.hamcrest.Description;
import org.hamcrest.TypeSafeMatcher;

import java.time.Instant;

public class ReadableMatchers {
private static final DateFormatter dateFormatter = DateFormatter.forPattern("strict_date_optional_time");

/**
* Test matcher for millis dates that expects longs, but describes the errors as dates, for better readability.
* <p>
* See {@link #matchesDateNanos} for the nanos counterpart.
* </p>
*/
public static DateMillisMatcher matchesDateMillis(String date) {
return new DateMillisMatcher(date);
}

/**
* Test matcher for nanos dates that expects longs, but describes the errors as dates, for better readability.
* <p>
* See {@link DateMillisMatcher} for the millis counterpart.
* </p>
*/
public static DateNanosMatcher matchesDateNanos(String date) {
return new DateNanosMatcher(date);
}

public static class DateMillisMatcher extends TypeSafeMatcher<Long> {
private final long timeMillis;

public DateMillisMatcher(String date) {
this.timeMillis = Instant.parse(date).toEpochMilli();
}

@Override
public boolean matchesSafely(Long item) {
return timeMillis == item;
}

@Override
public void describeMismatchSafely(Long item, Description description) {
description.appendText("was ").appendValue(dateFormatter.formatMillis(item));
}

@Override
public void describeTo(Description description) {
description.appendText(dateFormatter.formatMillis(timeMillis));
}
}

public static class DateNanosMatcher extends TypeSafeMatcher<Long> {
private final long timeNanos;

public DateNanosMatcher(String date) {
this.timeNanos = DateUtils.toLong(Instant.parse(date));
}

@Override
public boolean matchesSafely(Long item) {
return timeNanos == item;
}

@Override
public void describeMismatchSafely(Long item, Description description) {
description.appendText("was ").appendValue(dateFormatter.formatNanos(item));
}

@Override
public void describeTo(Description description) {
description.appendText(dateFormatter.formatNanos(timeNanos));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.function.IntFunction;
import java.util.stream.Stream;

import static java.util.Collections.emptySet;
import static java.util.Map.entry;
Expand All @@ -74,6 +75,7 @@
import static org.hamcrest.Matchers.emptyOrNullString;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.not;
import static org.hamcrest.Matchers.nullValue;
Expand Down Expand Up @@ -172,7 +174,7 @@ public RequestObjectBuilder params(String rawParams) throws IOException {
}

public RequestObjectBuilder timeZone(ZoneId zoneId) throws IOException {
builder.field("time_zone", zoneId);
builder.field("time_zone", zoneId.toString());
return this;
}

Expand Down Expand Up @@ -1852,6 +1854,73 @@ public void testMatchFunctionAcrossMultipleIndicesWithMissingField() throws IOEx
}
}

@SuppressWarnings("fallthrough")
public void testRandomTimezoneBuckets() throws IOException {
assumeTrue("timezone support for date_trunc is required", EsqlCapabilities.Cap.DATE_TRUNC_TIMEZONE_SUPPORT.isEnabled());

createIndex(testIndexName(), Settings.EMPTY, """
{
"properties": {
"@timestamp": {
"type": "date"
}
}
}
""");
bulkLoadTestData(randomIntBetween(1, 10), 0, false, i -> """
{"index":{"_id":"%s"}}
{"@timestamp": %s}
""".formatted(testIndexName(), randomLong()));

var timeZone = randomZone();
var interval = randomFrom("1 hour", "1 day", "1 month");
var functions = Stream.of("DATE_TRUNC(\"%s\", @timestamp)", "BUCKET(@timestamp, \"%s\")", "TBUCKET(\"%s\")")
.map(f -> f.formatted(interval))
.toList();

Object firstResultValues = null;

for (int timezoneSettingMethod = 0; timezoneSettingMethod < 3; timezoneSettingMethod++) {
for (var function : functions) {
var query = "FROM %s | STATS BY bucket=%s | SORT bucket".formatted(testIndexName(), function);
var builder = requestObjectBuilder();

switch (timezoneSettingMethod) {
case 0: // "time_zone" request param
builder.query(query).timeZone(timeZone);
break;
case 1: // Random "time_zone" request param with a SET overriding it
builder.timeZone(randomZone());
// Fall-through and set the actual timezone. This case only sets a random, to-be-overridden request timezone
case 2: // SET "time_zone" param
builder.query("SET time_zone=\"%s\"; %s".formatted(timeZone, query));
break;
}

var result = runEsql(requestObjectBuilder().query(query).timeZone(timeZone));

assertResultMap(
result,
getResultMatcher(result),
matchesList().item(matchesMap().entry("name", "bucket").entry("type", "date")),
hasSize(greaterThanOrEqualTo(1))
);

Object values = result.get("values");

if (firstResultValues == null) {
firstResultValues = values;
} else {
assertThat(
"function %s for timezone %s didn't return the same values".formatted(function, timeZone),
values,
equalTo(firstResultValues)
);
}
}
}
}

protected static Request prepareRequestWithOptions(RequestObjectBuilder requestObject, Mode mode) throws IOException {
requestObject.build();
Request request = prepareRequest(mode);
Expand Down
Loading