pvillard31 commented on code in PR #10956: URL: https://github.com/apache/nifi/pull/10956#discussion_r2959076707
########## nifi-commons/nifi-expression-language/src/main/java/org/apache/nifi/attribute/expression/language/evaluation/functions/AbstractInstantArithmeticEvaluator.java: ########## @@ -0,0 +1,70 @@ +/* + * 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 { Review Comment: the comment about blank lines also applies here, missed it before ########## nifi-commons/nifi-expression-language/src/test/java/org/apache/nifi/attribute/expression/language/TestQuery.java: ########## @@ -2866,4 +2868,720 @@ public void testTrimDelimitedList() { // 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 Review Comment: ```suggestion @Test ``` ########## nifi-commons/nifi-expression-language/src/test/java/org/apache/nifi/attribute/expression/language/TestQuery.java: ########## @@ -2866,4 +2868,720 @@ public void testTrimDelimitedList() { // Missing attribute evaluates to empty string verifyEquals("${missing:trimDelimitedList(',')}", attributes, ""); } + @Test Review Comment: ```suggestion @Test ``` -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
