This is an automated email from the ASF dual-hosted git repository.
pvillard 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 4aab4244d66 NIFI-15658 Add duration arithmetic functions for Date and
Instant values
4aab4244d66 is described below
commit 4aab4244d66ece9723a1a269c6eba9ed5cdb1776
Author: Richard Scott <[email protected]>
AuthorDate: Thu Mar 19 21:00:30 2026 +1100
NIFI-15658 Add duration arithmetic functions for Date and Instant values
This closes #10956.
Signed-off-by: Pierre Villard <[email protected]>
---
.../language/antlr/AttributeExpressionLexer.g | 4 +
.../language/antlr/AttributeExpressionParser.g | 3 +-
.../language/compile/ExpressionCompiler.java | 28 +
.../functions/AbstractDateArithmeticEvaluator.java | 80 +++
.../AbstractInstantArithmeticEvaluator.java | 76 +++
.../functions/MinusDurationEvaluator.java | 44 ++
.../functions/MinusInstantDurationEvaluator.java | 44 ++
.../functions/PlusDurationEvaluator.java | 44 ++
.../functions/PlusInstantDurationEvaluator.java | 44 ++
.../language/evaluation/util/DateAmountParser.java | 127 ++++
.../attribute/expression/language/TestQuery.java | 720 +++++++++++++++++++++
.../evaluation/util/DateAmountParserTest.java | 351 ++++++++++
.../main/asciidoc/expression-language-guide.adoc | 150 +++++
13 files changed, 1714 insertions(+), 1 deletion(-)
diff --git
a/nifi-commons/nifi-expression-language/src/main/antlr3/org/apache/nifi/attribute/expression/language/antlr/AttributeExpressionLexer.g
b/nifi-commons/nifi-expression-language/src/main/antlr3/org/apache/nifi/attribute/expression/language/antlr/AttributeExpressionLexer.g
index 747f907cf2a..8873d60cce6 100644
---
a/nifi-commons/nifi-expression-language/src/main/antlr3/org/apache/nifi/attribute/expression/language/antlr/AttributeExpressionLexer.g
+++
b/nifi-commons/nifi-expression-language/src/main/antlr3/org/apache/nifi/attribute/expression/language/antlr/AttributeExpressionLexer.g
@@ -200,6 +200,10 @@ LESS_THAN_OR_EQUAL : 'le';
FORMAT : 'format'; // takes string date format; uses
DateTimeFormatter
FORMAT_INSTANT : 'formatInstant';
TO_DATE : 'toDate'; // takes string date format;
converts the subject to a Long based on the date format
+MINUS_DURATION : 'minusDuration';
+MINUS_INSTANT_DURATION : 'minusInstantDuration';
+PLUS_DURATION : 'plusDuration';
+PLUS_INSTANT_DURATION : 'plusInstantDuration';
TO_INSTANT : 'toInstant';
MOD : 'mod';
PLUS : 'plus';
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 aaf69b62c90..cadf075fa90 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
@@ -96,7 +96,8 @@ multiArgBool : (IN) LPAREN! anyArg (COMMA! anyArg)* RPAREN!;
// functions that return Numbers (whole or decimal)
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!);
+ ((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!;
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 115a5d50cdb..ff618f50d76 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
@@ -84,7 +84,9 @@ import
org.apache.nifi.attribute.expression.language.evaluation.functions.LessTh
import
org.apache.nifi.attribute.expression.language.evaluation.functions.LessThanOrEqualEvaluator;
import
org.apache.nifi.attribute.expression.language.evaluation.functions.MatchesEvaluator;
import
org.apache.nifi.attribute.expression.language.evaluation.functions.MathEvaluator;
+import
org.apache.nifi.attribute.expression.language.evaluation.functions.MinusDurationEvaluator;
import
org.apache.nifi.attribute.expression.language.evaluation.functions.MinusEvaluator;
+import
org.apache.nifi.attribute.expression.language.evaluation.functions.MinusInstantDurationEvaluator;
import
org.apache.nifi.attribute.expression.language.evaluation.functions.ModEvaluator;
import
org.apache.nifi.attribute.expression.language.evaluation.functions.MultiplyEvaluator;
import
org.apache.nifi.attribute.expression.language.evaluation.functions.NotEvaluator;
@@ -96,7 +98,9 @@ import
org.apache.nifi.attribute.expression.language.evaluation.functions.OneUpS
import
org.apache.nifi.attribute.expression.language.evaluation.functions.OrEvaluator;
import
org.apache.nifi.attribute.expression.language.evaluation.functions.PadLeftEvaluator;
import
org.apache.nifi.attribute.expression.language.evaluation.functions.PadRightEvaluator;
+import
org.apache.nifi.attribute.expression.language.evaluation.functions.PlusDurationEvaluator;
import
org.apache.nifi.attribute.expression.language.evaluation.functions.PlusEvaluator;
+import
org.apache.nifi.attribute.expression.language.evaluation.functions.PlusInstantDurationEvaluator;
import
org.apache.nifi.attribute.expression.language.evaluation.functions.PrependEvaluator;
import
org.apache.nifi.attribute.expression.language.evaluation.functions.RandomNumberGeneratorEvaluator;
import
org.apache.nifi.attribute.expression.language.evaluation.functions.RepeatEvaluator;
@@ -221,6 +225,8 @@ import static
org.apache.nifi.attribute.expression.language.antlr.AttributeExpre
import static
org.apache.nifi.attribute.expression.language.antlr.AttributeExpressionParser.MATCHES;
import static
org.apache.nifi.attribute.expression.language.antlr.AttributeExpressionParser.MATH;
import static
org.apache.nifi.attribute.expression.language.antlr.AttributeExpressionParser.MINUS;
+import static
org.apache.nifi.attribute.expression.language.antlr.AttributeExpressionParser.MINUS_DURATION;
+import static
org.apache.nifi.attribute.expression.language.antlr.AttributeExpressionParser.MINUS_INSTANT_DURATION;
import static
org.apache.nifi.attribute.expression.language.antlr.AttributeExpressionParser.MOD;
import static
org.apache.nifi.attribute.expression.language.antlr.AttributeExpressionParser.MULTIPLY;
import static
org.apache.nifi.attribute.expression.language.antlr.AttributeExpressionParser.MULTI_ATTRIBUTE_REFERENCE;
@@ -234,6 +240,8 @@ import static
org.apache.nifi.attribute.expression.language.antlr.AttributeExpre
import static
org.apache.nifi.attribute.expression.language.antlr.AttributeExpressionParser.PAD_RIGHT;
import static
org.apache.nifi.attribute.expression.language.antlr.AttributeExpressionParser.PARAMETER_REFERENCE;
import static
org.apache.nifi.attribute.expression.language.antlr.AttributeExpressionParser.PLUS;
+import static
org.apache.nifi.attribute.expression.language.antlr.AttributeExpressionParser.PLUS_DURATION;
+import static
org.apache.nifi.attribute.expression.language.antlr.AttributeExpressionParser.PLUS_INSTANT_DURATION;
import static
org.apache.nifi.attribute.expression.language.antlr.AttributeExpressionParser.PREPEND;
import static
org.apache.nifi.attribute.expression.language.antlr.AttributeExpressionParser.RANDOM;
import static
org.apache.nifi.attribute.expression.language.antlr.AttributeExpressionParser.REPEAT;
@@ -875,6 +883,26 @@ public class ExpressionCompiler {
return addToken(new
NumberToDateEvaluator(toWholeNumberEvaluator(subjectEvaluator)), "toDate");
}
}
+ case PLUS_DURATION: {
+ verifyArgCount(argEvaluators, 1, "plusDuration");
+ return addToken(new
PlusDurationEvaluator(toDateEvaluator(subjectEvaluator),
+ toStringEvaluator(argEvaluators.get(0))),
"plusDuration");
+ }
+ case MINUS_DURATION: {
+ verifyArgCount(argEvaluators, 1, "minusDuration");
+ return addToken(new
MinusDurationEvaluator(toDateEvaluator(subjectEvaluator),
+ toStringEvaluator(argEvaluators.get(0))),
"minusDuration");
+ }
+ case PLUS_INSTANT_DURATION: {
+ verifyArgCount(argEvaluators, 1, "plusInstantDuration");
+ return addToken(new
PlusInstantDurationEvaluator(toInstantEvaluator(subjectEvaluator),
+ toStringEvaluator(argEvaluators.get(0))),
"plusInstantDuration");
+ }
+ case MINUS_INSTANT_DURATION: {
+ verifyArgCount(argEvaluators, 1, "minusInstantDuration");
+ return addToken(new
MinusInstantDurationEvaluator(toInstantEvaluator(subjectEvaluator),
+ toStringEvaluator(argEvaluators.get(0))),
"minusInstantDuration");
+ }
case TO_INSTANT: {
if (argEvaluators.isEmpty()) {
return addToken(new
NumberToInstantEvaluator(toWholeNumberEvaluator(subjectEvaluator)),
"toInstant");
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
new file mode 100644
index 00000000000..05d790ab661
--- /dev/null
+++
b/nifi-commons/nifi-expression-language/src/main/java/org/apache/nifi/attribute/expression/language/evaluation/functions/AbstractDateArithmeticEvaluator.java
@@ -0,0 +1,80 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.attribute.expression.language.evaluation.functions;
+
+import org.apache.nifi.attribute.expression.language.EvaluationContext;
+import org.apache.nifi.attribute.expression.language.evaluation.DateEvaluator;
+import
org.apache.nifi.attribute.expression.language.evaluation.DateQueryResult;
+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 java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.Date;
+
+/**
+ * Shared base for {@link PlusDurationEvaluator} and {@link
MinusDurationEvaluator}.
+ *
+ * <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>
+ */
+abstract class AbstractDateArithmeticEvaluator extends DateEvaluator {
+
+ private final Evaluator<Date> subject;
+ private final Evaluator<String> amountEvaluator;
+
+ /**
+ * @param subject the date-producing evaluator to operate on
+ * @param amountEvaluator the evaluator producing the amount expression
string
+ */
+ protected AbstractDateArithmeticEvaluator(final Evaluator<Date> subject,
+ final Evaluator<String>
amountEvaluator) {
+ this.subject = subject;
+ this.amountEvaluator = amountEvaluator;
+
+ if (amountEvaluator instanceof StringLiteralEvaluator) {
+ DateAmountParser.validate(
+ ((StringLiteralEvaluator)
amountEvaluator).evaluate(null).getValue());
+ }
+ }
+
+ /** Apply the date arithmetic — plus or minus — to the given date-time. */
+ protected abstract ZonedDateTime applyAmount(ZonedDateTime dateTime,
String amountExpression);
+
+ @Override
+ public QueryResult<Date> evaluate(final EvaluationContext
evaluationContext) {
+ final Date subjectValue =
subject.evaluate(evaluationContext).getValue();
+ if (subjectValue == null) {
+ return new DateQueryResult(null);
+ }
+
+ final String amountExpression =
amountEvaluator.evaluate(evaluationContext).getValue();
+ final ZonedDateTime zonedDateTime = ZonedDateTime.ofInstant(
+ subjectValue.toInstant(), ZoneId.systemDefault());
+ final ZonedDateTime result = applyAmount(zonedDateTime,
amountExpression);
+
+ return new DateQueryResult(Date.from(result.toInstant()));
+ }
+
+ @Override
+ public Evaluator<?> getSubjectEvaluator() {
+ return subject;
+ }
+}
diff --git
a/nifi-commons/nifi-expression-language/src/main/java/org/apache/nifi/attribute/expression/language/evaluation/functions/AbstractInstantArithmeticEvaluator.java
b/nifi-commons/nifi-expression-language/src/main/java/org/apache/nifi/attribute/expression/language/evaluation/functions/AbstractInstantArithmeticEvaluator.java
new file mode 100644
index 00000000000..1fb50f262b7
--- /dev/null
+++
b/nifi-commons/nifi-expression-language/src/main/java/org/apache/nifi/attribute/expression/language/evaluation/functions/AbstractInstantArithmeticEvaluator.java
@@ -0,0 +1,76 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.attribute.expression.language.evaluation.functions;
+
+import org.apache.nifi.attribute.expression.language.EvaluationContext;
+import org.apache.nifi.attribute.expression.language.evaluation.Evaluator;
+import
org.apache.nifi.attribute.expression.language.evaluation.InstantEvaluator;
+import
org.apache.nifi.attribute.expression.language.evaluation.InstantQueryResult;
+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 java.time.Instant;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+
+/**
+ * Shared base for {@link PlusInstantDurationEvaluator} and {@link
MinusInstantDurationEvaluator}.
+ *
+ * <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>
+ */
+abstract class AbstractInstantArithmeticEvaluator extends InstantEvaluator {
+
+ private final Evaluator<Instant> subject;
+ private final Evaluator<String> amountEvaluator;
+
+ /**
+ * @param subject the instant-producing evaluator to operate on
+ * @param amountEvaluator the evaluator producing the amount expression
string
+ */
+ protected AbstractInstantArithmeticEvaluator(final Evaluator<Instant>
subject,
+ final Evaluator<String>
amountEvaluator) {
+ this.subject = subject;
+ this.amountEvaluator = amountEvaluator;
+ if (amountEvaluator instanceof StringLiteralEvaluator) {
+ DateAmountParser.validate(
+ ((StringLiteralEvaluator)
amountEvaluator).evaluate(null).getValue());
+ }
+ }
+
+ /** Apply the date arithmetic — plus or minus — to the given date-time. */
+ protected abstract ZonedDateTime applyAmount(ZonedDateTime dateTime,
String amountExpression);
+
+ @Override
+ public QueryResult<Instant> evaluate(final EvaluationContext
evaluationContext) {
+ final Instant subjectValue =
subject.evaluate(evaluationContext).getValue();
+ if (subjectValue == null) {
+ return new InstantQueryResult(null);
+ }
+ final String amountExpression =
amountEvaluator.evaluate(evaluationContext).getValue();
+ final ZonedDateTime zonedDateTime =
ZonedDateTime.ofInstant(subjectValue, ZoneOffset.UTC);
+ final ZonedDateTime result = applyAmount(zonedDateTime,
amountExpression);
+ return new InstantQueryResult(result.toInstant());
+ }
+
+ @Override
+ public Evaluator<?> getSubjectEvaluator() {
+ return subject;
+ }
+}
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
new file mode 100644
index 00000000000..c95816b9fb0
--- /dev/null
+++
b/nifi-commons/nifi-expression-language/src/main/java/org/apache/nifi/attribute/expression/language/evaluation/functions/MinusDurationEvaluator.java
@@ -0,0 +1,44 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.attribute.expression.language.evaluation.functions;
+
+import org.apache.nifi.attribute.expression.language.evaluation.Evaluator;
+import
org.apache.nifi.attribute.expression.language.evaluation.util.DateAmountParser;
+
+import java.time.ZonedDateTime;
+import java.util.Date;
+
+/**
+ * Evaluator for {@code minusDuration('3 months')} — subtracts a
calendar-aware amount from a Date.
+ *
+ * <p>Examples:</p>
+ * <pre>
+ * ${date:toDate('dd-MM-yyyy'):minusDuration('1 month'):format('dd-MM-yyyy')}
+ * ${date:toDate('dd-MM-yyyy'):minusDuration('2 weeks')}
+ * </pre>
+ */
+public class MinusDurationEvaluator extends AbstractDateArithmeticEvaluator {
+
+ public MinusDurationEvaluator(final Evaluator<Date> subject, final
Evaluator<String> amountEvaluator) {
+ super(subject, amountEvaluator);
+ }
+
+ @Override
+ protected ZonedDateTime applyAmount(final ZonedDateTime dateTime, final
String amountExpression) {
+ return DateAmountParser.minus(dateTime, amountExpression);
+ }
+}
diff --git
a/nifi-commons/nifi-expression-language/src/main/java/org/apache/nifi/attribute/expression/language/evaluation/functions/MinusInstantDurationEvaluator.java
b/nifi-commons/nifi-expression-language/src/main/java/org/apache/nifi/attribute/expression/language/evaluation/functions/MinusInstantDurationEvaluator.java
new file mode 100644
index 00000000000..18c7ffb636a
--- /dev/null
+++
b/nifi-commons/nifi-expression-language/src/main/java/org/apache/nifi/attribute/expression/language/evaluation/functions/MinusInstantDurationEvaluator.java
@@ -0,0 +1,44 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.attribute.expression.language.evaluation.functions;
+
+import org.apache.nifi.attribute.expression.language.evaluation.Evaluator;
+import
org.apache.nifi.attribute.expression.language.evaluation.util.DateAmountParser;
+
+import java.time.Instant;
+import java.time.ZonedDateTime;
+
+/**
+ * Evaluator for {@code minusInstantDuration('3 months')} — subtracts a
calendar-aware amount from an Instant.
+ *
+ * <p>Examples:</p>
+ * <pre>
+ * ${date:toInstant('dd-MM-yyyy', 'UTC'):minusInstantDuration('1
month'):formatInstant('dd-MM-yyyy', 'UTC')}
+ * ${date:toInstant('dd-MM-yyyy', 'UTC'):minusInstantDuration('2 weeks')}
+ * </pre>
+ */
+public class MinusInstantDurationEvaluator extends
AbstractInstantArithmeticEvaluator {
+
+ public MinusInstantDurationEvaluator(final Evaluator<Instant> subject,
final Evaluator<String> amountEvaluator) {
+ super(subject, amountEvaluator);
+ }
+
+ @Override
+ protected ZonedDateTime applyAmount(final ZonedDateTime dateTime, final
String amountExpression) {
+ return DateAmountParser.minus(dateTime, amountExpression);
+ }
+}
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
new file mode 100644
index 00000000000..fbb594f3d71
--- /dev/null
+++
b/nifi-commons/nifi-expression-language/src/main/java/org/apache/nifi/attribute/expression/language/evaluation/functions/PlusDurationEvaluator.java
@@ -0,0 +1,44 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.attribute.expression.language.evaluation.functions;
+
+import org.apache.nifi.attribute.expression.language.evaluation.Evaluator;
+import
org.apache.nifi.attribute.expression.language.evaluation.util.DateAmountParser;
+
+import java.time.ZonedDateTime;
+import java.util.Date;
+
+/**
+ * Evaluator for {@code plusDuration('3 months')} — adds a calendar-aware
amount to a Date.
+ *
+ * <p>Examples:</p>
+ * <pre>
+ * ${date:toDate('dd-MM-yyyy'):plusDuration('1 week'):format('dd-MM-yyyy')}
+ * ${date:toDate('dd-MM-yyyy'):plusDuration('2 years')}
+ * </pre>
+ */
+public class PlusDurationEvaluator extends AbstractDateArithmeticEvaluator {
+
+ public PlusDurationEvaluator(final Evaluator<Date> subject, final
Evaluator<String> amountEvaluator) {
+ super(subject, amountEvaluator);
+ }
+
+ @Override
+ protected ZonedDateTime applyAmount(final ZonedDateTime dateTime, final
String amountExpression) {
+ return DateAmountParser.plus(dateTime, amountExpression);
+ }
+}
diff --git
a/nifi-commons/nifi-expression-language/src/main/java/org/apache/nifi/attribute/expression/language/evaluation/functions/PlusInstantDurationEvaluator.java
b/nifi-commons/nifi-expression-language/src/main/java/org/apache/nifi/attribute/expression/language/evaluation/functions/PlusInstantDurationEvaluator.java
new file mode 100644
index 00000000000..38ade39103a
--- /dev/null
+++
b/nifi-commons/nifi-expression-language/src/main/java/org/apache/nifi/attribute/expression/language/evaluation/functions/PlusInstantDurationEvaluator.java
@@ -0,0 +1,44 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.attribute.expression.language.evaluation.functions;
+
+import org.apache.nifi.attribute.expression.language.evaluation.Evaluator;
+import
org.apache.nifi.attribute.expression.language.evaluation.util.DateAmountParser;
+
+import java.time.Instant;
+import java.time.ZonedDateTime;
+
+/**
+ * Evaluator for {@code plusInstantDuration('3 months')} — adds a
calendar-aware amount to an Instant.
+ *
+ * <p>Examples:</p>
+ * <pre>
+ * ${date:toInstant('dd-MM-yyyy', 'UTC'):plusInstantDuration('1
week'):formatInstant('dd-MM-yyyy', 'UTC')}
+ * ${date:toInstant('dd-MM-yyyy', 'UTC'):plusInstantDuration('2 years')}
+ * </pre>
+ */
+public class PlusInstantDurationEvaluator extends
AbstractInstantArithmeticEvaluator {
+
+ public PlusInstantDurationEvaluator(final Evaluator<Instant> subject,
final Evaluator<String> amountEvaluator) {
+ super(subject, amountEvaluator);
+ }
+
+ @Override
+ protected ZonedDateTime applyAmount(final ZonedDateTime dateTime, final
String amountExpression) {
+ return DateAmountParser.plus(dateTime, amountExpression);
+ }
+}
diff --git
a/nifi-commons/nifi-expression-language/src/main/java/org/apache/nifi/attribute/expression/language/evaluation/util/DateAmountParser.java
b/nifi-commons/nifi-expression-language/src/main/java/org/apache/nifi/attribute/expression/language/evaluation/util/DateAmountParser.java
new file mode 100644
index 00000000000..dda55e1ced3
--- /dev/null
+++
b/nifi-commons/nifi-expression-language/src/main/java/org/apache/nifi/attribute/expression/language/evaluation/util/DateAmountParser.java
@@ -0,0 +1,127 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.attribute.expression.language.evaluation.util;
+
+import
org.apache.nifi.attribute.expression.language.exception.AttributeExpressionLanguageException;
+
+import java.time.Duration;
+import java.time.Period;
+import java.time.ZonedDateTime;
+import java.util.Locale;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Parses a human-readable duration expression (e.g. {@code "2 weeks"}, {@code
"1 month"})
+ * and applies calendar-aware arithmetic to a {@link ZonedDateTime}.
+ *
+ * <p>Format: {@code <positive-integer> <unit>} — a space between number and
unit is required.
+ * Units are case-insensitive and accept singular/plural: second(s),
minute(s), hour(s),
+ * day(s), week(s), month(s), year(s).</p>
+ */
+public final class DateAmountParser {
+
+ // Requires at least one whitespace character (\s+) between the integer
and unit.
+ private static final Pattern AMOUNT_PATTERN = Pattern.compile(
+
"^\\s*(\\d+)\\s+(nanoseconds?|seconds?|minutes?|hours?|days?|weeks?|months?|years?)\\s*$",
+ Pattern.CASE_INSENSITIVE
+ );
+
+ private static final String EXPECTED_FORMAT =
+ "Expected format: '<integer> <unit>' (e.g. '2 weeks', '1 month'). "
+ + "Supported units: nanosecond(s), second(s), minute(s),
hour(s), day(s), week(s), month(s), year(s)";
+
+ private record ParsedAmount(long amount, String unit) { }
+
+ private DateAmountParser() { }
+
+ /**
+ * Validates that the expression matches {@code <integer> <unit>} format.
+ * Called at evaluator construction time for literal arguments so the
processor
+ * fails validation before it can start.
+ */
+ public static void validate(final String amountExpression) {
+ parse(amountExpression);
+ }
+
+ /** Adds the parsed amount to the given date-time. */
+ public static ZonedDateTime plus(final ZonedDateTime dateTime, final
String amountExpression) {
+ return applyAmount(dateTime, parse(amountExpression), true);
+ }
+
+ /** Subtracts the parsed amount from the given date-time. */
+ public static ZonedDateTime minus(final ZonedDateTime dateTime, final
String amountExpression) {
+ return applyAmount(dateTime, parse(amountExpression), false);
+ }
+
+ /**
+ * Parses and validates the amount expression in a single pass.
+ * Shared by both {@link #validate} and the arithmetic methods.
+ */
+ private static ParsedAmount parse(final String amountExpression) {
+ if (amountExpression == null || amountExpression.isBlank()) {
+ throw new AttributeExpressionLanguageException(
+ "Amount expression cannot be null or empty. " +
EXPECTED_FORMAT);
+ }
+
+ final Matcher matcher = AMOUNT_PATTERN.matcher(amountExpression);
+ if (!matcher.matches()) {
+ throw new AttributeExpressionLanguageException(
+ "Invalid amount expression: '" + amountExpression + "'. "
+ EXPECTED_FORMAT);
+ }
+
+ final long amount;
+ try {
+ amount = Long.parseLong(matcher.group(1));
+ } catch (final NumberFormatException e) {
+ throw new AttributeExpressionLanguageException(
+ "Numeric overflow in expression: '" + amountExpression +
"'. " + EXPECTED_FORMAT, e);
+ }
+
+ final String rawUnit = matcher.group(2).toLowerCase(Locale.ROOT);
+ // Normalize plural to singular: "seconds" -> "second", "weeks" ->
"week"
+ final String unit = (rawUnit.length() > 1 && rawUnit.endsWith("s"))
+ ? rawUnit.substring(0, rawUnit.length() - 1) : rawUnit;
+
+ return new ParsedAmount(amount, unit);
+ }
+
+ private static ZonedDateTime applyAmount(final ZonedDateTime dateTime,
+ final ParsedAmount parsed, final
boolean add) {
+ final long amount = parsed.amount();
+ return switch (parsed.unit()) {
+ case "nanosecond" -> add ? dateTime.plus(Duration.ofNanos(amount))
+ : dateTime.minus(Duration.ofNanos(amount));
+ case "second" -> add ? dateTime.plus(Duration.ofSeconds(amount))
+ : dateTime.minus(Duration.ofSeconds(amount));
+ case "minute" -> add ? dateTime.plus(Duration.ofMinutes(amount))
+ : dateTime.minus(Duration.ofMinutes(amount));
+ case "hour" -> add ? dateTime.plus(Duration.ofHours(amount))
+ : dateTime.minus(Duration.ofHours(amount));
+ case "day" -> add ? dateTime.plusDays(amount)
+ : dateTime.minusDays(amount);
+ case "week" -> add ? dateTime.plusWeeks(amount)
+ : dateTime.minusWeeks(amount);
+ case "month" -> add ?
dateTime.plus(Period.ofMonths(Math.toIntExact(amount)))
+ : dateTime.minus(Period.ofMonths(Math.toIntExact(amount)));
+ case "year" -> add ?
dateTime.plus(Period.ofYears(Math.toIntExact(amount)))
+ : dateTime.minus(Period.ofYears(Math.toIntExact(amount)));
+ default -> throw new AttributeExpressionLanguageException(
+ "Unsupported time unit: '" + parsed.unit() + "'. " +
EXPECTED_FORMAT);
+ };
+ }
+}
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 044b5ce962b..3bd9cb80bd6 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
@@ -60,6 +60,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.eq;
@@ -2866,4 +2868,722 @@ public class TestQuery {
// Missing attribute evaluates to empty string
verifyEquals("${missing:trimDelimitedList(',')}", attributes, "");
}
+
+ @Test
+ public void testPlusDuration() {
+ final Map<String, String> attributes = new HashMap<>();
+ attributes.put("date", "04-08-2026");
+
+ // All units
+ verifyEquals(
+ "${date:toDate('dd-MM-yyyy'):plusDuration('1000000000
nanoseconds'):format('dd-MM-yyyy HH:mm:ss')}",
+ attributes,
+ "04-08-2026 00:00:01");
+ verifyEquals(
+ "${date:toDate('dd-MM-yyyy'):plusDuration('30
seconds'):format('dd-MM-yyyy HH:mm:ss')}",
+ attributes,
+ "04-08-2026 00:00:30");
+ verifyEquals(
+ "${date:toDate('dd-MM-yyyy'):plusDuration('15
minutes'):format('dd-MM-yyyy HH:mm:ss')}",
+ attributes,
+ "04-08-2026 00:15:00");
+ verifyEquals(
+ "${date:toDate('dd-MM-yyyy'):plusDuration('2
hours'):format('dd-MM-yyyy HH:mm:ss')}",
+ attributes,
+ "04-08-2026 02:00:00");
+ verifyEquals(
+ "${date:toDate('dd-MM-yyyy'):plusDuration('7
days'):format('dd-MM-yyyy')}",
+ attributes,
+ "11-08-2026");
+ verifyEquals(
+ "${date:toDate('dd-MM-yyyy'):plusDuration('2
weeks'):format('dd-MM-yyyy')}",
+ attributes,
+ "18-08-2026");
+ verifyEquals(
+ "${date:toDate('dd-MM-yyyy'):plusDuration('3
months'):format('dd-MM-yyyy')}",
+ attributes,
+ "04-11-2026");
+ verifyEquals(
+ "${date:toDate('dd-MM-yyyy'):plusDuration('1
year'):format('dd-MM-yyyy')}",
+ attributes,
+ "04-08-2027");
+
+ // Time crossing day boundary from midnight
+ verifyEquals(
+ "${date:toDate('dd-MM-yyyy'):plusDuration('25
hours'):format('dd-MM-yyyy')}",
+ attributes,
+ "05-08-2026");
+ verifyEquals(
+ "${date:toDate('dd-MM-yyyy'):plusDuration('90
minutes'):format('dd-MM-yyyy HH:mm:ss')}",
+ attributes,
+ "04-08-2026 01:30:00");
+
+ // Nanoseconds — singular and plural
+ verifyEquals(
+ "${date:toDate('dd-MM-yyyy'):plusDuration('1
nanosecond'):format('dd-MM-yyyy HH:mm:ss')}",
+ attributes,
+ "04-08-2026 00:00:00");
+ verifyEquals(
+ "${date:toDate('dd-MM-yyyy'):plusDuration('1
nanoseconds'):format('dd-MM-yyyy HH:mm:ss')}",
+ attributes,
+ "04-08-2026 00:00:00");
+
+ // Singular and plural
+ verifyEquals(
+ "${date:toDate('dd-MM-yyyy'):plusDuration('1
day'):format('dd-MM-yyyy')}",
+ attributes,
+ "05-08-2026");
+ verifyEquals(
+ "${date:toDate('dd-MM-yyyy'):plusDuration('1
days'):format('dd-MM-yyyy')}",
+ attributes,
+ "05-08-2026");
+
+ // Case-insensitive
+ verifyEquals(
+ "${date:toDate('dd-MM-yyyy'):plusDuration('2
WEEKS'):format('dd-MM-yyyy')}",
+ attributes,
+ "18-08-2026");
+ verifyEquals(
+ "${date:toDate('dd-MM-yyyy'):plusDuration('1
Month'):format('dd-MM-yyyy')}",
+ attributes,
+ "04-09-2026");
+
+ // Zero amount
+ verifyEquals(
+ "${date:toDate('dd-MM-yyyy'):plusDuration('0
days'):format('dd-MM-yyyy')}",
+ attributes,
+ "04-08-2026");
+
+ // DateTime input
+ attributes.put("dt", "04-08-2026 10:30:00");
+ verifyEquals(
+ "${dt:toDate('dd-MM-yyyy HH:mm:ss'):plusDuration('3
hours'):format('dd-MM-yyyy HH:mm:ss')}",
+ attributes,
+ "04-08-2026 13:30:00");
+
+ // Chaining
+ verifyEquals(
+ "${date:toDate('dd-MM-yyyy'):plusDuration('1
month'):minusDuration('2 days'):format('dd-MM-yyyy')}",
+ attributes,
+ "02-09-2026");
+ verifyEquals(
+ "${date:toDate('dd-MM-yyyy'):plusDuration('1
year'):plusDuration('1 month'):plusDuration('1 day'):format('dd-MM-yyyy')}",
+ attributes,
+ "05-09-2027");
+ verifyEquals(
+ "${date:toDate('dd-MM-yyyy'):plusDuration('2
hours'):minusDuration('30 minutes'):format('dd-MM-yyyy HH:mm:ss')}",
+ attributes,
+ "04-08-2026 01:30:00");
+
+ // Leap year: Feb 29, 2024 + 1 year = Feb 28, 2025
+ attributes.put("date", "29-02-2024");
+ verifyEquals(
+ "${date:toDate('dd-MM-yyyy'):plusDuration('1
year'):format('dd-MM-yyyy')}",
+ attributes,
+ "28-02-2025");
+
+ // Month-end clamping: Jan 31 + 1 month = Feb 28 (non-leap)
+ attributes.put("date", "31-01-2026");
+ verifyEquals(
+ "${date:toDate('dd-MM-yyyy'):plusDuration('1
month'):format('dd-MM-yyyy')}",
+ attributes,
+ "28-02-2026");
+
+ // Month-end clamping: Jan 31 + 1 month = Feb 29 (leap year)
+ attributes.put("date", "31-01-2024");
+ verifyEquals(
+ "${date:toDate('dd-MM-yyyy'):plusDuration('1
month'):format('dd-MM-yyyy')}",
+ attributes,
+ "29-02-2024");
+
+ // Dynamic attribute as amount
+ attributes.put("date", "04-08-2026");
+ attributes.put("timeUnit", "2 weeks");
+ verifyEquals(
+
"${date:toDate('dd-MM-yyyy'):plusDuration(${timeUnit}):format('dd-MM-yyyy')}",
+ attributes,
+ "18-08-2026");
+
+ // Without format() — returns Date
+ final QueryResult<?> epochResult =
Query.compile("${date:toDate('dd-MM-yyyy'):plusDuration('1 week')}")
+ .evaluate(new StandardEvaluationContext(attributes));
+ assertNotNull(epochResult.getValue());
+ assertInstanceOf(Date.class, epochResult.getValue());
+
+ // Dynamic attribute — invalid throws at evaluation
+ attributes.put("timeUnit", "1 Monday");
+ assertThrows(
+ AttributeExpressionLanguageException.class,
+ () ->
Query.compile("${date:toDate('dd-MM-yyyy'):plusDuration(${timeUnit}):format('dd-MM-yyyy')}")
+ .evaluate(new StandardEvaluationContext(attributes)));
+
+ // Invalid literal rejected at compile time
+ assertThrows(
+ AttributeExpressionLanguageException.class,
+ () ->
Query.compile("${date:toDate('dd-MM-yyyy'):plusDuration('1 Monday')}"));
+ assertThrows(
+ AttributeExpressionLanguageException.class,
+ () ->
Query.compile("${date:toDate('dd-MM-yyyy'):plusDuration('1days')}"));
+ assertThrows(
+ AttributeExpressionLanguageException.class,
+ () ->
Query.compile("${date:toDate('dd-MM-yyyy'):plusDuration('2 fortnights')}"));
+
+ // Subject is not a date — no toDate() call, throws at evaluation
+ assertThrows(
+ AttributeExpressionLanguageException.class,
+ () -> Query.compile("${date:plusDuration('1 week')}")
+ .evaluate(new StandardEvaluationContext(attributes)));
+
+ // Missing attribute returns null
+ final QueryResult<?> missingNoToDate =
Query.compile("${missing:plusDuration('1 week')}")
+ .evaluate(new StandardEvaluationContext(attributes));
+ assertNull(missingNoToDate.getValue());
+ }
+
+ @Test
+ public void testMinusDuration() {
+ final Map<String, String> attributes = new HashMap<>();
+ attributes.put("date", "04-08-2026");
+
+ // All units
+ verifyEquals(
+ "${date:toDate('dd-MM-yyyy'):minusDuration('1000000000
nanoseconds'):format('dd-MM-yyyy HH:mm:ss')}",
+ attributes,
+ "03-08-2026 23:59:59");
+ verifyEquals(
+ "${date:toDate('dd-MM-yyyy'):minusDuration('30
seconds'):format('dd-MM-yyyy HH:mm:ss')}",
+ attributes,
+ "03-08-2026 23:59:30");
+ verifyEquals(
+ "${date:toDate('dd-MM-yyyy'):minusDuration('15
minutes'):format('dd-MM-yyyy HH:mm:ss')}",
+ attributes,
+ "03-08-2026 23:45:00");
+ verifyEquals(
+ "${date:toDate('dd-MM-yyyy'):minusDuration('2
hours'):format('dd-MM-yyyy HH:mm:ss')}",
+ attributes,
+ "03-08-2026 22:00:00");
+ verifyEquals(
+ "${date:toDate('dd-MM-yyyy'):minusDuration('7
days'):format('dd-MM-yyyy')}",
+ attributes,
+ "28-07-2026");
+ verifyEquals(
+ "${date:toDate('dd-MM-yyyy'):minusDuration('2
weeks'):format('dd-MM-yyyy')}",
+ attributes,
+ "21-07-2026");
+ verifyEquals(
+ "${date:toDate('dd-MM-yyyy'):minusDuration('3
months'):format('dd-MM-yyyy')}",
+ attributes,
+ "04-05-2026");
+ verifyEquals(
+ "${date:toDate('dd-MM-yyyy'):minusDuration('1
year'):format('dd-MM-yyyy')}",
+ attributes,
+ "04-08-2025");
+
+ // Time crossing day boundary from midnight
+ verifyEquals(
+ "${date:toDate('dd-MM-yyyy'):minusDuration('25
hours'):format('dd-MM-yyyy')}",
+ attributes,
+ "02-08-2026");
+ verifyEquals(
+ "${date:toDate('dd-MM-yyyy'):minusDuration('90
minutes'):format('dd-MM-yyyy HH:mm:ss')}",
+ attributes,
+ "03-08-2026 22:30:00");
+
+ // Nanoseconds — singular and plural (Date truncates to ms, so 1 ns
crosses into the previous second)
+ verifyEquals(
+ "${date:toDate('dd-MM-yyyy'):minusDuration('1
nanosecond'):format('dd-MM-yyyy HH:mm:ss')}",
+ attributes,
+ "03-08-2026 23:59:59");
+ verifyEquals(
+ "${date:toDate('dd-MM-yyyy'):minusDuration('1
nanoseconds'):format('dd-MM-yyyy HH:mm:ss')}",
+ attributes,
+ "03-08-2026 23:59:59");
+
+ // Singular and plural
+ verifyEquals(
+ "${date:toDate('dd-MM-yyyy'):minusDuration('1
day'):format('dd-MM-yyyy')}",
+ attributes,
+ "03-08-2026");
+ verifyEquals(
+ "${date:toDate('dd-MM-yyyy'):minusDuration('1
days'):format('dd-MM-yyyy')}",
+ attributes,
+ "03-08-2026");
+
+ // Case-insensitive
+ verifyEquals(
+ "${date:toDate('dd-MM-yyyy'):minusDuration('2
WEEKS'):format('dd-MM-yyyy')}",
+ attributes,
+ "21-07-2026");
+ verifyEquals(
+ "${date:toDate('dd-MM-yyyy'):minusDuration('1
Month'):format('dd-MM-yyyy')}",
+ attributes,
+ "04-07-2026");
+
+ // Zero amount
+ verifyEquals(
+ "${date:toDate('dd-MM-yyyy'):minusDuration('0
days'):format('dd-MM-yyyy')}",
+ attributes,
+ "04-08-2026");
+
+ // DateTime input
+ attributes.put("dt", "04-08-2026 10:30:00");
+ verifyEquals(
+ "${dt:toDate('dd-MM-yyyy HH:mm:ss'):minusDuration('3
hours'):format('dd-MM-yyyy HH:mm:ss')}",
+ attributes,
+ "04-08-2026 07:30:00");
+
+ // Chaining
+ verifyEquals(
+ "${date:toDate('dd-MM-yyyy'):minusDuration('1
month'):plusDuration('2 days'):format('dd-MM-yyyy')}",
+ attributes,
+ "06-07-2026");
+ verifyEquals(
+ "${date:toDate('dd-MM-yyyy'):minusDuration('1
year'):minusDuration('1 month'):minusDuration('1 day'):format('dd-MM-yyyy')}",
+ attributes,
+ "03-07-2025");
+ verifyEquals(
+ "${date:toDate('dd-MM-yyyy'):minusDuration('2
hours'):plusDuration('30 minutes'):format('dd-MM-yyyy HH:mm:ss')}",
+ attributes,
+ "03-08-2026 22:30:00");
+
+ // Leap year: Feb 29, 2024 - 1 year = Feb 28, 2023
+ attributes.put("date", "29-02-2024");
+ verifyEquals(
+ "${date:toDate('dd-MM-yyyy'):minusDuration('1
year'):format('dd-MM-yyyy')}",
+ attributes,
+ "28-02-2023");
+
+ // Month-end clamping: Mar 31 - 1 month = Feb 28 (non-leap)
+ attributes.put("date", "31-03-2026");
+ verifyEquals(
+ "${date:toDate('dd-MM-yyyy'):minusDuration('1
month'):format('dd-MM-yyyy')}",
+ attributes,
+ "28-02-2026");
+
+ // Month-end clamping: Mar 31 - 1 month = Feb 29 (leap year)
+ attributes.put("date", "31-03-2024");
+ verifyEquals(
+ "${date:toDate('dd-MM-yyyy'):minusDuration('1
month'):format('dd-MM-yyyy')}",
+ attributes,
+ "29-02-2024");
+
+ // Dynamic attribute as amount
+ attributes.put("date", "04-08-2026");
+ attributes.put("timeUnit", "2 weeks");
+ verifyEquals(
+
"${date:toDate('dd-MM-yyyy'):minusDuration(${timeUnit}):format('dd-MM-yyyy')}",
+ attributes,
+ "21-07-2026");
+
+ // Without format() — returns Date
+ final QueryResult<?> epochResult =
Query.compile("${date:toDate('dd-MM-yyyy'):minusDuration('1 week')}")
+ .evaluate(new StandardEvaluationContext(attributes));
+ assertNotNull(epochResult.getValue());
+ assertInstanceOf(Date.class, epochResult.getValue());
+
+ // Dynamic attribute — invalid throws at evaluation
+ attributes.put("timeUnit", "1 Monday");
+ assertThrows(
+ AttributeExpressionLanguageException.class,
+ () ->
Query.compile("${date:toDate('dd-MM-yyyy'):minusDuration(${timeUnit}):format('dd-MM-yyyy')}")
+ .evaluate(new StandardEvaluationContext(attributes)));
+
+ // Invalid literal rejected at compile time
+ assertThrows(
+ AttributeExpressionLanguageException.class,
+ () ->
Query.compile("${date:toDate('dd-MM-yyyy'):minusDuration('1 Monday')}"));
+ assertThrows(
+ AttributeExpressionLanguageException.class,
+ () ->
Query.compile("${date:toDate('dd-MM-yyyy'):minusDuration('1days')}"));
+ assertThrows(
+ AttributeExpressionLanguageException.class,
+ () ->
Query.compile("${date:toDate('dd-MM-yyyy'):minusDuration('2 fortnights')}"));
+
+ // Subject is not a date — no toDate() call, throws at evaluation
+ assertThrows(
+ AttributeExpressionLanguageException.class,
+ () -> Query.compile("${date:minusDuration('1 week')}")
+ .evaluate(new StandardEvaluationContext(attributes)));
+
+ // Missing attribute returns null
+ final QueryResult<?> missingNoToDate =
Query.compile("${missing:minusDuration('1 week')}")
+ .evaluate(new StandardEvaluationContext(attributes));
+ assertNull(missingNoToDate.getValue());
+ }
+
+ @Test
+ public void testPlusInstantDuration() {
+ final Map<String, String> attributes = new HashMap<>();
+ attributes.put("date", "04-08-2026 00:00:00");
+
+ // All units
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):plusInstantDuration('1000000000 nanoseconds'):formatInstant('dd-MM-yyyy
HH:mm:ss', 'UTC')}",
+ attributes,
+ "04-08-2026 00:00:01");
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):plusInstantDuration('30 seconds'):formatInstant('dd-MM-yyyy HH:mm:ss',
'UTC')}",
+ attributes,
+ "04-08-2026 00:00:30");
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):plusInstantDuration('15 minutes'):formatInstant('dd-MM-yyyy HH:mm:ss',
'UTC')}",
+ attributes,
+ "04-08-2026 00:15:00");
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):plusInstantDuration('2 hours'):formatInstant('dd-MM-yyyy HH:mm:ss',
'UTC')}",
+ attributes,
+ "04-08-2026 02:00:00");
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):plusInstantDuration('7 days'):formatInstant('dd-MM-yyyy', 'UTC')}",
+ attributes,
+ "11-08-2026");
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):plusInstantDuration('2 weeks'):formatInstant('dd-MM-yyyy', 'UTC')}",
+ attributes,
+ "18-08-2026");
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):plusInstantDuration('3 months'):formatInstant('dd-MM-yyyy', 'UTC')}",
+ attributes,
+ "04-11-2026");
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):plusInstantDuration('1 year'):formatInstant('dd-MM-yyyy', 'UTC')}",
+ attributes,
+ "04-08-2027");
+
+ // Non-UTC timezone
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'Australia/Brisbane'):plusInstantDuration('3 months'):formatInstant('dd-MM-yyyy
HH:mm:ss', 'Australia/Brisbane')}",
+ attributes,
+ "04-11-2026 00:00:00");
+
+ // Across daylight savings
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'Australia/Sydney'):plusInstantDuration('3 months'):formatInstant('dd-MM-yyyy
HH:mm:ss', 'Australia/Sydney')}",
+ attributes,
+ "04-11-2026 01:00:00");
+
+ // Time crossing day boundary from midnight
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):plusInstantDuration('25 hours'):formatInstant('dd-MM-yyyy', 'UTC')}",
+ attributes,
+ "05-08-2026");
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):plusInstantDuration('90 minutes'):formatInstant('dd-MM-yyyy HH:mm:ss',
'UTC')}",
+ attributes,
+ "04-08-2026 01:30:00");
+
+ // Nanoseconds — sub-second precision and singular/plural
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):plusInstantDuration('1 nanosecond'):formatInstant('dd-MM-yyyy
HH:mm:ss.SSSSSSSSS', 'UTC')}",
+ attributes,
+ "04-08-2026 00:00:00.000000001");
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):plusInstantDuration('1 nanoseconds'):formatInstant('dd-MM-yyyy
HH:mm:ss.SSSSSSSSS', 'UTC')}",
+ attributes,
+ "04-08-2026 00:00:00.000000001");
+
+ // Singular and plural
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):plusInstantDuration('1 day'):formatInstant('dd-MM-yyyy', 'UTC')}",
+ attributes,
+ "05-08-2026");
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):plusInstantDuration('1 days'):formatInstant('dd-MM-yyyy', 'UTC')}",
+ attributes,
+ "05-08-2026");
+
+ // Case-insensitive
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):plusInstantDuration('2 WEEKS'):formatInstant('dd-MM-yyyy', 'UTC')}",
+ attributes,
+ "18-08-2026");
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):plusInstantDuration('1 Month'):formatInstant('dd-MM-yyyy', 'UTC')}",
+ attributes,
+ "04-09-2026");
+
+ // Zero amount
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):plusInstantDuration('0 days'):formatInstant('dd-MM-yyyy', 'UTC')}",
+ attributes,
+ "04-08-2026");
+
+ // DateTime input
+ attributes.put("dt", "04-08-2026 10:30:00");
+ verifyEquals(
+ "${dt:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):plusInstantDuration('3 hours'):formatInstant('dd-MM-yyyy HH:mm:ss',
'UTC')}",
+ attributes,
+ "04-08-2026 13:30:00");
+
+ // Chaining
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):plusInstantDuration('1 month'):minusInstantDuration('2
days'):formatInstant('dd-MM-yyyy', 'UTC')}",
+ attributes,
+ "02-09-2026");
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):plusInstantDuration('1 year'):plusInstantDuration('1
month'):plusInstantDuration('1 day'):formatInstant('dd-MM-yyyy', 'UTC')}",
+ attributes,
+ "05-09-2027");
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):plusInstantDuration('2 hours'):minusInstantDuration('30
minutes'):formatInstant('dd-MM-yyyy HH:mm:ss', 'UTC')}",
+ attributes,
+ "04-08-2026 01:30:00");
+
+ // Leap year: Feb 29, 2024 + 1 year = Feb 28, 2025
+ attributes.put("date", "29-02-2024 00:00:00");
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):plusInstantDuration('1 year'):formatInstant('dd-MM-yyyy', 'UTC')}",
+ attributes,
+ "28-02-2025");
+
+ // Month-end clamping: Jan 31 + 1 month = Feb 28 (non-leap)
+ attributes.put("date", "31-01-2026 00:00:00");
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):plusInstantDuration('1 month'):formatInstant('dd-MM-yyyy', 'UTC')}",
+ attributes,
+ "28-02-2026");
+
+ // Month-end clamping: Jan 31 + 1 month = Feb 29 (leap year)
+ attributes.put("date", "31-01-2024 00:00:00");
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):plusInstantDuration('1 month'):formatInstant('dd-MM-yyyy', 'UTC')}",
+ attributes,
+ "29-02-2024");
+
+ // Dynamic attribute as amount
+ attributes.put("date", "04-08-2026 00:00:00");
+ attributes.put("timeUnit", "2 weeks");
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):plusInstantDuration(${timeUnit}):formatInstant('dd-MM-yyyy', 'UTC')}",
+ attributes,
+ "18-08-2026");
+
+ // Without format() — returns Instant
+ final QueryResult<?> epochResult = Query.compile(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):plusInstantDuration('1 week')}")
+ .evaluate(new StandardEvaluationContext(attributes));
+ assertNotNull(epochResult.getValue());
+ assertInstanceOf(Instant.class, epochResult.getValue());
+
+ // Dynamic attribute — invalid throws at evaluation
+ attributes.put("timeUnit", "1 Monday");
+ assertThrows(
+ AttributeExpressionLanguageException.class,
+ () -> Query.compile(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):plusInstantDuration(${timeUnit}):formatInstant('dd-MM-yyyy', 'UTC')}")
+ .evaluate(new StandardEvaluationContext(attributes)));
+
+ // Invalid literal rejected at compile time
+ assertThrows(
+ AttributeExpressionLanguageException.class,
+ () -> Query.compile(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):plusInstantDuration('1 Monday')}"));
+ assertThrows(
+ AttributeExpressionLanguageException.class,
+ () -> Query.compile(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):plusInstantDuration('1days')}"));
+ assertThrows(
+ AttributeExpressionLanguageException.class,
+ () -> Query.compile(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):plusInstantDuration('2 fortnights')}"));
+
+ // Subject is not an instant — no toInstant() call, throws at
evaluation
+ assertThrows(
+ AttributeExpressionLanguageException.class,
+ () -> Query.compile("${date:plusInstantDuration('1 week')}")
+ .evaluate(new StandardEvaluationContext(attributes)));
+
+ // Missing attribute returns null
+ final QueryResult<?> missingNoToInstant =
Query.compile("${missing:plusInstantDuration('1 week')}")
+ .evaluate(new StandardEvaluationContext(attributes));
+ assertNull(missingNoToInstant.getValue());
+ }
+
+ @Test
+ public void testMinusInstantDuration() {
+ final Map<String, String> attributes = new HashMap<>();
+ attributes.put("date", "04-08-2026 00:00:00");
+
+ // All units
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):minusInstantDuration('1000000000 nanoseconds'):formatInstant('dd-MM-yyyy
HH:mm:ss', 'UTC')}",
+ attributes,
+ "03-08-2026 23:59:59");
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):minusInstantDuration('30 seconds'):formatInstant('dd-MM-yyyy HH:mm:ss',
'UTC')}",
+ attributes,
+ "03-08-2026 23:59:30");
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):minusInstantDuration('15 minutes'):formatInstant('dd-MM-yyyy HH:mm:ss',
'UTC')}",
+ attributes,
+ "03-08-2026 23:45:00");
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):minusInstantDuration('2 hours'):formatInstant('dd-MM-yyyy HH:mm:ss',
'UTC')}",
+ attributes,
+ "03-08-2026 22:00:00");
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):minusInstantDuration('7 days'):formatInstant('dd-MM-yyyy', 'UTC')}",
+ attributes,
+ "28-07-2026");
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):minusInstantDuration('2 weeks'):formatInstant('dd-MM-yyyy', 'UTC')}",
+ attributes,
+ "21-07-2026");
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):minusInstantDuration('3 months'):formatInstant('dd-MM-yyyy', 'UTC')}",
+ attributes,
+ "04-05-2026");
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):minusInstantDuration('1 year'):formatInstant('dd-MM-yyyy', 'UTC')}",
+ attributes,
+ "04-08-2025");
+
+ // Non-UTC timezone
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'Australia/Brisbane'):minusInstantDuration('3
months'):formatInstant('dd-MM-yyyy HH:mm:ss', 'Australia/Brisbane')}",
+ attributes,
+ "04-05-2026 00:00:00");
+
+ // Across daylight savings
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'Australia/Sydney'):minusInstantDuration('9 months'):formatInstant('dd-MM-yyyy
HH:mm:ss', 'Australia/Sydney')}",
+ attributes,
+ "04-11-2025 01:00:00");
+
+ // Time crossing day boundary from midnight
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):minusInstantDuration('25 hours'):formatInstant('dd-MM-yyyy', 'UTC')}",
+ attributes,
+ "02-08-2026");
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):minusInstantDuration('90 minutes'):formatInstant('dd-MM-yyyy HH:mm:ss',
'UTC')}",
+ attributes,
+ "03-08-2026 22:30:00");
+
+ // Nanoseconds — sub-second precision and singular/plural
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):minusInstantDuration('1 nanosecond'):formatInstant('dd-MM-yyyy
HH:mm:ss.SSSSSSSSS', 'UTC')}",
+ attributes,
+ "03-08-2026 23:59:59.999999999");
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):minusInstantDuration('1 nanoseconds'):formatInstant('dd-MM-yyyy
HH:mm:ss.SSSSSSSSS', 'UTC')}",
+ attributes,
+ "03-08-2026 23:59:59.999999999");
+
+ // Singular and plural
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):minusInstantDuration('1 day'):formatInstant('dd-MM-yyyy', 'UTC')}",
+ attributes,
+ "03-08-2026");
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):minusInstantDuration('1 days'):formatInstant('dd-MM-yyyy', 'UTC')}",
+ attributes,
+ "03-08-2026");
+
+ // Case-insensitive
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):minusInstantDuration('2 WEEKS'):formatInstant('dd-MM-yyyy', 'UTC')}",
+ attributes,
+ "21-07-2026");
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):minusInstantDuration('1 Month'):formatInstant('dd-MM-yyyy', 'UTC')}",
+ attributes,
+ "04-07-2026");
+
+ // Zero amount
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):minusInstantDuration('0 days'):formatInstant('dd-MM-yyyy', 'UTC')}",
+ attributes,
+ "04-08-2026");
+
+ // DateTime input
+ attributes.put("dt", "04-08-2026 10:30:00");
+ verifyEquals(
+ "${dt:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):minusInstantDuration('3 hours'):formatInstant('dd-MM-yyyy HH:mm:ss',
'UTC')}",
+ attributes,
+ "04-08-2026 07:30:00");
+
+ // Chaining
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):minusInstantDuration('1 month'):plusInstantDuration('2
days'):formatInstant('dd-MM-yyyy', 'UTC')}",
+ attributes,
+ "06-07-2026");
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):minusInstantDuration('1 year'):minusInstantDuration('1
month'):minusInstantDuration('1 day'):formatInstant('dd-MM-yyyy', 'UTC')}",
+ attributes,
+ "03-07-2025");
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):minusInstantDuration('2 hours'):plusInstantDuration('30
minutes'):formatInstant('dd-MM-yyyy HH:mm:ss', 'UTC')}",
+ attributes,
+ "03-08-2026 22:30:00");
+
+ // Leap year: Feb 29, 2024 - 1 year = Feb 28, 2023
+ attributes.put("date", "29-02-2024 00:00:00");
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):minusInstantDuration('1 year'):formatInstant('dd-MM-yyyy', 'UTC')}",
+ attributes,
+ "28-02-2023");
+
+ // Month-end clamping: Mar 31 - 1 month = Feb 28 (non-leap)
+ attributes.put("date", "31-03-2026 00:00:00");
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):minusInstantDuration('1 month'):formatInstant('dd-MM-yyyy', 'UTC')}",
+ attributes,
+ "28-02-2026");
+
+ // Month-end clamping: Mar 31 - 1 month = Feb 29 (leap year)
+ attributes.put("date", "31-03-2024 00:00:00");
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):minusInstantDuration('1 month'):formatInstant('dd-MM-yyyy', 'UTC')}",
+ attributes,
+ "29-02-2024");
+
+ // Dynamic attribute as amount
+ attributes.put("date", "04-08-2026 00:00:00");
+ attributes.put("timeUnit", "2 weeks");
+ verifyEquals(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):minusInstantDuration(${timeUnit}):formatInstant('dd-MM-yyyy', 'UTC')}",
+ attributes,
+ "21-07-2026");
+
+ // Without format() — returns Instant
+ final QueryResult<?> epochResult = Query.compile(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):minusInstantDuration('1 week')}")
+ .evaluate(new StandardEvaluationContext(attributes));
+ assertNotNull(epochResult.getValue());
+ assertInstanceOf(Instant.class, epochResult.getValue());
+
+ // Dynamic attribute — invalid throws at evaluation
+ attributes.put("timeUnit", "1 Monday");
+ assertThrows(
+ AttributeExpressionLanguageException.class,
+ () -> Query.compile(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):minusInstantDuration(${timeUnit}):formatInstant('dd-MM-yyyy', 'UTC')}")
+ .evaluate(new StandardEvaluationContext(attributes)));
+
+ // Invalid literal rejected at compile time
+ assertThrows(
+ AttributeExpressionLanguageException.class,
+ () -> Query.compile(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):minusInstantDuration('1 Monday')}"));
+ assertThrows(
+ AttributeExpressionLanguageException.class,
+ () -> Query.compile(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):minusInstantDuration('1days')}"));
+ assertThrows(
+ AttributeExpressionLanguageException.class,
+ () -> Query.compile(
+ "${date:toInstant('dd-MM-yyyy HH:mm:ss',
'UTC'):minusInstantDuration('2 fortnights')}"));
+
+ // Subject is not an instant — no toInstant() call, throws at
evaluation
+ assertThrows(
+ AttributeExpressionLanguageException.class,
+ () -> Query.compile("${date:minusInstantDuration('1 week')}")
+ .evaluate(new StandardEvaluationContext(attributes)));
+
+ // Missing attribute returns null
+ final QueryResult<?> missingNoToInstant =
Query.compile("${missing:minusInstantDuration('1 week')}")
+ .evaluate(new StandardEvaluationContext(attributes));
+ assertNull(missingNoToInstant.getValue());
+ }
}
diff --git
a/nifi-commons/nifi-expression-language/src/test/java/org/apache/nifi/attribute/expression/language/evaluation/util/DateAmountParserTest.java
b/nifi-commons/nifi-expression-language/src/test/java/org/apache/nifi/attribute/expression/language/evaluation/util/DateAmountParserTest.java
new file mode 100644
index 00000000000..6300849f14d
--- /dev/null
+++
b/nifi-commons/nifi-expression-language/src/test/java/org/apache/nifi/attribute/expression/language/evaluation/util/DateAmountParserTest.java
@@ -0,0 +1,351 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.attribute.expression.language.evaluation.util;
+
+import
org.apache.nifi.attribute.expression.language.exception.AttributeExpressionLanguageException;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+class DateAmountParserTest {
+
+ private static final ZonedDateTime AUG_4_2026 = ZonedDateTime.of(
+ 2026, 8, 4, 0, 0, 0, 0, ZoneId.systemDefault());
+
+ // ================================================================
+ // validate() — valid expressions
+ // ================================================================
+
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "1 nanosecond", "500 nanoseconds",
+ "1 second", "30 seconds", "1 minute", "15 minutes",
+ "1 hour", "2 hours", "1 day", "7 days",
+ "1 week", "2 weeks", "1 month", "3 months",
+ "1 year", "2 years", "0 days", "100 hours", "365 days"
+ })
+ void testValidateAcceptsValidExpressions(final String expression) {
+ assertDoesNotThrow(() -> DateAmountParser.validate(expression));
+ }
+
+ // ================================================================
+ // Space is required between integer and unit
+ // ================================================================
+
+ @ParameterizedTest
+ @ValueSource(strings = {"1days", "2weeks", "30seconds", "1hour",
"3months", "1year", "15minutes"})
+ void testValidateRejectsMissingSpace(final String expression) {
+ assertThrows(AttributeExpressionLanguageException.class,
+ () -> DateAmountParser.validate(expression));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"1 days", "2 weeks", "30 seconds", "1 hour", "3
months", "1 year", "15 minutes"})
+ void testValidateAcceptsWithSpace(final String expression) {
+ assertDoesNotThrow(() -> DateAmountParser.validate(expression));
+ }
+
+ // ================================================================
+ // Case insensitivity
+ // ================================================================
+
+ @ParameterizedTest
+ @CsvSource({
+ "2 WEEKS, 2026-08-18T00:00:00",
+ "2 Weeks, 2026-08-18T00:00:00",
+ "2 weeks, 2026-08-18T00:00:00",
+ "2 wEeKs, 2026-08-18T00:00:00",
+ "1 MONTH, 2026-09-04T00:00:00",
+ "1 Month, 2026-09-04T00:00:00",
+ "1 YEAR, 2027-08-04T00:00:00",
+ "3 HOURS, 2026-08-04T03:00:00",
+ "1 DAY, 2026-08-05T00:00:00",
+ "5 SECONDS, 2026-08-04T00:00:05",
+ "10 MINUTES, 2026-08-04T00:10:00"
+ })
+ void testCaseInsensitive(final String expression, final String
expectedIso) {
+ final ZonedDateTime result = DateAmountParser.plus(AUG_4_2026,
expression);
+ assertEquals(LocalDateTime.parse(expectedIso),
result.toLocalDateTime());
+ }
+
+ // ================================================================
+ // Singular and plural produce identical results
+ // ================================================================
+
+ @ParameterizedTest
+ @CsvSource({
+ "1 nanosecond, 1 nanoseconds",
+ "1 second, 1 seconds",
+ "1 minute, 1 minutes",
+ "1 hour, 1 hours",
+ "1 day, 1 days",
+ "1 week, 1 weeks",
+ "1 month, 1 months",
+ "1 year, 1 years"
+ })
+ void testSingularAndPluralProduceSameResult(final String singular, final
String plural) {
+ assertEquals(
+ DateAmountParser.plus(AUG_4_2026, singular),
+ DateAmountParser.plus(AUG_4_2026, plural));
+ }
+
+ // ================================================================
+ // Whitespace tolerance
+ // ================================================================
+
+ @ParameterizedTest
+ @ValueSource(strings = {" 2 weeks", "2 weeks ", " 2 weeks ", "2
weeks"})
+ void testExtraWhitespaceAccepted(final String expression) {
+ assertEquals(AUG_4_2026.plusWeeks(2),
DateAmountParser.plus(AUG_4_2026, expression));
+ }
+
+ // ================================================================
+ // Invalid expressions
+ // ================================================================
+
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "1 Monday", // day name, not a unit
+ "B weeks", // non-numeric amount
+ "1 dog", // nonsense unit
+ "abc days", // alpha amount
+ "2 fortnights", // unsupported unit
+ "1 decade", // unsupported unit
+ "500 milliseconds", // unsupported unit
+ "-1 days", // negative
+ "1.5 days", // decimal
+ "1 day 2 hours", // compound expression
+ "1days", // missing space
+ "2weeks", // missing space
+ "", // empty
+ " ", // blank
+ "2", // number only
+ "weeks" // unit only
+ })
+ void testValidateRejectsInvalidExpressions(final String expression) {
+ assertThrows(AttributeExpressionLanguageException.class,
+ () -> DateAmountParser.validate(expression));
+ }
+
+ @Test
+ void testValidateRejectsNull() {
+ assertThrows(AttributeExpressionLanguageException.class,
+ () -> DateAmountParser.validate(null));
+ }
+
+ // ================================================================
+ // Plus — all units from base date (Aug 4, 2026 midnight)
+ // ================================================================
+
+ @ParameterizedTest
+ @CsvSource({
+ "1 nanosecond, 2026-08-04T00:00:00.000000001",
+ "1000000000 nanoseconds, 2026-08-04T00:00:01",
+ "1 second, 2026-08-04T00:00:01",
+ "30 seconds, 2026-08-04T00:00:30",
+ "1 minute, 2026-08-04T00:01:00",
+ "15 minutes, 2026-08-04T00:15:00",
+ "1 hour, 2026-08-04T01:00:00",
+ "2 hours, 2026-08-04T02:00:00",
+ "1 day, 2026-08-05T00:00:00",
+ "7 days, 2026-08-11T00:00:00",
+ "1 week, 2026-08-11T00:00:00",
+ "2 weeks, 2026-08-18T00:00:00",
+ "1 month, 2026-09-04T00:00:00",
+ "3 months, 2026-11-04T00:00:00",
+ "1 year, 2027-08-04T00:00:00",
+ "2 years, 2028-08-04T00:00:00"
+ })
+ void testPlusAllUnits(final String expression, final String expectedIso) {
+ final ZonedDateTime result = DateAmountParser.plus(AUG_4_2026,
expression);
+ assertEquals(LocalDateTime.parse(expectedIso),
result.toLocalDateTime(),
+ "Failed for: " + expression);
+ }
+
+ // ================================================================
+ // Minus — all units from base date
+ // ================================================================
+
+ @ParameterizedTest
+ @CsvSource({
+ "1 nanosecond, 2026-08-03T23:59:59.999999999",
+ "1000000000 nanoseconds, 2026-08-03T23:59:59",
+ "1 second, 2026-08-03T23:59:59",
+ "30 seconds, 2026-08-03T23:59:30",
+ "1 minute, 2026-08-03T23:59:00",
+ "15 minutes, 2026-08-03T23:45:00",
+ "1 hour, 2026-08-03T23:00:00",
+ "2 hours, 2026-08-03T22:00:00",
+ "1 day, 2026-08-03T00:00:00",
+ "7 days, 2026-07-28T00:00:00",
+ "1 week, 2026-07-28T00:00:00",
+ "2 weeks, 2026-07-21T00:00:00",
+ "1 month, 2026-07-04T00:00:00",
+ "3 months, 2026-05-04T00:00:00",
+ "1 year, 2025-08-04T00:00:00",
+ "2 years, 2024-08-04T00:00:00"
+ })
+ void testMinusAllUnits(final String expression, final String expectedIso) {
+ final ZonedDateTime result = DateAmountParser.minus(AUG_4_2026,
expression);
+ assertEquals(LocalDateTime.parse(expectedIso),
result.toLocalDateTime(),
+ "Failed for: " + expression);
+ }
+
+ // ================================================================
+ // Zero amount returns same date
+ // ================================================================
+
+ @Test
+ void testZeroAmountNoOp() {
+ assertEquals(AUG_4_2026, DateAmountParser.plus(AUG_4_2026, "0 days"));
+ assertEquals(AUG_4_2026, DateAmountParser.plus(AUG_4_2026, "0
months"));
+ }
+
+ // ================================================================
+ // Leap year behavior
+ // ================================================================
+
+ @Test
+ void testPlusOneYearFromLeapDay() {
+ // Feb 29, 2024 + 1 year -> Feb 28, 2025 (clamped)
+ final ZonedDateTime leapDay = zonedDate(2024, 2, 29);
+ assertEquals(zonedDate(2025, 2, 28), DateAmountParser.plus(leapDay, "1
year"));
+ }
+
+ @Test
+ void testPlusFourYearsFromLeapDay() {
+ // Feb 29, 2024 + 4 years -> Feb 29, 2028 (2028 is a leap year)
+ final ZonedDateTime leapDay = zonedDate(2024, 2, 29);
+ assertEquals(zonedDate(2028, 2, 29), DateAmountParser.plus(leapDay, "4
years"));
+ }
+
+ @Test
+ void testMinusOneYearFromLeapDay() {
+ final ZonedDateTime leapDay = zonedDate(2024, 2, 29);
+ assertEquals(zonedDate(2023, 2, 28), DateAmountParser.minus(leapDay,
"1 year"));
+ }
+
+ // ================================================================
+ // Month-end clamping
+ // ================================================================
+
+ @Test
+ void testPlusOneMonthFromJan31() {
+ assertEquals(zonedDate(2026, 2, 28),
DateAmountParser.plus(zonedDate(2026, 1, 31), "1 month"));
+ }
+
+ @Test
+ void testPlusOneMonthFromJan31LeapYear() {
+ assertEquals(zonedDate(2024, 2, 29),
DateAmountParser.plus(zonedDate(2024, 1, 31), "1 month"));
+ }
+
+ @Test
+ void testPlusOneMonthFromMarch31() {
+ // Mar 31 + 1 month -> Apr 30
+ assertEquals(zonedDate(2026, 4, 30),
DateAmountParser.plus(zonedDate(2026, 3, 31), "1 month"));
+ }
+
+ @Test
+ void testMinusOneMonthFromMarch31() {
+ // Mar 31 - 1 month -> Feb 28
+ assertEquals(zonedDate(2026, 2, 28),
DateAmountParser.minus(zonedDate(2026, 3, 31), "1 month"));
+ }
+
+ // ================================================================
+ // Date-only input + sub-day adjustments (midnight baseline)
+ // ================================================================
+
+ @Test
+ void testPlusHoursFromMidnight() {
+ assertEquals(
+ ZonedDateTime.of(2026, 8, 4, 2, 0, 0, 0,
ZoneId.systemDefault()),
+ DateAmountParser.plus(AUG_4_2026, "2 hours"));
+ }
+
+ @Test
+ void testMinusOneSecondFromMidnight() {
+ assertEquals(
+ ZonedDateTime.of(2026, 8, 3, 23, 59, 59, 0,
ZoneId.systemDefault()),
+ DateAmountParser.minus(AUG_4_2026, "1 second"));
+ }
+
+ @Test
+ void testPlus25HoursCrossesDay() {
+ assertEquals(
+ ZonedDateTime.of(2026, 8, 5, 1, 0, 0, 0,
ZoneId.systemDefault()),
+ DateAmountParser.plus(AUG_4_2026, "25 hours"));
+ }
+
+ @Test
+ void testPlus90MinutesFromMidnight() {
+ assertEquals(
+ ZonedDateTime.of(2026, 8, 4, 1, 30, 0, 0,
ZoneId.systemDefault()),
+ DateAmountParser.plus(AUG_4_2026, "90 minutes"));
+ }
+
+ // ================================================================
+ // Nanoseconds
+ // ================================================================
+
+ @Test
+ void testPlusNanosecondFromMidnight() {
+ assertEquals(
+ ZonedDateTime.of(2026, 8, 4, 0, 0, 0, 1,
ZoneId.systemDefault()),
+ DateAmountParser.plus(AUG_4_2026, "1 nanosecond"));
+ }
+
+ @Test
+ void testMinusNanosecondFromMidnight() {
+ assertEquals(
+ ZonedDateTime.of(2026, 8, 3, 23, 59, 59, 999_999_999,
ZoneId.systemDefault()),
+ DateAmountParser.minus(AUG_4_2026, "1 nanosecond"));
+ }
+
+ @Test
+ void testNanosecondSingularAndPluralMatch() {
+ assertEquals(
+ DateAmountParser.plus(AUG_4_2026, "1 nanosecond"),
+ DateAmountParser.plus(AUG_4_2026, "1 nanoseconds"));
+ }
+
+ // ================================================================
+ // Large amounts
+ // ================================================================
+
+ @Test
+ void testLargeAmounts() {
+ assertEquals(AUG_4_2026.plusDays(365),
DateAmountParser.plus(AUG_4_2026, "365 days"));
+ assertEquals(AUG_4_2026.plusHours(1000),
DateAmountParser.plus(AUG_4_2026, "1000 hours"));
+ }
+
+ // ================================================================
+ // Helper
+ // ================================================================
+
+ private static ZonedDateTime zonedDate(final int year, final int month,
final int day) {
+ return ZonedDateTime.of(year, month, day, 0, 0, 0, 0,
ZoneId.systemDefault());
+ }
+}
diff --git a/nifi-docs/src/main/asciidoc/expression-language-guide.adoc
b/nifi-docs/src/main/asciidoc/expression-language-guide.adoc
index 454b90020dc..4c52ef78aaf 100644
--- a/nifi-docs/src/main/asciidoc/expression-language-guide.adoc
+++ b/nifi-docs/src/main/asciidoc/expression-language-guide.adoc
@@ -2526,6 +2526,156 @@ the attribute to 1647613347678234 microseconds since
epoch.
`${dateTime:toInstant('yyyy/MM/dd HH:mm:ss.SSSSSSSSS',
'America/New_York'):toNanos()}` converts the Instant value of
the attribute to 1647613347678234567 nanoseconds since epoch.
+
+[.function]
+=== plusDuration
+
+*Description*: [.description]#Adds a specified amount of time to a Date value
using a human-readable expression such as `"1 week"` or `"3 months"`. Month and
year adjustments follow calendar rules.#
+
+If the subject is null, the function returns null.
+
+*Subject Type*: [.subject]#Date#
+
+*Arguments*:
+
+- [.argName]#_amount_# : [.argDesc]#A string in the form `"<number> <unit>"`.
A space between the number and unit is required.#
+
+*Supported Units*: nanosecond(s), second(s), minute(s), hour(s), day(s),
week(s), month(s), year(s)
+
+Units are case-insensitive.
+
+NOTE: Nanoseconds are accepted but have no effect on a `Date` subject because
`java.util.Date` has millisecond precision. Use `plusInstantDuration` if
nanosecond precision is required.
+
+*Return Type*: [.returnType]#Date#
+
+*Examples*: If the "date" attribute has the value `"04-08-2026"` (August 4,
2026), then the following Expressions will result in the following values:
+
+.plusDuration Examples
+|============================================================================
+| Expression | Value
+| `${date:toDate('dd-MM-yyyy'):plusDuration('1 week'):format('dd-MM-yyyy')}` |
`11-08-2026`
+| `${date:toDate('dd-MM-yyyy'):plusDuration('2 years'):format('dd-MM-yyyy')}`
| `04-08-2028`
+| `${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.
+|============================================================================
+
+.Calendar-Aware Behavior
+|============================================================================
+| Scenario | Example | Result
+| 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
+|============================================================================
+
+
+[.function]
+=== minusDuration
+
+*Description*: [.description]#Subtracts a specified amount of time from a Date
value using a human-readable expression such as `"1 week"` or `"3 months"`.
Month and year adjustments follow calendar rules.#
+
+If the subject is null, the function returns null.
+
+*Subject Type*: [.subject]#Date#
+
+*Arguments*:
+
+- [.argName]#_amount_# : [.argDesc]#A string in the form `"<number> <unit>"`.
A space between the number and unit is required.#
+
+*Supported Units*: nanosecond(s), second(s), minute(s), hour(s), day(s),
week(s), month(s), year(s)
+
+Units are case-insensitive.
+
+NOTE: Nanoseconds are accepted but have no effect on a `Date` subject because
`java.util.Date` has millisecond precision. Use `minusInstantDuration` if
nanosecond precision is required.
+
+*Return Type*: [.returnType]#Date#
+
+*Examples*: If the "date" attribute has the value `"04-08-2026"` (August 4,
2026), then the following Expressions will result in the following values:
+
+.minusDuration Examples
+|============================================================================
+| Expression | Value
+| `${date:toDate('dd-MM-yyyy'):minusDuration('1 month'):format('dd-MM-yyyy')}`
| `04-07-2026`
+| `${date:toDate('dd-MM-yyyy'):minusDuration('2 weeks'):format('dd-MM-yyyy')}`
| `21-07-2026`
+| `${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.
+|============================================================================
+
+.Calendar-Aware Behavior
+|============================================================================
+| Scenario | Example | Result
+| 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
+|============================================================================
+
+
+[.function]
+=== plusInstantDuration
+
+*Description*: [.description]#Adds a specified amount of time to an Instant
value using a human-readable expression such as `"1 week"` or `"3 months"`.
Arithmetic is evaluated in UTC.#
+
+If the subject is null, the function returns null.
+
+*Subject Type*: [.subject]#Instant#
+
+*Arguments*:
+
+- [.argName]#_amount_# : [.argDesc]#A string in the form `"<number> <unit>"`.
A space between the number and unit is required.#
+
+*Supported Units*: nanosecond(s), second(s), minute(s), hour(s), day(s),
week(s), month(s), year(s)
+
+Units are case-insensitive.
+
+*Return Type*: [.returnType]#Instant#
+
+*Examples*: If the "date" attribute has the value `"04-08-2026 00:00:00"`,
then the following Expressions will result in the following values:
+
+.plusInstantDuration Examples
+|============================================================================
+| Expression | Value
+| `${date:toInstant('dd-MM-yyyy HH:mm:ss', 'UTC'):plusInstantDuration('1
week'):formatInstant('dd-MM-yyyy HH:mm:ss', 'UTC')}` | `11-08-2026 00:00:00`
+| `${date:toInstant('dd-MM-yyyy HH:mm:ss', 'UTC'):plusInstantDuration('2
years'):formatInstant('dd-MM-yyyy HH:mm:ss', 'UTC')}` | `04-08-2028 00:00:00`
+| `${date:toInstant('dd-MM-yyyy HH:mm:ss', 'UTC'):plusInstantDuration('3
months'):formatInstant('dd-MM-yyyy HH:mm:ss', 'UTC')}` | `04-11-2026 00:00:00`
+| `${date:toInstant('dd-MM-yyyy HH:mm:ss',
'Australia/Sydney'):plusInstantDuration('3 months'):formatInstant('dd-MM-yyyy
HH:mm:ss', 'Australia/Sydney')}` | `04-11-2026 01:00:00`
+|============================================================================
+
+*Notes*:
+- Arithmetic is evaluated in UTC.
+
+
+[.function]
+=== minusInstantDuration
+
+*Description*: [.description]#Subtracts a specified amount of time from an
Instant value using a human-readable expression such as `"1 week"` or `"3
months"`. Arithmetic is evaluated in UTC.#
+
+If the subject is null, the function returns null.
+
+*Subject Type*: [.subject]#Instant#
+
+*Arguments*:
+
+- [.argName]#_amount_# : [.argDesc]#A string in the form `"<number> <unit>"`.
A space between the number and unit is required.#
+
+*Supported Units*: nanosecond(s), second(s), minute(s), hour(s), day(s),
week(s), month(s), year(s)
+
+Units are case-insensitive.
+
+*Return Type*: [.returnType]#Instant#
+
+*Examples*: If the "date" attribute has the value `"04-08-2026 00:00:00"`,
then the following Expressions will result in the following values:
+
+.minusInstantDuration Examples
+|============================================================================
+| Expression | Value
+| `${date:toInstant('dd-MM-yyyy HH:mm:ss', 'UTC'):minusInstantDuration('1
week'):formatInstant('dd-MM-yyyy HH:mm:ss', 'UTC')}` | `28-07-2026 00:00:00`
+| `${date:toInstant('dd-MM-yyyy HH:mm:ss', 'UTC'):minusInstantDuration('2
years'):formatInstant('dd-MM-yyyy HH:mm:ss', 'UTC')}` | `04-08-2024 00:00:00`
+| `${date:toInstant('dd-MM-yyyy HH:mm:ss', 'UTC'):minusInstantDuration('3
months'):formatInstant('dd-MM-yyyy HH:mm:ss', 'UTC')}` | `04-05-2026 00:00:00`
+| `${date:toInstant('dd-MM-yyyy HH:mm:ss',
'Australia/Sydney'):minusInstantDuration('3 months'):formatInstant('dd-MM-yyyy
HH:mm:ss', 'Australia/Sydney')}` | `04-05-2026 00:00:00`
+|============================================================================
+
+
[.function]
=== now