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 e44b1238541 NIFI-15674: Add isValidDate and isValidInstant Expression 
Language functions (#10975)
e44b1238541 is described below

commit e44b12385411a609ecd7e58c7562f4024f846a14
Author: Richard Scott <[email protected]>
AuthorDate: Wed Mar 25 21:04:28 2026 +1100

    NIFI-15674: Add isValidDate and isValidInstant Expression Language 
functions (#10975)
    
    - isValidDate(format[, timezone]) returns true if the subject parses as
      a valid calendar date using the given format and optional timezone;
      rejects invalid calendar combinations (e.g. Feb 31), DST gap times,
      and supports both 24-hour (HH) and 12-hour (hh a) clock formats
    - isValidInstant() returns true if the subject parses as a valid
      ISO-8601 instant (ISO_INSTANT, ISO_OFFSET_DATE_TIME, RFC_1123)
    - Added oneOrTwoArgBool to anyArg in parser grammar so isValidDate
      can appear as an argument to other functions
---
 .../language/antlr/AttributeExpressionLexer.g      |   2 +
 .../language/antlr/AttributeExpressionParser.g     |   7 +-
 .../language/compile/ExpressionCompiler.java       |  19 +++
 .../evaluation/functions/IsValidDateEvaluator.java | 188 +++++++++++++++++++++
 .../functions/IsValidInstantEvaluator.java         |  68 ++++++++
 .../attribute/expression/language/TestQuery.java   |  80 +++++++++
 .../main/asciidoc/expression-language-guide.adoc   |  51 ++++++
 7 files changed, 412 insertions(+), 3 deletions(-)

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 8873d60cce6..8d33a0ea82a 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
@@ -173,6 +173,8 @@ BASE64_DECODE : 'base64Decode';
 GET_STATE_VALUE: 'getStateValue';
 EVALUATE_EL_STRING: 'evaluateELString';
 IS_JSON: 'isJson';
+IS_VALID_INSTANT: 'isValidInstant';
+IS_VALID_DATE: 'isValidDate';
 
 // 1 arg functions
 SUBSTRING_AFTER        : 'substringAfter';
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 cadf075fa90..7e8ee3f8c21 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
@@ -85,12 +85,13 @@ threeArgString: ((JSON_PATH_PUT) LPAREN! anyArg COMMA! 
anyArg COMMA! anyArg RPAR
 fiveArgString : GET_DELIMITED_FIELD LPAREN! anyArg (COMMA! anyArg (COMMA! 
anyArg (COMMA! anyArg (COMMA! anyArg)?)?)?)? RPAREN!;
 
 // functions that return Booleans
-zeroArgBool : (IS_NULL | NOT_NULL | IS_EMPTY | NOT | IS_JSON) LPAREN! RPAREN!;
+zeroArgBool : (IS_NULL | NOT_NULL | IS_EMPTY | NOT | IS_JSON | 
IS_VALID_INSTANT) LPAREN! RPAREN!;
 oneArgBool     : ((FIND | MATCHES | EQUALS_IGNORE_CASE) LPAREN! anyArg 
RPAREN!) |
                          (GREATER_THAN | LESS_THAN | GREATER_THAN_OR_EQUAL | 
LESS_THAN_OR_EQUAL) LPAREN! anyArg RPAREN! |
                          (EQUALS) LPAREN! anyArg RPAREN! |
                          (AND | OR) LPAREN! anyArg RPAREN!;
 multiArgBool : (IN) LPAREN! anyArg (COMMA! anyArg)* RPAREN!;
+oneOrTwoArgBool : IS_VALID_DATE LPAREN! anyArg (COMMA! anyArg)? RPAREN!;
 
 
 // functions that return Numbers (whole or decimal)
@@ -103,10 +104,10 @@ zeroOrOneOrTwoArgNum : TO_DATE LPAREN! anyArg? (COMMA! 
anyArg)? RPAREN!;
 zeroOrTwoArgNum: TO_INSTANT LPAREN! (anyArg COMMA! anyArg)? RPAREN!;
 
 stringFunctionRef : zeroArgString | oneArgString | twoArgString | 
threeArgString | fiveArgString;
-booleanFunctionRef : zeroArgBool | oneArgBool | multiArgBool;
+booleanFunctionRef : zeroArgBool | oneArgBool | multiArgBool | oneOrTwoArgBool;
 numberFunctionRef : zeroArgNum | oneArgNum | zeroOrTwoArgNum | oneOrTwoArgNum 
| zeroOrOneOrTwoArgNum;
 
-anyArg : WHOLE_NUMBER | DECIMAL | numberFunctionRef | STRING_LITERAL | 
zeroArgString | oneArgString | twoArgString | fiveArgString | booleanLiteral | 
zeroArgBool | oneArgBool | multiArgBool
+anyArg : WHOLE_NUMBER | DECIMAL | numberFunctionRef | STRING_LITERAL | 
zeroArgString | oneArgString | twoArgString | fiveArgString | booleanLiteral | 
zeroArgBool | oneArgBool | multiArgBool | oneOrTwoArgBool
                 | expression | parameterReference | NULL;
 
 stringArg : STRING_LITERAL | zeroArgString | oneArgString | twoArgString | 
expression;
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 ff618f50d76..4b9b34f6e1e 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
@@ -73,6 +73,8 @@ import 
org.apache.nifi.attribute.expression.language.evaluation.functions.Instan
 import 
org.apache.nifi.attribute.expression.language.evaluation.functions.IsEmptyEvaluator;
 import 
org.apache.nifi.attribute.expression.language.evaluation.functions.IsJsonEvaluator;
 import 
org.apache.nifi.attribute.expression.language.evaluation.functions.IsNullEvaluator;
+import 
org.apache.nifi.attribute.expression.language.evaluation.functions.IsValidDateEvaluator;
+import 
org.apache.nifi.attribute.expression.language.evaluation.functions.IsValidInstantEvaluator;
 import 
org.apache.nifi.attribute.expression.language.evaluation.functions.JsonPathAddEvaluator;
 import 
org.apache.nifi.attribute.expression.language.evaluation.functions.JsonPathDeleteEvaluator;
 import 
org.apache.nifi.attribute.expression.language.evaluation.functions.JsonPathEvaluator;
@@ -212,6 +214,8 @@ import static 
org.apache.nifi.attribute.expression.language.antlr.AttributeExpre
 import static 
org.apache.nifi.attribute.expression.language.antlr.AttributeExpressionParser.IS_EMPTY;
 import static 
org.apache.nifi.attribute.expression.language.antlr.AttributeExpressionParser.IS_JSON;
 import static 
org.apache.nifi.attribute.expression.language.antlr.AttributeExpressionParser.IS_NULL;
+import static 
org.apache.nifi.attribute.expression.language.antlr.AttributeExpressionParser.IS_VALID_DATE;
+import static 
org.apache.nifi.attribute.expression.language.antlr.AttributeExpressionParser.IS_VALID_INSTANT;
 import static 
org.apache.nifi.attribute.expression.language.antlr.AttributeExpressionParser.JOIN;
 import static 
org.apache.nifi.attribute.expression.language.antlr.AttributeExpressionParser.JSON_PATH;
 import static 
org.apache.nifi.attribute.expression.language.antlr.AttributeExpressionParser.JSON_PATH_ADD;
@@ -829,6 +833,21 @@ public class ExpressionCompiler {
             case IS_JSON:
                 verifyArgCount(argEvaluators, 0, "isJson");
                 return addToken(new 
IsJsonEvaluator(toStringEvaluator(subjectEvaluator)), "isJson");
+            case IS_VALID_INSTANT:
+                verifyArgCount(argEvaluators, 0, "isValidInstant");
+                return addToken(new 
IsValidInstantEvaluator(toStringEvaluator(subjectEvaluator)), "isValidInstant");
+            case IS_VALID_DATE: {
+                if (argEvaluators.size() == 1) {
+                    return addToken(new 
IsValidDateEvaluator(toStringEvaluator(subjectEvaluator),
+                            toStringEvaluator(argEvaluators.get(0), "first 
argument to isValidDate"), null), "isValidDate");
+                } else if (argEvaluators.size() == 2) {
+                    return addToken(new 
IsValidDateEvaluator(toStringEvaluator(subjectEvaluator),
+                            toStringEvaluator(argEvaluators.get(0), "first 
argument to isValidDate"),
+                            toStringEvaluator(argEvaluators.get(1), "second 
argument to isValidDate")), "isValidDate");
+                } else {
+                    throw new 
AttributeExpressionLanguageParsingException("isValidDate() requires 1 or 2 
arguments");
+                }
+            }
             case FIND: {
                 verifyArgCount(argEvaluators, 1, "find");
                 return addToken(new 
FindEvaluator(toStringEvaluator(subjectEvaluator),
diff --git 
a/nifi-commons/nifi-expression-language/src/main/java/org/apache/nifi/attribute/expression/language/evaluation/functions/IsValidDateEvaluator.java
 
b/nifi-commons/nifi-expression-language/src/main/java/org/apache/nifi/attribute/expression/language/evaluation/functions/IsValidDateEvaluator.java
new file mode 100644
index 00000000000..beb312e4246
--- /dev/null
+++ 
b/nifi-commons/nifi-expression-language/src/main/java/org/apache/nifi/attribute/expression/language/evaluation/functions/IsValidDateEvaluator.java
@@ -0,0 +1,188 @@
+/*
+ * 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.StandardEvaluationContext;
+import 
org.apache.nifi.attribute.expression.language.evaluation.BooleanEvaluator;
+import 
org.apache.nifi.attribute.expression.language.evaluation.BooleanQueryResult;
+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.util.FormatUtils;
+
+import java.text.ParsePosition;
+import java.time.DateTimeException;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.time.temporal.ChronoField;
+import java.time.temporal.TemporalAccessor;
+import java.time.zone.ZoneRules;
+import java.util.Collections;
+
+/**
+ * Evaluator for the {@code isValidDate(format[, timeZone])} Expression 
Language function.
+ * Returns {@code true} if the subject parses successfully against the given 
format, represents
+ * a real calendar date (e.g. rejects February 31), and — when a named 
timezone is supplied —
+ * falls outside any DST gap (e.g. rejects 02:30 when clocks spring forward); 
{@code false} otherwise.
+ */
+public class IsValidDateEvaluator extends BooleanEvaluator {
+
+    private final Evaluator<String> subject;
+    private final Evaluator<String> format;
+    private final Evaluator<String> timeZone;
+
+    private final DateTimeFormatter preparedFormatter;
+    private final boolean preparedFormatterHasRequestedTimeZone;
+    private final ZoneRules preparedZoneRules;
+
+    public IsValidDateEvaluator(final Evaluator<String> subject, final 
Evaluator<String> format, final Evaluator<String> timeZone) {
+        this.subject = subject;
+        this.format = format;
+        if (format instanceof StringLiteralEvaluator) {
+            final String evaluatedFormat = format.evaluate(new 
StandardEvaluationContext(Collections.emptyMap())).getValue();
+            final DateTimeFormatter dtf = 
FormatUtils.prepareLenientCaseInsensitiveDateTimeFormatter(evaluatedFormat);
+            if (timeZone == null) {
+                preparedFormatter = dtf;
+                preparedFormatterHasRequestedTimeZone = true;
+            } else if (timeZone instanceof StringLiteralEvaluator) {
+                preparedFormatter = 
dtf.withZone(ZoneId.of(timeZone.evaluate(new 
StandardEvaluationContext(Collections.emptyMap())).getValue()));
+                preparedFormatterHasRequestedTimeZone = true;
+            } else {
+                preparedFormatter = dtf;
+                preparedFormatterHasRequestedTimeZone = false;
+            }
+        } else {
+            preparedFormatter = null;
+            preparedFormatterHasRequestedTimeZone = false;
+        }
+        this.timeZone = timeZone;
+        if (preparedFormatterHasRequestedTimeZone) {
+            final ZoneId zone = preparedFormatter.getZone();
+            preparedZoneRules = (zone != null && !(zone instanceof 
ZoneOffset)) ? zone.getRules() : null;
+        } else {
+            preparedZoneRules = null;
+        }
+    }
+
+    @Override
+    public QueryResult<Boolean> evaluate(final EvaluationContext 
evaluationContext) {
+        final String subjectValue = 
subject.evaluate(evaluationContext).getValue();
+        if (subjectValue == null) {
+            return new BooleanQueryResult(false);
+        }
+        final String trimmedValue = subjectValue.trim();
+
+        DateTimeFormatter dtf;
+        if (preparedFormatter != null) {
+            dtf = preparedFormatter;
+        } else {
+            final String formatValue = 
format.evaluate(evaluationContext).getValue();
+            if (formatValue == null) {
+                return new BooleanQueryResult(false);
+            }
+            try {
+                dtf = 
FormatUtils.prepareLenientCaseInsensitiveDateTimeFormatter(formatValue);
+            } catch (final IllegalArgumentException e) {
+                return new BooleanQueryResult(false);
+            }
+        }
+
+        if (!preparedFormatterHasRequestedTimeZone && timeZone != null) {
+            final String tz = timeZone.evaluate(evaluationContext).getValue();
+            if (tz != null) {
+                try {
+                    dtf = dtf.withZone(ZoneId.of(tz));
+                } catch (final Exception e) {
+                    return new BooleanQueryResult(false);
+                }
+            }
+        }
+
+        // Use parseUnresolved to get raw field values without lenient 
resolution
+        final ParsePosition pos = new ParsePosition(0);
+        final TemporalAccessor parsed = dtf.parseUnresolved(trimmedValue, pos);
+        if (parsed == null || pos.getErrorIndex() >= 0 || pos.getIndex() < 
trimmedValue.length()) {
+            return new BooleanQueryResult(false);
+        }
+
+        // If the parsed result contains date fields, validate the calendar 
combination strictly.
+        // LocalDate.of() always validates calendar rules (e.g., rejects 
February 31).
+        int year = 2000; // leap year: allows Feb 29 to be valid when no year 
in format
+        int month = -1;
+        int day = -1;
+        if (parsed.isSupported(ChronoField.DAY_OF_MONTH) && 
parsed.isSupported(ChronoField.MONTH_OF_YEAR)) {
+            try {
+                if (parsed.isSupported(ChronoField.YEAR)) {
+                    year = parsed.get(ChronoField.YEAR);
+                } else if (parsed.isSupported(ChronoField.YEAR_OF_ERA)) {
+                    year = parsed.get(ChronoField.YEAR_OF_ERA);
+                }
+                month = parsed.get(ChronoField.MONTH_OF_YEAR);
+                day = parsed.get(ChronoField.DAY_OF_MONTH);
+                LocalDate.of(year, month, day);
+            } catch (final DateTimeException e) {
+                return new BooleanQueryResult(false);
+            }
+        }
+
+        // If we have a named timezone (one with DST rules) and time fields, 
check that the
+        // local date-time actually exists — times in a DST gap (e.g. 02:30 
when clocks spring
+        // forward) have no valid offset and should be rejected.
+        final ZoneRules zoneRules;
+        if (preparedFormatterHasRequestedTimeZone) {
+            zoneRules = preparedZoneRules;
+        } else {
+            final ZoneId zone = dtf.getZone();
+            zoneRules = (zone != null && !(zone instanceof ZoneOffset)) ? 
zone.getRules() : null;
+        }
+        if (zoneRules != null && month != -1 && 
(parsed.isSupported(ChronoField.HOUR_OF_DAY)
+                || parsed.isSupported(ChronoField.HOUR_OF_AMPM)
+                || parsed.isSupported(ChronoField.CLOCK_HOUR_OF_AMPM))) {
+            try {
+                final int hour;
+                if (parsed.isSupported(ChronoField.HOUR_OF_DAY)) {
+                    hour = parsed.get(ChronoField.HOUR_OF_DAY);
+                } else {
+                    final int hourOfAmPm = 
parsed.isSupported(ChronoField.HOUR_OF_AMPM)
+                            ? parsed.get(ChronoField.HOUR_OF_AMPM)
+                            : parsed.get(ChronoField.CLOCK_HOUR_OF_AMPM) % 12;
+                    final int amPm = 
parsed.isSupported(ChronoField.AMPM_OF_DAY) ? 
parsed.get(ChronoField.AMPM_OF_DAY) : 0;
+                    hour = amPm * 12 + hourOfAmPm;
+                }
+                final int minute = 
parsed.isSupported(ChronoField.MINUTE_OF_HOUR) ? 
parsed.get(ChronoField.MINUTE_OF_HOUR) : 0;
+                final int second = 
parsed.isSupported(ChronoField.SECOND_OF_MINUTE) ? 
parsed.get(ChronoField.SECOND_OF_MINUTE) : 0;
+                final LocalDateTime ldt = LocalDateTime.of(year, month, day, 
hour, minute, second);
+                if (zoneRules.getValidOffsets(ldt).isEmpty()) {
+                    return new BooleanQueryResult(false);
+                }
+            } catch (final DateTimeException e) {
+                return new BooleanQueryResult(false);
+            }
+        }
+
+        return new BooleanQueryResult(true);
+    }
+
+    @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/IsValidInstantEvaluator.java
 
b/nifi-commons/nifi-expression-language/src/main/java/org/apache/nifi/attribute/expression/language/evaluation/functions/IsValidInstantEvaluator.java
new file mode 100644
index 00000000000..39764927336
--- /dev/null
+++ 
b/nifi-commons/nifi-expression-language/src/main/java/org/apache/nifi/attribute/expression/language/evaluation/functions/IsValidInstantEvaluator.java
@@ -0,0 +1,68 @@
+/*
+ * 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.BooleanEvaluator;
+import 
org.apache.nifi.attribute.expression.language.evaluation.BooleanQueryResult;
+import org.apache.nifi.attribute.expression.language.evaluation.Evaluator;
+import org.apache.nifi.attribute.expression.language.evaluation.QueryResult;
+
+import java.time.Instant;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeFormatterBuilder;
+import java.time.format.DateTimeParseException;
+
+/**
+ * Evaluator for the {@code isValidInstant()} Expression Language function.
+ * Returns {@code true} if the subject parses as a valid {@link 
java.time.Instant} using
+ * ISO_INSTANT, ISO_OFFSET_DATE_TIME, or RFC_1123_DATE_TIME formats; {@code 
false} otherwise.
+ */
+public class IsValidInstantEvaluator extends BooleanEvaluator {
+
+    private static final DateTimeFormatter INSTANT_FORMATTER = new 
DateTimeFormatterBuilder()
+            .appendOptional(DateTimeFormatter.ISO_INSTANT)
+            .appendOptional(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
+            .appendOptional(DateTimeFormatter.RFC_1123_DATE_TIME)
+            .toFormatter();
+
+    private final Evaluator<String> subject;
+
+    public IsValidInstantEvaluator(final Evaluator<String> subject) {
+        this.subject = subject;
+    }
+
+    @Override
+    public QueryResult<Boolean> evaluate(final EvaluationContext 
evaluationContext) {
+        final String subjectValue = 
subject.evaluate(evaluationContext).getValue();
+        if (subjectValue == null) {
+            return new BooleanQueryResult(false);
+        }
+
+        try {
+            INSTANT_FORMATTER.parse(subjectValue.trim(), Instant::from);
+            return new BooleanQueryResult(true);
+        } catch (final DateTimeParseException e) {
+            return new BooleanQueryResult(false);
+        }
+    }
+
+    @Override
+    public Evaluator<?> getSubjectEvaluator() {
+        return subject;
+    }
+}
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 3bd9cb80bd6..f924a612202 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
@@ -2572,6 +2572,86 @@ public class TestQuery {
         verifyEquals("${nullAttr:isJson()}", attributes, false);
     }
 
+    @Test
+    void testIsValidDate() {
+        final Map<String, String> attributes = new HashMap<>();
+        attributes.put("validDate", "04-08-2026");
+        attributes.put("invalidCalendarDate", "31-02-2026");
+        attributes.put("formatMismatch", "2026-08-04");
+        attributes.put("notADate", "not-a-date");
+        attributes.put("validDateWithTime", "2026-08-04 10:15:30");
+        attributes.put("validDateUtc", "2026-08-04 10:15:30");
+        attributes.put("leapDay", "29-02");
+        attributes.put("leapDayInLeapYear", "29-02-2000");
+        attributes.put("leapDayInNonLeapYear", "29-02-2001");
+
+        verifyEquals("${validDate:isValidDate('dd-MM-yyyy')}", attributes, 
true);
+        verifyEquals("${invalidCalendarDate:isValidDate('dd-MM-yyyy')}", 
attributes, false);
+        verifyEquals("${formatMismatch:isValidDate('dd-MM-yyyy')}", 
attributes, false);
+        verifyEquals("${notADate:isValidDate('dd-MM-yyyy')}", attributes, 
false);
+
+        // Feb 29 without a year in the format should be valid (format has no 
year field)
+        verifyEquals("${leapDay:isValidDate('dd-MM')}", attributes, true);
+        // Feb 29 with an explicit leap year should be valid
+        verifyEquals("${leapDayInLeapYear:isValidDate('dd-MM-yyyy')}", 
attributes, true);
+        // Feb 29 in a non-leap year should be invalid
+        verifyEquals("${leapDayInNonLeapYear:isValidDate('dd-MM-yyyy')}", 
attributes, false);
+
+        // with timezone argument
+        verifyEquals("${validDateWithTime:isValidDate('yyyy-MM-dd HH:mm:ss', 
'UTC')}", attributes, true);
+        verifyEquals("${notADate:isValidDate('yyyy-MM-dd HH:mm:ss', 'UTC')}", 
attributes, false);
+
+        // DST gap: Australia/Sydney springs forward from 02:00 to 03:00 on 
2024-10-06,
+        // so 02:30 on that date does not exist and should be invalid
+        attributes.put("dstGapTime", "2024-10-06 02:30:00");
+        attributes.put("dstValidTimeBefore", "2024-10-06 01:30:00");
+        attributes.put("dstValidTimeAfter", "2024-10-06 03:30:00");
+        verifyEquals("${dstGapTime:isValidDate('yyyy-MM-dd HH:mm:ss', 
'Australia/Sydney')}", attributes, false);
+        verifyEquals("${dstValidTimeBefore:isValidDate('yyyy-MM-dd HH:mm:ss', 
'Australia/Sydney')}", attributes, true);
+        verifyEquals("${dstValidTimeAfter:isValidDate('yyyy-MM-dd HH:mm:ss', 
'Australia/Sydney')}", attributes, true);
+
+        // chaining with ifElse
+        verifyEquals("${validDate:isValidDate('dd-MM-yyyy'):ifElse('valid', 
'invalid')}", attributes, "valid");
+        
verifyEquals("${invalidCalendarDate:isValidDate('dd-MM-yyyy'):ifElse('valid', 
'invalid')}", attributes, "invalid");
+
+        // null subject (attribute does not exist)
+        verifyEquals("${nonExistentAttr:isValidDate('dd-MM-yyyy')}", 
attributes, false);
+
+        // leading/trailing whitespace should be trimmed before parsing
+        attributes.put("paddedDate", "  04-08-2026  ");
+        verifyEquals("${paddedDate:isValidDate('dd-MM-yyyy')}", attributes, 
true);
+
+        // DST gap with 12-hour clock format: Australia/Sydney springs forward 
from 02:00 to 03:00
+        // on 2024-10-06, so 02:30 AM does not exist and should be invalid
+        attributes.put("dstGapTime12h", "2024-10-06 02:30:00 AM");
+        attributes.put("dstValidTimeBefore12h", "2024-10-06 01:30:00 AM");
+        attributes.put("dstValidTimeAfter12h", "2024-10-06 03:30:00 AM");
+        verifyEquals("${dstGapTime12h:isValidDate('yyyy-MM-dd hh:mm:ss a', 
'Australia/Sydney')}", attributes, false);
+        verifyEquals("${dstValidTimeBefore12h:isValidDate('yyyy-MM-dd hh:mm:ss 
a', 'Australia/Sydney')}", attributes, true);
+        verifyEquals("${dstValidTimeAfter12h:isValidDate('yyyy-MM-dd hh:mm:ss 
a', 'Australia/Sydney')}", attributes, true);
+    }
+
+    @Test
+    void testIsValidInstant() {
+        final Map<String, String> attributes = new HashMap<>();
+        attributes.put("isoInstant", "2026-08-04T10:15:30Z");
+        attributes.put("isoOffsetDateTime", "2026-08-04T10:15:30+05:30");
+        attributes.put("notAnInstant", "not-a-date");
+        attributes.put("plainDate", "2026-08-04");
+
+        verifyEquals("${isoInstant:isValidInstant()}", attributes, true);
+        verifyEquals("${isoOffsetDateTime:isValidInstant()}", attributes, 
true);
+        verifyEquals("${notAnInstant:isValidInstant()}", attributes, false);
+        verifyEquals("${plainDate:isValidInstant()}", attributes, false);
+
+        // chaining with ifElse
+        verifyEquals("${isoInstant:isValidInstant():ifElse('valid-instant', 
'invalid-instant')}", attributes, "valid-instant");
+        verifyEquals("${notAnInstant:isValidInstant():ifElse('valid-instant', 
'invalid-instant')}", attributes, "invalid-instant");
+
+        // null subject (attribute does not exist)
+        verifyEquals("${nonExistentAttr:isValidInstant()}", attributes, false);
+    }
+
     @Test
     void testGetUri() {
         verifyEquals("${getUri('https', 'admin:admin', 'nifi.apache.org', 
'1234', '/path/data ', 'key=value &key2=value2', 'frag1')}",
diff --git a/nifi-docs/src/main/asciidoc/expression-language-guide.adoc 
b/nifi-docs/src/main/asciidoc/expression-language-guide.adoc
index 4c52ef78aaf..bdf8303513b 100644
--- a/nifi-docs/src/main/asciidoc/expression-language-guide.adoc
+++ b/nifi-docs/src/main/asciidoc/expression-language-guide.adoc
@@ -605,6 +605,57 @@ the following results:
 
 
 
+[.function]
+=== isValidInstant
+
+*Description*: [.description]#The `isValidInstant` function returns `true` 
when the Subject can be parsed as a valid ISO-8601 instant or RFC-1123 
date-time, and `false` otherwise.
+This function is useful for validating values before calling `toInstant()`. 
Unlike `toInstant()`, this function does not throw an error when parsing fails.#
+
+*Subject Type*: [.subject]#String#
+
+*Arguments*: No arguments
+
+*Return Type*: [.returnType]#Boolean#
+
+.isValidInstant Examples
+|===================================================================
+| Expression | Value
+| `${eventTime:isValidInstant()}` where `eventTime` = `2026-08-04T10:15:30Z` | 
`true`
+| `${eventTime:isValidInstant()}` where `eventTime` = 
`2026-08-04T10:15:30+05:30` | `true`
+| `${eventTime:isValidInstant()}` where `eventTime` = `Tue, 04 Aug 2026 
10:15:30 GMT` | `true`
+| `${eventTime:isValidInstant()}` where `eventTime` = `not-a-date` | `false`
+| `${eventTime:isValidInstant():ifElse('valid-instant', 'invalid-instant')}` 
where `eventTime` = `2026-08-04T10:15:30Z` | `valid-instant`
+|===================================================================
+
+
+[.function]
+=== isValidDate
+
+*Description*: [.description]#The `isValidDate` function returns `true` when 
the Subject can be parsed as a valid date using the specified format and 
optional timezone, and `false` otherwise.
+Validation is calendar-aware, so values that match the format pattern but do 
not represent a real date, such as `31-02-2026`, return `false`.
+This function is useful for validating values before calling `toDate()`. 
Unlike `toDate()`, this function does not throw an error when parsing fails.#
+
+*Subject Type*: [.subject]#String#
+
+*Arguments*:
+
+- *format* (required): The date format to use for parsing. The format must be 
a valid `java.time.format.DateTimeFormatter` pattern, such as `dd-MM-yyyy`.
+- *timezone* (optional): The timezone to apply when resolving date-time 
fields, such as `UTC` or `America/New_York`. When not provided, the system 
default timezone is used.
+
+*Return Type*: [.returnType]#Boolean#
+
+.isValidDate Examples
+|===================================================================
+| Expression | Value
+| `${myDate:isValidDate('dd-MM-yyyy')}` where `myDate` = `04-08-2026` | `true`
+| `${myDate:isValidDate('dd-MM-yyyy')}` where `myDate` = `31-02-2026` | `false`
+| `${myDate:isValidDate('dd-MM-yyyy')}` where `myDate` = `not-a-date` | `false`
+| `${myDate:isValidDate('yyyy-MM-dd HH:mm:ss', 'UTC')}` where `myDate` = 
`2026-08-04 10:15:30` | `true`
+| `${myDate:isValidDate('dd-MM-yyyy'):ifElse('valid', 'invalid')}` where 
`myDate` = `04-08-2026` | `valid`
+|===================================================================
+
+
+
 [[strings]]
 == String Manipulation
 

Reply via email to