Skip to content

Commit f7ff00f

Browse files
authored
ESQL: Align year diffing to the rest of the units in DATE_DIFF: chronological (#113103)
This will correct/switch "year" unit diffing from the current integer subtraction to a crono subtraction. Consequently, two dates are (at least) one year apart now if (at least) a full calendar year separates them. The previous implementation simply subtracted the year part of the dates. Note: this parts with ES SQL's implementation of the same function, which itself is aligned with MS SQL's implementation, which works equivalent to an integer subtraction. Fixes #112482.
1 parent 8a179ff commit f7ff00f

File tree

7 files changed

+167
-34
lines changed

7 files changed

+167
-34
lines changed

docs/changelog/113103.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
pr: 113103
2+
summary: "ESQL: Align year diffing to the rest of the units in DATE_DIFF: chronological"
3+
area: ES|QL
4+
type: bug
5+
issues:
6+
- 112482

docs/reference/esql/functions/examples/date_diff.asciidoc

Lines changed: 12 additions & 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/definition/date_diff.json

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

x-pack/plugin/esql/qa/testFixtures/src/main/resources/date.csv-spec

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,80 @@ date1:date | dd_ms:integer
367367
2023-12-02T11:00:00.000Z | 1000
368368
;
369369

370+
evalDateDiffMonthAsWhole0Months
371+
372+
ROW from="2023-12-31T23:59:59.999Z"::DATETIME, to="2024-01-01T00:00:00"::DATETIME
373+
| EVAL msecs=DATE_DIFF("milliseconds", from, to), months=DATE_DIFF("month", from, to)
374+
;
375+
376+
from:date | to:date | msecs:integer| months:integer
377+
2023-12-31T23:59:59.999Z|2024-01-01T00:00:00.000Z|1 |0
378+
379+
;
380+
381+
evalDateDiffMonthAsWhole1Month
382+
383+
ROW from="2023-12-31T23:59:59.999Z"::DATETIME, to="2024-02-01T00:00:00"::DATETIME
384+
| EVAL secs=DATE_DIFF("seconds", from, to), months=DATE_DIFF("month", from, to)
385+
;
386+
387+
from:date | to:date | secs:integer| months:integer
388+
2023-12-31T23:59:59.999Z|2024-02-01T00:00:00.000Z|2678400 |1
389+
390+
;
391+
392+
evalDateDiffYearAsWhole0Years
393+
required_capability: date_diff_year_calendarial
394+
395+
ROW from="2023-12-31T23:59:59.999Z"::DATETIME, to="2024-01-01T00:00:00"::DATETIME
396+
| EVAL msecs=DATE_DIFF("milliseconds", from, to), years=DATE_DIFF("year", from, to)
397+
;
398+
399+
from:date | to:date | msecs:integer | years:integer
400+
2023-12-31T23:59:59.999Z|2024-01-01T00:00:00.000Z|1 |0
401+
;
402+
403+
evalDateDiffYearAsWhole1Year
404+
required_capability: date_diff_year_calendarial
405+
406+
ROW from="2023-12-31T23:59:59.999Z"::DATETIME, to="2025-01-01T00:00:00"::DATETIME
407+
| EVAL secs=DATE_DIFF("seconds", from, to), years=DATE_DIFF("year", from, to)
408+
;
409+
410+
from:date | to:date | secs:integer| years:integer
411+
2023-12-31T23:59:59.999Z|2025-01-01T00:00:00.000Z|31622400 |1
412+
;
413+
414+
evalDateDiffYearAsWhole1Year
415+
required_capability: date_diff_year_calendarial
416+
417+
ROW from="2024-01-01T00:00:00Z"::DATETIME, to="2025-01-01T00:00:00"::DATETIME
418+
| EVAL secs=DATE_DIFF("seconds", from, to), years=DATE_DIFF("year", from, to)
419+
;
420+
421+
from:date | to:date | secs:integer| years:integer
422+
2024-01-01T00:00:00.000Z|2025-01-01T00:00:00.000Z|31622400 |1
423+
;
424+
425+
evalDateDiffYearForDocs
426+
required_capability: date_diff_year_calendarial
427+
428+
// tag::evalDateDiffYearForDocs[]
429+
ROW end_23="2023-12-31T23:59:59.999Z"::DATETIME,
430+
start_24="2024-01-01T00:00:00.000Z"::DATETIME,
431+
end_24="2024-12-31T23:59:59.999"::DATETIME
432+
| EVAL end23_to_start24=DATE_DIFF("year", end_23, start_24)
433+
| EVAL end23_to_end24=DATE_DIFF("year", end_23, end_24)
434+
| EVAL start_to_end_24=DATE_DIFF("year", start_24, end_24)
435+
// end::evalDateDiffYearForDocs[]
436+
;
437+
438+
// tag::evalDateDiffYearForDocs-result[]
439+
end_23:date | start_24:date | end_24:date |end23_to_start24:integer|end23_to_end24:integer|start_to_end_24:integer
440+
2023-12-31T23:59:59.999Z|2024-01-01T00:00:00.000Z|2024-12-31T23:59:59.999Z|0 |1 |0
441+
// end::evalDateDiffYearForDocs-result[]
442+
;
443+
370444
evalDateParseWithSimpleDate
371445
row a = "2023-02-01" | eval b = date_parse("yyyy-MM-dd", a) | keep b;
372446

x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,12 @@ public enum Cap {
320320
* Don't optimize CASE IS NOT NULL function by not requiring the fields to be not null as well.
321321
* https://github.com/elastic/elasticsearch/issues/112704
322322
*/
323-
FIXED_WRONG_IS_NOT_NULL_CHECK_ON_CASE;
323+
FIXED_WRONG_IS_NOT_NULL_CHECK_ON_CASE,
324+
325+
/**
326+
* Compute year differences in full calendar years.
327+
*/
328+
DATE_DIFF_YEAR_CALENDARIAL;
324329

325330
private final boolean snapshotOnly;
326331
private final FeatureFlag featureFlag;

x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/date/DateDiff.java

Lines changed: 39 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ public class DateDiff extends EsqlScalarFunction {
6666
*/
6767
public enum Part implements DateTimeField {
6868

69-
YEAR((start, end) -> end.getYear() - start.getYear(), "years", "yyyy", "yy"),
69+
YEAR((start, end) -> safeToInt(ChronoUnit.YEARS.between(start, end)), "years", "yyyy", "yy"),
7070
QUARTER((start, end) -> safeToInt(IsoFields.QUARTER_YEARS.between(start, end)), "quarters", "qq", "q"),
7171
MONTH((start, end) -> safeToInt(ChronoUnit.MONTHS.between(start, end)), "months", "mm", "m"),
7272
DAYOFYEAR((start, end) -> safeToInt(ChronoUnit.DAYS.between(start, end)), "dy", "y"),
@@ -126,36 +126,44 @@ public static Part resolve(String dateTimeUnit) {
126126
}
127127
}
128128

129-
@FunctionInfo(returnType = "integer", description = """
130-
Subtracts the `startTimestamp` from the `endTimestamp` and returns the difference in multiples of `unit`.
131-
If `startTimestamp` is later than the `endTimestamp`, negative values are returned.""", detailedDescription = """
132-
[cols=\"^,^\",role=\"styled\"]
133-
|===
134-
2+h|Datetime difference units
135-
136-
s|unit
137-
s|abbreviations
138-
139-
| year | years, yy, yyyy
140-
| quarter | quarters, qq, q
141-
| month | months, mm, m
142-
| dayofyear | dy, y
143-
| day | days, dd, d
144-
| week | weeks, wk, ww
145-
| weekday | weekdays, dw
146-
| hour | hours, hh
147-
| minute | minutes, mi, n
148-
| second | seconds, ss, s
149-
| millisecond | milliseconds, ms
150-
| microsecond | microseconds, mcs
151-
| nanosecond | nanoseconds, ns
152-
|===
153-
154-
Note that while there is an overlap between the function's supported units and
155-
{esql}'s supported time span literals, these sets are distinct and not
156-
interchangeable. Similarly, the supported abbreviations are conveniently shared
157-
with implementations of this function in other established products and not
158-
necessarily common with the date-time nomenclature used by {es}.""", examples = @Example(file = "date", tag = "docsDateDiff"))
129+
@FunctionInfo(
130+
returnType = "integer",
131+
description = """
132+
Subtracts the `startTimestamp` from the `endTimestamp` and returns the difference in multiples of `unit`.
133+
If `startTimestamp` is later than the `endTimestamp`, negative values are returned.""",
134+
detailedDescription = """
135+
[cols=\"^,^\",role=\"styled\"]
136+
|===
137+
2+h|Datetime difference units
138+
139+
s|unit
140+
s|abbreviations
141+
142+
| year | years, yy, yyyy
143+
| quarter | quarters, qq, q
144+
| month | months, mm, m
145+
| dayofyear | dy, y
146+
| day | days, dd, d
147+
| week | weeks, wk, ww
148+
| weekday | weekdays, dw
149+
| hour | hours, hh
150+
| minute | minutes, mi, n
151+
| second | seconds, ss, s
152+
| millisecond | milliseconds, ms
153+
| microsecond | microseconds, mcs
154+
| nanosecond | nanoseconds, ns
155+
|===
156+
157+
Note that while there is an overlap between the function's supported units and
158+
{esql}'s supported time span literals, these sets are distinct and not
159+
interchangeable. Similarly, the supported abbreviations are conveniently shared
160+
with implementations of this function in other established products and not
161+
necessarily common with the date-time nomenclature used by {es}.""",
162+
examples = { @Example(file = "date", tag = "docsDateDiff"), @Example(description = """
163+
When subtracting in calendar units - like year, month a.s.o. - only the fully elapsed units are counted.
164+
To avoid this and obtain also remainders, simply switch to the next smaller unit and do the date math accordingly.
165+
""", file = "date", tag = "evalDateDiffYearForDocs") }
166+
)
159167
public DateDiff(
160168
Source source,
161169
@Param(name = "unit", type = { "keyword", "text" }, description = "Time difference unit") Expression unit,

x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/date/DateDiffTests.java

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,34 @@ public static Iterable<Object[]> parameters() {
113113
)
114114
)
115115
);
116+
suppliers.add(new TestCaseSupplier("Date Diff In Year - 1", List.of(DataType.KEYWORD, DataType.DATETIME, DataType.DATETIME), () -> {
117+
ZonedDateTime zdtStart2 = ZonedDateTime.parse("2023-12-12T00:01:01Z");
118+
ZonedDateTime zdtEnd2 = ZonedDateTime.parse("2024-12-12T00:01:01Z");
119+
return new TestCaseSupplier.TestCase(
120+
List.of(
121+
new TestCaseSupplier.TypedData(new BytesRef("year"), DataType.KEYWORD, "unit"),
122+
new TestCaseSupplier.TypedData(zdtStart2.toInstant().toEpochMilli(), DataType.DATETIME, "startTimestamp"),
123+
new TestCaseSupplier.TypedData(zdtEnd2.toInstant().toEpochMilli(), DataType.DATETIME, "endTimestamp")
124+
),
125+
"DateDiffEvaluator[unit=Attribute[channel=0], startTimestamp=Attribute[channel=1], " + "endTimestamp=Attribute[channel=2]]",
126+
DataType.INTEGER,
127+
equalTo(1)
128+
);
129+
}));
130+
suppliers.add(new TestCaseSupplier("Date Diff In Year - 0", List.of(DataType.KEYWORD, DataType.DATETIME, DataType.DATETIME), () -> {
131+
ZonedDateTime zdtStart2 = ZonedDateTime.parse("2023-12-12T00:01:01.001Z");
132+
ZonedDateTime zdtEnd2 = ZonedDateTime.parse("2024-12-12T00:01:01Z");
133+
return new TestCaseSupplier.TestCase(
134+
List.of(
135+
new TestCaseSupplier.TypedData(new BytesRef("year"), DataType.KEYWORD, "unit"),
136+
new TestCaseSupplier.TypedData(zdtStart2.toInstant().toEpochMilli(), DataType.DATETIME, "startTimestamp"),
137+
new TestCaseSupplier.TypedData(zdtEnd2.toInstant().toEpochMilli(), DataType.DATETIME, "endTimestamp")
138+
),
139+
"DateDiffEvaluator[unit=Attribute[channel=0], startTimestamp=Attribute[channel=1], " + "endTimestamp=Attribute[channel=2]]",
140+
DataType.INTEGER,
141+
equalTo(0)
142+
);
143+
}));
116144
return parameterSuppliersFromTypedData(anyNullIsNull(false, suppliers));
117145
}
118146

0 commit comments

Comments
 (0)