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
 


Reply via email to