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