This is an automated email from the ASF dual-hosted git repository.
pvillard31 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git
The following commit(s) were added to refs/heads/main by this push:
new d501315e053 NIFI-15900 Add optional timezone to plusDuration and
minusDuration for DST-aware calendar arithmetic (#11296)
d501315e053 is described below
commit d501315e0536ba7252b2d4bba585bb0330d306cd
Author: Rakesh Kumar Singh <[email protected]>
AuthorDate: Wed Jun 3 02:36:22 2026 +0530
NIFI-15900 Add optional timezone to plusDuration and minusDuration for
DST-aware calendar arithmetic (#11296)
When adding or subtracting calendar units (days, weeks, months, years)
across DST
boundaries, the result should preserve the wall-clock time rather than
adding a
fixed number of seconds. Previously, both functions always used the JVM
system
default timezone when converting the Date to a ZonedDateTime, which produced
incorrect results when the desired timezone differed from the system
default.
Changes:
- Add optional timezone parameter to plusDuration and minusDuration
functions
e.g. plusDuration('1 day', 'America/New_York')
- Update ANTLR parser grammar to allow 1 or 2 arguments for these functions
- AbstractDateArithmeticEvaluator uses the provided timezone (or system
default)
when performing calendar arithmetic
- Add DST boundary test cases covering spring-forward and fall-back
scenarios
---
.../language/antlr/AttributeExpressionParser.g | 5 ++-
.../language/compile/ExpressionCompiler.java | 10 +++++
.../functions/AbstractDateArithmeticEvaluator.java | 41 ++++++++++++++++---
.../functions/MinusDurationEvaluator.java | 15 +++++--
.../functions/PlusDurationEvaluator.java | 15 +++++--
.../attribute/expression/language/TestQuery.java | 46 ++++++++++++++++++++++
.../main/asciidoc/expression-language-guide.adoc | 6 +++
7 files changed, 125 insertions(+), 13 deletions(-)
diff --git
a/nifi-commons/nifi-expression-language/src/main/antlr3/org/apache/nifi/attribute/expression/language/antlr/AttributeExpressionParser.g
b/nifi-commons/nifi-expression-language/src/main/antlr3/org/apache/nifi/attribute/expression/language/antlr/AttributeExpressionParser.g
index 7e8ee3f8c21..fd081dbe424 100644
---
a/nifi-commons/nifi-expression-language/src/main/antlr3/org/apache/nifi/attribute/expression/language/antlr/AttributeExpressionParser.g
+++
b/nifi-commons/nifi-expression-language/src/main/antlr3/org/apache/nifi/attribute/expression/language/antlr/AttributeExpressionParser.g
@@ -98,8 +98,9 @@ oneOrTwoArgBool : IS_VALID_DATE LPAREN! anyArg (COMMA!
anyArg)? RPAREN!;
zeroArgNum : (LENGTH | TO_NUMBER | TO_DECIMAL | TO_MICROS | TO_NANOS |
COUNT) LPAREN! RPAREN!;
oneArgNum : ((INDEX_OF | LAST_INDEX_OF) LPAREN! anyArg RPAREN!) |
((MOD | PLUS | MINUS | MULTIPLY | DIVIDE) LPAREN!
anyArg RPAREN!) |
- ((PLUS_DURATION | MINUS_DURATION |
PLUS_INSTANT_DURATION | MINUS_INSTANT_DURATION) LPAREN! anyArg RPAREN!);
-oneOrTwoArgNum : MATH LPAREN! anyArg (COMMA! anyArg)? RPAREN!;
+ ((PLUS_INSTANT_DURATION | MINUS_INSTANT_DURATION)
LPAREN! anyArg RPAREN!);
+oneOrTwoArgNum : MATH LPAREN! anyArg (COMMA! anyArg)? RPAREN! |
+ (PLUS_DURATION | MINUS_DURATION) LPAREN! anyArg (COMMA!
anyArg)? RPAREN!;
zeroOrOneOrTwoArgNum : TO_DATE LPAREN! anyArg? (COMMA! anyArg)? RPAREN!;
zeroOrTwoArgNum: TO_INSTANT LPAREN! (anyArg COMMA! anyArg)? RPAREN!;
diff --git
a/nifi-commons/nifi-expression-language/src/main/java/org/apache/nifi/attribute/expression/language/compile/ExpressionCompiler.java
b/nifi-commons/nifi-expression-language/src/main/java/org/apache/nifi/attribute/expression/language/compile/ExpressionCompiler.java
index 4b9b34f6e1e..f833a37f3ce 100644
---
a/nifi-commons/nifi-expression-language/src/main/java/org/apache/nifi/attribute/expression/language/compile/ExpressionCompiler.java
+++
b/nifi-commons/nifi-expression-language/src/main/java/org/apache/nifi/attribute/expression/language/compile/ExpressionCompiler.java
@@ -903,11 +903,21 @@ public class ExpressionCompiler {
}
}
case PLUS_DURATION: {
+ if (argEvaluators.size() == 2) {
+ return addToken(new
PlusDurationEvaluator(toDateEvaluator(subjectEvaluator),
+ toStringEvaluator(argEvaluators.get(0)),
+ toStringEvaluator(argEvaluators.get(1))),
"plusDuration");
+ }
verifyArgCount(argEvaluators, 1, "plusDuration");
return addToken(new
PlusDurationEvaluator(toDateEvaluator(subjectEvaluator),
toStringEvaluator(argEvaluators.get(0))),
"plusDuration");
}
case MINUS_DURATION: {
+ if (argEvaluators.size() == 2) {
+ return addToken(new
MinusDurationEvaluator(toDateEvaluator(subjectEvaluator),
+ toStringEvaluator(argEvaluators.get(0)),
+ toStringEvaluator(argEvaluators.get(1))),
"minusDuration");
+ }
verifyArgCount(argEvaluators, 1, "minusDuration");
return addToken(new
MinusDurationEvaluator(toDateEvaluator(subjectEvaluator),
toStringEvaluator(argEvaluators.get(0))),
"minusDuration");
diff --git
a/nifi-commons/nifi-expression-language/src/main/java/org/apache/nifi/attribute/expression/language/evaluation/functions/AbstractDateArithmeticEvaluator.java
b/nifi-commons/nifi-expression-language/src/main/java/org/apache/nifi/attribute/expression/language/evaluation/functions/AbstractDateArithmeticEvaluator.java
index 05d790ab661..187f3b940d8 100644
---
a/nifi-commons/nifi-expression-language/src/main/java/org/apache/nifi/attribute/expression/language/evaluation/functions/AbstractDateArithmeticEvaluator.java
+++
b/nifi-commons/nifi-expression-language/src/main/java/org/apache/nifi/attribute/expression/language/evaluation/functions/AbstractDateArithmeticEvaluator.java
@@ -23,7 +23,9 @@ import
org.apache.nifi.attribute.expression.language.evaluation.Evaluator;
import org.apache.nifi.attribute.expression.language.evaluation.QueryResult;
import
org.apache.nifi.attribute.expression.language.evaluation.literals.StringLiteralEvaluator;
import
org.apache.nifi.attribute.expression.language.evaluation.util.DateAmountParser;
+import
org.apache.nifi.attribute.expression.language.exception.AttributeExpressionLanguageException;
+import java.time.DateTimeException;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.Date;
@@ -34,25 +36,42 @@ import java.util.Date;
* <p>Handles literal-argument validation at construction time and the common
* evaluate-and-convert logic. Subclasses only provide the arithmetic direction
* via {@link #applyAmount(ZonedDateTime, String)}.</p>
+ *
+ * <p>An optional timezone evaluator may be provided so that DST-sensitive
calendar units
+ * (days, weeks, months, years) are applied at the correct wall-clock time
rather than
+ * as a fixed-length instant offset. When omitted, the system default timezone
is used.</p>
*/
abstract class AbstractDateArithmeticEvaluator extends DateEvaluator {
private final Evaluator<Date> subject;
private final Evaluator<String> amountEvaluator;
+ private final Evaluator<String> timeZoneEvaluator;
/**
- * @param subject the date-producing evaluator to operate on
- * @param amountEvaluator the evaluator producing the amount expression
string
+ * @param subject the date-producing evaluator to operate on
+ * @param amountEvaluator the evaluator producing the amount expression
string
+ * @param timeZoneEvaluator optional timezone evaluator (e.g. a timezone
ID string); may be {@code null}
*/
protected AbstractDateArithmeticEvaluator(final Evaluator<Date> subject,
- final Evaluator<String>
amountEvaluator) {
+ final Evaluator<String>
amountEvaluator,
+ final Evaluator<String>
timeZoneEvaluator) {
this.subject = subject;
this.amountEvaluator = amountEvaluator;
+ this.timeZoneEvaluator = timeZoneEvaluator;
if (amountEvaluator instanceof StringLiteralEvaluator) {
DateAmountParser.validate(
((StringLiteralEvaluator)
amountEvaluator).evaluate(null).getValue());
}
+
+ if (timeZoneEvaluator instanceof StringLiteralEvaluator) {
+ final String tz = ((StringLiteralEvaluator)
timeZoneEvaluator).evaluate(null).getValue();
+ try {
+ ZoneId.of(tz);
+ } catch (final DateTimeException e) {
+ throw new AttributeExpressionLanguageException("Invalid
timezone identifier: " + tz, e);
+ }
+ }
}
/** Apply the date arithmetic — plus or minus — to the given date-time. */
@@ -66,8 +85,20 @@ abstract class AbstractDateArithmeticEvaluator extends
DateEvaluator {
}
final String amountExpression =
amountEvaluator.evaluate(evaluationContext).getValue();
- final ZonedDateTime zonedDateTime = ZonedDateTime.ofInstant(
- subjectValue.toInstant(), ZoneId.systemDefault());
+
+ ZoneId zoneId = ZoneId.systemDefault();
+ if (timeZoneEvaluator != null) {
+ final String tz =
timeZoneEvaluator.evaluate(evaluationContext).getValue();
+ if (tz != null) {
+ try {
+ zoneId = ZoneId.of(tz);
+ } catch (final DateTimeException e) {
+ throw new AttributeExpressionLanguageException("Invalid
timezone identifier: " + tz, e);
+ }
+ }
+ }
+
+ final ZonedDateTime zonedDateTime =
ZonedDateTime.ofInstant(subjectValue.toInstant(), zoneId);
final ZonedDateTime result = applyAmount(zonedDateTime,
amountExpression);
return new DateQueryResult(Date.from(result.toInstant()));
diff --git
a/nifi-commons/nifi-expression-language/src/main/java/org/apache/nifi/attribute/expression/language/evaluation/functions/MinusDurationEvaluator.java
b/nifi-commons/nifi-expression-language/src/main/java/org/apache/nifi/attribute/expression/language/evaluation/functions/MinusDurationEvaluator.java
index c95816b9fb0..1b3221a9430 100644
---
a/nifi-commons/nifi-expression-language/src/main/java/org/apache/nifi/attribute/expression/language/evaluation/functions/MinusDurationEvaluator.java
+++
b/nifi-commons/nifi-expression-language/src/main/java/org/apache/nifi/attribute/expression/language/evaluation/functions/MinusDurationEvaluator.java
@@ -23,18 +23,27 @@ import java.time.ZonedDateTime;
import java.util.Date;
/**
- * Evaluator for {@code minusDuration('3 months')} — subtracts a
calendar-aware amount from a Date.
+ * Evaluator for {@code minusDuration('3 months')} or {@code minusDuration('1
day', 'America/Chicago')}
+ * — subtracts a calendar-aware amount from a Date, optionally in a specified
timezone.
+ *
+ * <p>Providing a timezone is important for DST-sensitive units (days, weeks,
months, years):
+ * subtracting "1 day" across a DST boundary should preserve wall-clock time,
not subtract exactly 24 hours.</p>
*
* <p>Examples:</p>
* <pre>
* ${date:toDate('dd-MM-yyyy'):minusDuration('1 month'):format('dd-MM-yyyy')}
- * ${date:toDate('dd-MM-yyyy'):minusDuration('2 weeks')}
+ * ${date:toDate('dd-MM-yyyy', 'America/Chicago'):minusDuration('1 day',
'America/Chicago'):format('dd-MM-yyyy', 'America/Chicago')}
* </pre>
*/
public class MinusDurationEvaluator extends AbstractDateArithmeticEvaluator {
public MinusDurationEvaluator(final Evaluator<Date> subject, final
Evaluator<String> amountEvaluator) {
- super(subject, amountEvaluator);
+ super(subject, amountEvaluator, null);
+ }
+
+ public MinusDurationEvaluator(final Evaluator<Date> subject, final
Evaluator<String> amountEvaluator,
+ final Evaluator<String> timeZoneEvaluator) {
+ super(subject, amountEvaluator, timeZoneEvaluator);
}
@Override
diff --git
a/nifi-commons/nifi-expression-language/src/main/java/org/apache/nifi/attribute/expression/language/evaluation/functions/PlusDurationEvaluator.java
b/nifi-commons/nifi-expression-language/src/main/java/org/apache/nifi/attribute/expression/language/evaluation/functions/PlusDurationEvaluator.java
index fbb594f3d71..30b5b467b8e 100644
---
a/nifi-commons/nifi-expression-language/src/main/java/org/apache/nifi/attribute/expression/language/evaluation/functions/PlusDurationEvaluator.java
+++
b/nifi-commons/nifi-expression-language/src/main/java/org/apache/nifi/attribute/expression/language/evaluation/functions/PlusDurationEvaluator.java
@@ -23,18 +23,27 @@ import java.time.ZonedDateTime;
import java.util.Date;
/**
- * Evaluator for {@code plusDuration('3 months')} — adds a calendar-aware
amount to a Date.
+ * Evaluator for {@code plusDuration('3 months')} or {@code plusDuration('1
day', 'America/New_York')}
+ * — adds a calendar-aware amount to a Date, optionally in a specified
timezone.
+ *
+ * <p>Providing a timezone is important for DST-sensitive units (days, weeks,
months, years):
+ * adding "1 day" across a DST boundary should preserve wall-clock time, not
add exactly 24 hours.</p>
*
* <p>Examples:</p>
* <pre>
* ${date:toDate('dd-MM-yyyy'):plusDuration('1 week'):format('dd-MM-yyyy')}
- * ${date:toDate('dd-MM-yyyy'):plusDuration('2 years')}
+ * ${date:toDate('dd-MM-yyyy', 'America/Chicago'):plusDuration('1 day',
'America/Chicago'):format('dd-MM-yyyy', 'America/Chicago')}
* </pre>
*/
public class PlusDurationEvaluator extends AbstractDateArithmeticEvaluator {
public PlusDurationEvaluator(final Evaluator<Date> subject, final
Evaluator<String> amountEvaluator) {
- super(subject, amountEvaluator);
+ super(subject, amountEvaluator, null);
+ }
+
+ public PlusDurationEvaluator(final Evaluator<Date> subject, final
Evaluator<String> amountEvaluator,
+ final Evaluator<String> timeZoneEvaluator) {
+ super(subject, amountEvaluator, timeZoneEvaluator);
}
@Override
diff --git
a/nifi-commons/nifi-expression-language/src/test/java/org/apache/nifi/attribute/expression/language/TestQuery.java
b/nifi-commons/nifi-expression-language/src/test/java/org/apache/nifi/attribute/expression/language/TestQuery.java
index f924a612202..825e636901a 100644
---
a/nifi-commons/nifi-expression-language/src/test/java/org/apache/nifi/attribute/expression/language/TestQuery.java
+++
b/nifi-commons/nifi-expression-language/src/test/java/org/apache/nifi/attribute/expression/language/TestQuery.java
@@ -3118,6 +3118,40 @@ public class TestQuery {
final QueryResult<?> missingNoToDate =
Query.compile("${missing:plusDuration('1 week')}")
.evaluate(new StandardEvaluationContext(attributes));
assertNull(missingNoToDate.getValue());
+
+ // DST-aware: adding 1 day across a spring-forward DST boundary
preserves wall-clock time
+ // 2026-03-29 00:00:00 CET (+01:00) + 1 day (DST-aware) = 2026-03-30
00:00:00 CEST (+02:00)
+ // Without timezone context, this would add exactly 24 hours → wrong
result due to the DST gap
+ attributes.put("dstDate", "2026-03-29 00:00:00");
+ verifyEquals(
+ "${dstDate:toDate('yyyy-MM-dd HH:mm:ss',
'Europe/Vienna'):plusDuration('1 day', 'Europe/Vienna'):format('yyyy-MM-dd
HH:mm:ssXXX', 'Europe/Vienna')}",
+ attributes,
+ "2026-03-30 00:00:00+02:00");
+
+ // DST-aware: adding 1 week across a spring-forward DST boundary
preserves wall-clock time
+ verifyEquals(
+ "${dstDate:toDate('yyyy-MM-dd HH:mm:ss',
'Europe/Vienna'):plusDuration('1 week', 'Europe/Vienna'):format('yyyy-MM-dd
HH:mm:ssXXX', 'Europe/Vienna')}",
+ attributes,
+ "2026-04-05 00:00:00+02:00");
+
+ // DST-aware: subtracting 1 day while specifying a timezone preserves
wall-clock time
+ attributes.put("fallDate", "2026-11-02 00:00:00");
+ verifyEquals(
+ "${fallDate:toDate('yyyy-MM-dd HH:mm:ss',
'America/New_York'):minusDuration('1 day',
'America/New_York'):format('yyyy-MM-dd HH:mm:ssXXX', 'America/New_York')}",
+ attributes,
+ "2026-11-01 00:00:00-04:00");
+
+ // Invalid literal timezone rejected at compile time
+ assertThrows(
+ AttributeExpressionLanguageException.class,
+ () ->
Query.compile("${date:toDate('dd-MM-yyyy'):plusDuration('1 day',
'America/Fake')}"));
+
+ // Invalid dynamic timezone rejected at evaluation time
+ attributes.put("tz", "NotAZone");
+ assertThrows(
+ AttributeExpressionLanguageException.class,
+ () ->
Query.compile("${date:toDate('dd-MM-yyyy'):plusDuration('1 day',
${tz}):format('dd-MM-yyyy')}")
+ .evaluate(new StandardEvaluationContext(attributes)));
}
@Test
@@ -3289,6 +3323,18 @@ public class TestQuery {
final QueryResult<?> missingNoToDate =
Query.compile("${missing:minusDuration('1 week')}")
.evaluate(new StandardEvaluationContext(attributes));
assertNull(missingNoToDate.getValue());
+
+ // Invalid literal timezone rejected at compile time
+ assertThrows(
+ AttributeExpressionLanguageException.class,
+ () ->
Query.compile("${date:toDate('dd-MM-yyyy'):minusDuration('1 day',
'America/Fake')}"));
+
+ // Invalid dynamic timezone rejected at evaluation time
+ attributes.put("tz", "NotAZone");
+ assertThrows(
+ AttributeExpressionLanguageException.class,
+ () ->
Query.compile("${date:toDate('dd-MM-yyyy'):minusDuration('1 day',
${tz}):format('dd-MM-yyyy')}")
+ .evaluate(new StandardEvaluationContext(attributes)));
}
@Test
diff --git a/nifi-docs/src/main/asciidoc/expression-language-guide.adoc
b/nifi-docs/src/main/asciidoc/expression-language-guide.adoc
index bdf8303513b..1f1e5ecf5dc 100644
--- a/nifi-docs/src/main/asciidoc/expression-language-guide.adoc
+++ b/nifi-docs/src/main/asciidoc/expression-language-guide.adoc
@@ -2590,6 +2590,7 @@ If the subject is null, the function returns null.
*Arguments*:
- [.argName]#_amount_# : [.argDesc]#A string in the form `"<number> <unit>"`.
A space between the number and unit is required.#
+- [.argName]#_timezone_# (optional) : [.argDesc]#A timezone ID string (e.g.
`"America/New_York"`, `"Europe/Vienna"`). When provided, calendar-unit
arithmetic (days, weeks, months, years) is performed in the specified timezone
so that wall-clock time is preserved across DST boundaries. When omitted, the
JVM system default timezone is used.#
*Supported Units*: nanosecond(s), second(s), minute(s), hour(s), day(s),
week(s), month(s), year(s)
@@ -2609,6 +2610,7 @@ NOTE: Nanoseconds are accepted but have no effect on a
`Date` subject because `j
| `${date:toDate('dd-MM-yyyy'):plusDuration('3 months'):format('dd-MM-yyyy')}`
| `04-11-2026`
| `${date:toDate('dd-MM-yyyy'):plusDuration('2 hours'):format('dd-MM-yyyy
HH:mm:ss')}` | `04-08-2026 02:00:00`
| `${date:toDate('dd-MM-yyyy'):plusDuration('1 day')}` | Returns the epoch
milliseconds for the resulting Date.
+| `${date:toDate('yyyy-MM-dd HH:mm:ss', 'Europe/Vienna'):plusDuration('1 day',
'Europe/Vienna'):format('yyyy-MM-dd HH:mm:ssXXX', 'Europe/Vienna')}` (where
date = `"2026-03-29 00:00:00"`) | `2026-03-30 00:00:00+02:00` (DST-aware:
wall-clock time preserved across spring-forward)
|============================================================================
.Calendar-Aware Behavior
@@ -2617,6 +2619,7 @@ NOTE: Nanoseconds are accepted but have no effect on a
`Date` subject because `j
| Month-end clamping | Jan 31 + 1 month | Feb 28 (or Feb 29 in a leap year)
| Leap year clamping | Feb 29, 2024 + 1 year | Feb 28, 2025
| Crossing a day boundary | Aug 4 00:00:00 + 25 hours | Aug 5 01:00:00
+| DST spring-forward (with timezone) | Mar 29 00:00:00 CET + 1 day
(Europe/Vienna) | Mar 30 00:00:00 CEST
|============================================================================
@@ -2632,6 +2635,7 @@ If the subject is null, the function returns null.
*Arguments*:
- [.argName]#_amount_# : [.argDesc]#A string in the form `"<number> <unit>"`.
A space between the number and unit is required.#
+- [.argName]#_timezone_# (optional) : [.argDesc]#A timezone ID string (e.g.
`"America/New_York"`, `"Europe/Vienna"`). When provided, calendar-unit
arithmetic (days, weeks, months, years) is performed in the specified timezone
so that wall-clock time is preserved across DST boundaries. When omitted, the
JVM system default timezone is used.#
*Supported Units*: nanosecond(s), second(s), minute(s), hour(s), day(s),
week(s), month(s), year(s)
@@ -2651,6 +2655,7 @@ NOTE: Nanoseconds are accepted but have no effect on a
`Date` subject because `j
| `${date:toDate('dd-MM-yyyy'):minusDuration('1 year'):format('dd-MM-yyyy')}`
| `04-08-2025`
| `${date:toDate('dd-MM-yyyy'):minusDuration('1 second'):format('dd-MM-yyyy
HH:mm:ss')}` | `03-08-2026 23:59:59`
| `${date:toDate('dd-MM-yyyy'):minusDuration('1 day')}` | Returns the epoch
milliseconds for the resulting Date.
+| `${date:toDate('yyyy-MM-dd HH:mm:ss', 'America/New_York'):minusDuration('1
day', 'America/New_York'):format('yyyy-MM-dd HH:mm:ssXXX',
'America/New_York')}` (where date = `"2026-11-02 00:00:00"`) | `2026-11-01
00:00:00-04:00` (DST-aware: wall-clock time preserved across fall-back)
|============================================================================
.Calendar-Aware Behavior
@@ -2659,6 +2664,7 @@ NOTE: Nanoseconds are accepted but have no effect on a
`Date` subject because `j
| Month-end clamping | Mar 31 - 1 month | Feb 28 (or Feb 29 in a leap year)
| Leap year clamping | Feb 29, 2024 - 1 year | Feb 28, 2023
| Crossing a day boundary | Aug 4 00:00:00 - 1 second | Aug 3 23:59:59
+| DST fall-back (with timezone) | Nov 2 00:00:00 EDT - 1 day
(America/New_York) | Nov 1 00:00:00 EDT
|============================================================================