This is an automated email from the ASF dual-hosted git repository.

lukaszlenart pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/struts.git

commit ee38d3ae4f969735bc385095838d7630708b4966
Author: Lukasz Lenart <[email protected]>
AuthorDate: Sun Oct 25 09:45:49 2026 +0100

    WW-5816 feat(validation): add a range validator for java.time.LocalDate
    
    DateRangeFieldValidator compares against java.util.Date bounds, so on a
    LocalDate property it throws ClassCastException. 
LocalDateRangeFieldValidator
    is a RangeValidatorSupport<LocalDate>, registered as "localdate", with
    ISO min/max bounds (or minExpression/maxExpression), both inclusive.
    
    An XML min such as "2026-01-01" reaches the generic setMin(T) as a
    LocalDate through the usual parameter conversion, which a test pins.
    
    @LocalDateRangeFieldValidator mirrors @DateRangeFieldValidator without
    dateFormat, since the bounds are ISO only. It is processed on a method
    and inside @Validations via the new localDateRangeFields() member. The
    builder parses the bounds with LocalDate.parse, so a malformed bound
    fails when the validators are built instead of passing through as a
    string.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../AnnotationValidationConfigurationBuilder.java  |  43 +++++
 .../annotations/LocalDateRangeFieldValidator.java  | 181 +++++++++++++++++++++
 .../struts2/validator/annotations/Validations.java |   7 +
 .../validators/LocalDateRangeFieldValidator.java   |  75 +++++++++
 .../struts2/validator/validators/default.xml       |   1 +
 .../validator/AnnotationLocalDateRangeAction.java  |  40 +++++
 .../AnnotationMalformedLocalDateRangeAction.java   |  32 ++++
 ...notationValidationConfigurationBuilderTest.java |  36 ++++
 .../LocalDateRangeFieldValidatorTest.java          | 154 ++++++++++++++++++
 9 files changed, 569 insertions(+)

diff --git 
a/core/src/main/java/org/apache/struts2/validator/AnnotationValidationConfigurationBuilder.java
 
b/core/src/main/java/org/apache/struts2/validator/AnnotationValidationConfigurationBuilder.java
index 7c9650a05..ac0844666 100644
--- 
a/core/src/main/java/org/apache/struts2/validator/AnnotationValidationConfigurationBuilder.java
+++ 
b/core/src/main/java/org/apache/struts2/validator/AnnotationValidationConfigurationBuilder.java
@@ -29,6 +29,7 @@ import 
org.apache.struts2.validator.annotations.EmailValidator;
 import org.apache.struts2.validator.annotations.ExpressionValidator;
 import org.apache.struts2.validator.annotations.FieldExpressionValidator;
 import org.apache.struts2.validator.annotations.IntRangeFieldValidator;
+import org.apache.struts2.validator.annotations.LocalDateRangeFieldValidator;
 import org.apache.struts2.validator.annotations.LongRangeFieldValidator;
 import org.apache.struts2.validator.annotations.RegexFieldValidator;
 import org.apache.struts2.validator.annotations.RequiredFieldValidator;
@@ -46,6 +47,7 @@ import java.lang.reflect.Method;
 import java.text.DateFormat;
 import java.text.ParseException;
 import java.text.SimpleDateFormat;
+import java.time.LocalDate;
 import java.util.ArrayList;
 import java.util.Date;
 import java.util.HashMap;
@@ -125,6 +127,10 @@ public class AnnotationValidationConfigurationBuilder {
                     }
 
                 }
+                // Process LocalDateRangeFieldValidator
+                else if (a instanceof LocalDateRangeFieldValidator v) {
+                    
result.add(processLocalDateRangeFieldValidatorAnnotation(v, fieldName, 
methodName));
+                }
                 // Process EmailValidator
                 else if (a instanceof EmailValidator v) {
                     ValidatorConfig temp = processEmailValidatorAnnotation(v, 
fieldName, methodName);
@@ -267,6 +273,9 @@ public class AnnotationValidationConfigurationBuilder {
                 }
             }
         }
+        for (LocalDateRangeFieldValidator v : 
validations.localDateRangeFields()) {
+            result.add(processLocalDateRangeFieldValidatorAnnotation(v, 
fieldName, methodName));
+        }
         EmailValidator[] emv = validations.emails();
         if (emv != null) {
             for (EmailValidator v : emv) {
@@ -891,6 +900,40 @@ public class AnnotationValidationConfigurationBuilder {
                 .build();
     }
 
+    private ValidatorConfig 
processLocalDateRangeFieldValidatorAnnotation(LocalDateRangeFieldValidator v, 
String fieldName, String methodName) {
+        String validatorType = "localdate";
+
+        Map<String, Object> params = new HashMap<>();
+
+        if (fieldName != null) {
+            params.put("fieldName", fieldName);
+        } else if (StringUtils.isNotEmpty(v.fieldName())) {
+            params.put("fieldName", v.fieldName());
+        }
+        if (StringUtils.isNotEmpty(v.min())) {
+            params.put("min", LocalDate.parse(v.min()));
+        }
+        if (StringUtils.isNotEmpty(v.max())) {
+            params.put("max", LocalDate.parse(v.max()));
+        }
+        if (StringUtils.isNotEmpty(v.minExpression())) {
+            params.put("minExpression", v.minExpression());
+        }
+        if (StringUtils.isNotEmpty(v.maxExpression())) {
+            params.put("maxExpression", v.maxExpression());
+        }
+
+        validatorFactory.lookupRegisteredValidatorType(validatorType);
+        return new ValidatorConfig.Builder(validatorType)
+                .addParams(params)
+                .addParam("methodName", methodName)
+                .shortCircuit(v.shortCircuit())
+                .defaultMessage(v.message())
+                .messageKey(v.key())
+                .messageParams(v.messageParams())
+                .build();
+    }
+
     private ValidatorConfig 
processDateRangeFieldValidatorAnnotation(DateRangeFieldValidator v, String 
fieldName, String methodName) {
         String validatorType = "date";
 
diff --git 
a/core/src/main/java/org/apache/struts2/validator/annotations/LocalDateRangeFieldValidator.java
 
b/core/src/main/java/org/apache/struts2/validator/annotations/LocalDateRangeFieldValidator.java
new file mode 100644
index 000000000..428ee7605
--- /dev/null
+++ 
b/core/src/main/java/org/apache/struts2/validator/annotations/LocalDateRangeFieldValidator.java
@@ -0,0 +1,181 @@
+/*
+ * 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.struts2.validator.annotations;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * <!-- START SNIPPET: description -->
+ * This validator checks that a {@link java.time.LocalDate} field has a value 
within a specified range, both
+ * bounds inclusive. The min and max values are ISO dates (yyyy-MM-dd).
+ * <!-- END SNIPPET: description -->
+ *
+ * <p><u>Annotation usage:</u></p>
+ *
+ * <!-- START SNIPPET: usage -->
+ * <p>The annotation must be applied at method level.</p>
+ * <!-- END SNIPPET: usage -->
+ *
+ * <p><u>Annotation parameters:</u></p>
+ *
+ * <!-- START SNIPPET: parameters -->
+ * <table class='confluenceTable' summary=''>
+ * <tr>
+ * <th class='confluenceTh'> Parameter </th>
+ * <th class='confluenceTh'> Required </th>
+ * <th class='confluenceTh'> Default </th>
+ * <th class='confluenceTh'> Notes </th>
+ * </tr>
+ * <tr>
+ * <td class='confluenceTd'>message</td>
+ * <td class='confluenceTd'>yes</td>
+ * <td class='confluenceTd'>&nbsp;</td>
+ * <td class='confluenceTd'>field error message</td>
+ * </tr>
+ * <tr>
+ * <td class='confluenceTd'>key</td>
+ * <td class='confluenceTd'>no</td>
+ * <td class='confluenceTd'>&nbsp;</td>
+ * <td class='confluenceTd'>i18n key from language specific properties 
file.</td>
+ * </tr>
+ * <tr>
+ * <td class='confluenceTd'>messageParams</td>
+ * <td class='confluenceTd'>no</td>
+ * <td class='confluenceTd'>&nbsp;</td>
+ * <td class='confluenceTd'>Additional params to be used to customize message 
- will be evaluated against the Value Stack</td>
+ * </tr>
+ * <tr>
+ * <td class='confluenceTd'>fieldName</td>
+ * <td class='confluenceTd'>no</td>
+ * <td class='confluenceTd'>&nbsp;</td>
+ * <td class='confluenceTd'>&nbsp;</td>
+ * </tr>
+ * <tr>
+ * <td class='confluenceTd'>shortCircuit</td>
+ * <td class='confluenceTd'>no</td>
+ * <td class='confluenceTd'>false</td>
+ * <td class='confluenceTd'>If this validator should be used as 
shortCircuit.</td>
+ * </tr>
+ * <tr>
+ * <td class='confluenceTd'>type</td>
+ * <td class='confluenceTd'>yes</td>
+ * <td class='confluenceTd'>ValidatorType.FIELD</td>
+ * <td class='confluenceTd'>Enum value from ValidatorType. Either FIELD or 
SIMPLE can be used here.</td>
+ * </tr>
+ * <tr>
+ * <td class='confluenceTd'> min </td>
+ * <td class='confluenceTd'> no </td>
+ * <td class='confluenceTd'>&nbsp;</td>
+ * <td class='confluenceTd'> ISO date (yyyy-MM-dd).  The minimum the date must 
be. </td>
+ * </tr>
+ * <tr>
+ * <td class='confluenceTd'>minExpression</td>
+ * <td class='confluenceTd'>no</td>
+ * <td class='confluenceTd'>&nbsp;</td>
+ * <td class='confluenceTd'>OGNL expression used to obtain the minimum the 
date must be.</td>
+ * </tr>
+ * <tr>
+ * <td class='confluenceTd'> max </td>
+ * <td class='confluenceTd'> no </td>
+ * <td class='confluenceTd'>&nbsp;</td>
+ * <td class='confluenceTd'> ISO date (yyyy-MM-dd).  The maximum date can be. 
</td>
+ * </tr>
+ * <tr>
+ * <td class='confluenceTd'>maxExpression</td>
+ * <td class='confluenceTd'>no</td>
+ * <td class='confluenceTd'>&nbsp;</td>
+ * <td class='confluenceTd'>OGNL expression used to obtain the maximum date 
can be.</td>
+ * </tr>
+ * </table>
+ *
+ * <p>If neither <em>min</em> nor <em>max</em> is set, nothing will be 
done.</p>
+ * <!-- END SNIPPET: parameters -->
+ *
+ * <p><u>Example code:</u></p>
+ *
+ * <pre>
+ * <!-- START SNIPPET: example -->
+ * &#64;LocalDateRangeFieldValidator(message = "Default message", key = 
"i18n.key", shortCircuit = true, min = "2005-01-01", max = "2005-12-31")
+ * <!-- END SNIPPET: example -->
+ * </pre>
+ *
+ * @since 7.5.0
+ */
+@Target({ElementType.METHOD})
+@Retention(RetentionPolicy.RUNTIME)
+public @interface LocalDateRangeFieldValidator {
+
+    /**
+     *  @return The minimum the date must be, as an ISO date (yyyy-MM-dd).
+     */
+    String min() default "";
+
+    /**
+     * @return An expression which will be evaluated against the Value Stack 
to get the min value
+     */
+    String minExpression() default "";
+
+    /**
+     *  @return The maximum the date can be, as an ISO date (yyyy-MM-dd).
+     */
+    String max() default "";
+
+    /**
+     * @return An expression which will be evaluated against the Value Stack 
to get the max value
+     */
+    String maxExpression() default "";
+
+    /**
+     * @return The default error message for this validator.
+     * NOTE: It is required to set a message, if you are not using the message 
key for 18n lookup!
+     */
+    String message() default "";
+
+    /**
+     * @return The message key to lookup for i18n.
+     */
+    String key() default "";
+
+    /**
+     * @return Additional params to be used to customize message - will be 
evaluated against the Value Stack
+     */
+    String[] messageParams() default {};
+
+    /**
+     * @return The optional fieldName for SIMPLE validator types.
+     */
+    String fieldName() default "";
+
+    /**
+     * If this is activated, the validator will be used as short-circuit.
+     *
+     * Adds the short-circuit="true" attribute value if <tt>true</tt>.
+     * @return true if validator will be used as short-circuit. Default is 
false.
+     */
+    boolean shortCircuit() default false;
+
+    /**
+     * @return The validation type for this field/method.
+     */
+    ValidatorType type() default ValidatorType.FIELD;
+
+}
diff --git 
a/core/src/main/java/org/apache/struts2/validator/annotations/Validations.java 
b/core/src/main/java/org/apache/struts2/validator/annotations/Validations.java
index aee0bfe85..7cfbb8627 100644
--- 
a/core/src/main/java/org/apache/struts2/validator/annotations/Validations.java
+++ 
b/core/src/main/java/org/apache/struts2/validator/annotations/Validations.java
@@ -67,6 +67,11 @@ import java.lang.annotation.Target;
  * <td class='confluenceTd'> Add list of DateRangeFieldValidators </td>
  * </tr>
  * <tr>
+ * <td class='confluenceTd'> localDateRangeFields </td>
+ * <td class='confluenceTd'> no </td>
+ * <td class='confluenceTd'> Add list of LocalDateRangeFieldValidators </td>
+ * </tr>
+ * <tr>
  * <td class='confluenceTd'> emails </td>
  * <td class='confluenceTd'> no </td>
  * <td class='confluenceTd'> Add list of EmailValidators </td>
@@ -186,6 +191,8 @@ public @interface Validations {
 
     DateRangeFieldValidator[] dateRangeFields() default {};
 
+    LocalDateRangeFieldValidator[] localDateRangeFields() default {};
+
     EmailValidator[] emails() default {};
 
     CreditCardValidator[] creditCards() default {};
diff --git 
a/core/src/main/java/org/apache/struts2/validator/validators/LocalDateRangeFieldValidator.java
 
b/core/src/main/java/org/apache/struts2/validator/validators/LocalDateRangeFieldValidator.java
new file mode 100644
index 000000000..157cde259
--- /dev/null
+++ 
b/core/src/main/java/org/apache/struts2/validator/validators/LocalDateRangeFieldValidator.java
@@ -0,0 +1,75 @@
+/*
+ * 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.struts2.validator.validators;
+
+import java.time.LocalDate;
+
+/**
+ * <!-- START SNIPPET: javadoc -->
+ *
+ * Field Validator that checks if a {@link LocalDate} is within a specific 
range, both bounds inclusive.
+ * Use {@link DateRangeFieldValidator} for a {@link java.util.Date} property.
+ *
+ * <!-- END SNIPPET: javadoc -->
+ *
+ * <!-- START SNIPPET: parameters -->
+ * <ul>
+ *      <li>fieldName - The field name this validator is validating. Required 
if using Plain-Validator Syntax otherwise not required</li>
+ *      <li>min - the min date as an ISO date (yyyy-MM-dd). If not specified 
will not be checked.</li>
+ *      <li>max - the max date as an ISO date (yyyy-MM-dd). If not specified 
will not be checked.</li>
+ *      <li>minExpression - expression to calculate the minimum value (if none 
is specified, it will not be checked) </li>
+ *      <li>maxExpression - expression to calculate the maximum value (if none 
is specified, it will not be checked) </li>
+ * </ul>
+ * <!-- END SNIPPET: parameters -->
+ *
+ * <!-- START SNIPPET: parameters-warning -->
+ * Do not use ${minExpression} and ${maxExpression} as an expression as this 
will turn into infinitive loop!
+ * <!-- END SNIPPET: parameters-warning -->
+ *
+ * <pre>
+ * <!-- START SNIPPET: examples -->
+ * &lt;validators&gt;
+ *     &lt;field name="birthday"&gt;
+ *         &lt;field-validator type="localdate"&gt;
+ *             &lt;param name="min"&gt;1990-01-01&lt;/param&gt;
+ *             &lt;param name="max"&gt;2000-01-01&lt;/param&gt;
+ *             &lt;message&gt;Birthday must be within ${min} and 
${max}&lt;/message&gt;
+ *         &lt;/field-validator&gt;
+ *     &lt;/field&gt;
+ *
+ *     &lt;field name="birthday"&gt;
+ *         &lt;field-validator type="localdate"&gt;
+ *             &lt;param name="minExpression"&gt;${minValue}&lt;/param&gt; 
&lt;!-- will be evaluated as: LocalDate getMinValue() --&gt;
+ *             &lt;param name="maxExpression"&gt;${maxValue}&lt;/param&gt; 
&lt;!-- will be evaluated as: LocalDate getMaxValue() --&gt;
+ *             &lt;message&gt;Birthday must be within ${min} and 
${max}&lt;/message&gt;
+ *         &lt;/field-validator&gt;
+ *     &lt;/field&gt;
+ * &lt;/validators&gt;
+ * <!-- END SNIPPET: examples -->
+ * </pre>
+ *
+ * @since 7.5.0
+ */
+public final class LocalDateRangeFieldValidator extends 
RangeValidatorSupport<LocalDate> {
+
+    public LocalDateRangeFieldValidator() {
+        super(LocalDate.class);
+    }
+
+}
diff --git 
a/core/src/main/resources/org/apache/struts2/validator/validators/default.xml 
b/core/src/main/resources/org/apache/struts2/validator/validators/default.xml
index a3e6f9365..bb3968da8 100644
--- 
a/core/src/main/resources/org/apache/struts2/validator/validators/default.xml
+++ 
b/core/src/main/resources/org/apache/struts2/validator/validators/default.xml
@@ -32,6 +32,7 @@
     <validator name="short" 
class="org.apache.struts2.validator.validators.ShortRangeFieldValidator"/>
     <validator name="double" 
class="org.apache.struts2.validator.validators.DoubleRangeFieldValidator"/>
     <validator name="date" 
class="org.apache.struts2.validator.validators.DateRangeFieldValidator"/>
+    <validator name="localdate" 
class="org.apache.struts2.validator.validators.LocalDateRangeFieldValidator"/>
     <validator name="expression" 
class="org.apache.struts2.validator.validators.ExpressionValidator"/>
     <validator name="fieldexpression" 
class="org.apache.struts2.validator.validators.FieldExpressionValidator"/>
     <validator name="email" 
class="org.apache.struts2.validator.validators.EmailValidator"/>
diff --git 
a/core/src/test/java/org/apache/struts2/validator/AnnotationLocalDateRangeAction.java
 
b/core/src/test/java/org/apache/struts2/validator/AnnotationLocalDateRangeAction.java
new file mode 100644
index 000000000..b0693b4b6
--- /dev/null
+++ 
b/core/src/test/java/org/apache/struts2/validator/AnnotationLocalDateRangeAction.java
@@ -0,0 +1,40 @@
+/*
+ * 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.struts2.validator;
+
+import org.apache.struts2.ActionSupport;
+import org.apache.struts2.validator.annotations.LocalDateRangeFieldValidator;
+import org.apache.struts2.validator.annotations.Validations;
+
+public class AnnotationLocalDateRangeAction extends ActionSupport {
+
+    @LocalDateRangeFieldValidator(fieldName = "birthday", min = "2026-01-01", 
max = "2026-12-31",
+            message = "Birthday must be between ${min} and ${max}")
+    @Validations(
+            localDateRangeFields = {
+                    @LocalDateRangeFieldValidator(fieldName = "holiday", min = 
"2026-07-01", max = "2026-08-31",
+                            message = "Holiday must be between ${min} and 
${max}")
+            }
+    )
+    @Override
+    public String execute() {
+        return SUCCESS;
+    }
+
+}
diff --git 
a/core/src/test/java/org/apache/struts2/validator/AnnotationMalformedLocalDateRangeAction.java
 
b/core/src/test/java/org/apache/struts2/validator/AnnotationMalformedLocalDateRangeAction.java
new file mode 100644
index 000000000..717709612
--- /dev/null
+++ 
b/core/src/test/java/org/apache/struts2/validator/AnnotationMalformedLocalDateRangeAction.java
@@ -0,0 +1,32 @@
+/*
+ * 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.struts2.validator;
+
+import org.apache.struts2.ActionSupport;
+import org.apache.struts2.validator.annotations.LocalDateRangeFieldValidator;
+
+public class AnnotationMalformedLocalDateRangeAction extends ActionSupport {
+
+    @LocalDateRangeFieldValidator(fieldName = "birthday", min = "01/01/2026", 
message = "Birthday is too early")
+    @Override
+    public String execute() {
+        return SUCCESS;
+    }
+
+}
diff --git 
a/core/src/test/java/org/apache/struts2/validator/AnnotationValidationConfigurationBuilderTest.java
 
b/core/src/test/java/org/apache/struts2/validator/AnnotationValidationConfigurationBuilderTest.java
index 0e55dbb79..b7d5bcc27 100644
--- 
a/core/src/test/java/org/apache/struts2/validator/AnnotationValidationConfigurationBuilderTest.java
+++ 
b/core/src/test/java/org/apache/struts2/validator/AnnotationValidationConfigurationBuilderTest.java
@@ -18,6 +18,7 @@
  */
 package org.apache.struts2.validator;
 
+import org.apache.commons.lang3.exception.ExceptionUtils;
 import org.apache.struts2.ActionContext;
 import org.apache.struts2.ActionInvocation;
 import org.apache.struts2.ActionSupport;
@@ -36,6 +37,7 @@ import 
org.apache.struts2.validator.validators.ConditionalVisitorFieldValidator;
 import org.apache.struts2.validator.validators.ConversionErrorFieldValidator;
 import org.apache.struts2.validator.validators.CreditCardValidator;
 import org.apache.struts2.validator.validators.DateRangeFieldValidator;
+import org.apache.struts2.validator.validators.LocalDateRangeFieldValidator;
 import org.apache.struts2.validator.validators.DoubleRangeFieldValidator;
 import org.apache.struts2.validator.validators.EmailValidator;
 import org.apache.struts2.validator.validators.ExpressionValidator;
@@ -53,6 +55,8 @@ import org.apache.struts2.StrutsConstants;
 
 import java.text.ParseException;
 import java.text.SimpleDateFormat;
+import java.time.LocalDate;
+import java.time.format.DateTimeParseException;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Locale;
@@ -355,6 +359,38 @@ public class AnnotationValidationConfigurationBuilderTest 
extends XWorkTestCase
         assertEquals(2, shortValidators);
     }
 
+    public void testLocalDateRangeAnnotations() throws Exception {
+        AnnotationActionValidatorManager manager = 
createValidationManager(AnnotationLocalDateRangeAction.class, Locale.US);
+
+        List<Validator> validators = 
manager.getValidators(AnnotationLocalDateRangeAction.class, null);
+
+        assertEquals(2, validators.size());
+        for (Validator validator : validators) {
+            assertEquals("localdate", validator.getValidatorType());
+            LocalDateRangeFieldValidator localDateValidator = 
(LocalDateRangeFieldValidator) validator;
+            if ("birthday".equals(localDateValidator.getFieldName())) {
+                assertEquals(LocalDate.of(2026, 1, 1), 
localDateValidator.getMin());
+                assertEquals(LocalDate.of(2026, 12, 31), 
localDateValidator.getMax());
+                assertEquals("Birthday must be between ${min} and ${max}", 
localDateValidator.getDefaultMessage());
+            } else {
+                assertEquals("holiday", localDateValidator.getFieldName());
+                assertEquals(LocalDate.of(2026, 7, 1), 
localDateValidator.getMin());
+                assertEquals(LocalDate.of(2026, 8, 31), 
localDateValidator.getMax());
+            }
+        }
+    }
+
+    public void testLocalDateRangeAnnotationRejectsANonIsoBound() throws 
Exception {
+        AnnotationActionValidatorManager manager = 
createValidationManager(AnnotationMalformedLocalDateRangeAction.class, 
Locale.US);
+
+        try {
+            
manager.getValidators(AnnotationMalformedLocalDateRangeAction.class, null);
+            fail("A non-ISO min must fail when the validators are built");
+        } catch (Exception e) {
+            assertTrue(ExceptionUtils.getRootCause(e) instanceof 
DateTimeParseException);
+        }
+    }
+
     private AnnotationActionValidatorManager createValidationManager(final 
Class<? extends ActionSupport> actionClass, Locale locale) throws Exception {
         loadConfigurationProviders(new ConfigurationProvider() {
             public void destroy() {
diff --git 
a/core/src/test/java/org/apache/struts2/validator/validators/LocalDateRangeFieldValidatorTest.java
 
b/core/src/test/java/org/apache/struts2/validator/validators/LocalDateRangeFieldValidatorTest.java
new file mode 100644
index 000000000..b1d6eb495
--- /dev/null
+++ 
b/core/src/test/java/org/apache/struts2/validator/validators/LocalDateRangeFieldValidatorTest.java
@@ -0,0 +1,154 @@
+/*
+ * 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.struts2.validator.validators;
+
+import org.apache.struts2.ActionContext;
+import org.apache.struts2.ObjectFactory;
+import org.apache.struts2.XWorkTestCase;
+import org.apache.struts2.text.TextProviderFactory;
+import org.apache.struts2.util.ValueStack;
+import org.apache.struts2.util.ValueStackFactory;
+import org.apache.struts2.validator.DummyValidatorContext;
+import org.apache.struts2.validator.ValidatorContext;
+import org.apache.struts2.validator.ValidatorFactory;
+
+import java.time.LocalDate;
+import java.util.HashMap;
+import java.util.Map;
+
+public class LocalDateRangeFieldValidatorTest extends XWorkTestCase {
+
+    private static final LocalDate MIN = LocalDate.of(2026, 1, 1);
+    private static final LocalDate MAX = LocalDate.of(2026, 12, 31);
+
+    private TextProviderFactory tpf;
+
+    public void testPassesInsideTheRange() {
+        assertEquals(0, validate(LocalDate.of(2026, 6, 
15)).getFieldErrors().size());
+    }
+
+    public void testPassesOnTheMinBound() {
+        assertEquals(0, validate(MIN).getFieldErrors().size());
+    }
+
+    public void testPassesOnTheMaxBound() {
+        assertEquals(0, validate(MAX).getFieldErrors().size());
+    }
+
+    public void testFailsBelowTheMinBound() {
+        ValidatorContext context = validate(MIN.minusDays(1));
+
+        assertEquals("Birthday must be within 2026-01-01 and 2026-12-31", 
context.getFieldErrors().get("birthday").get(0));
+    }
+
+    public void testFailsAboveTheMaxBound() {
+        ValidatorContext context = validate(MAX.plusDays(1));
+
+        assertEquals("Birthday must be within 2026-01-01 and 2026-12-31", 
context.getFieldErrors().get("birthday").get(0));
+    }
+
+    public void testSkipsANullValue() {
+        assertEquals(0, validate(null).getFieldErrors().size());
+    }
+
+    public void testReadsTheBoundsFromExpressions() throws Exception {
+        LocalDateAction action = new LocalDateAction(MIN.minusDays(1));
+        ValidatorContext context = new DummyValidatorContext(action, tpf);
+        LocalDateRangeFieldValidator validator = prepareValidator(action, 
context);
+        validator.setMinExpression("${minValue}");
+        validator.setMaxExpression("${maxValue}");
+
+        validator.validate(action);
+
+        assertEquals(1, context.getFieldErrors().get("birthday").size());
+    }
+
+    public void testIsRegisteredAsLocaldate() {
+        String className = 
container.getInstance(ValidatorFactory.class).lookupRegisteredValidatorType("localdate");
+
+        assertEquals(LocalDateRangeFieldValidator.class.getName(), className);
+    }
+
+    public void testConvertsIsoBoundsFromConfigurationParams() throws 
Exception {
+        Map<String, Object> params = new HashMap<>();
+        params.put("min", "2026-01-01");
+        params.put("max", "2026-12-31");
+
+        LocalDateRangeFieldValidator validator = 
(LocalDateRangeFieldValidator) container.getInstance(ObjectFactory.class)
+                .buildValidator(LocalDateRangeFieldValidator.class.getName(), 
params, ActionContext.getContext().getContextMap());
+
+        assertEquals(MIN, validator.getMin());
+        assertEquals(MAX, validator.getMax());
+    }
+
+    private ValidatorContext validate(LocalDate birthday) {
+        LocalDateAction action = new LocalDateAction(birthday);
+        ValidatorContext context = new DummyValidatorContext(action, tpf);
+        LocalDateRangeFieldValidator validator = prepareValidator(action, 
context);
+        validator.setMin(MIN);
+        validator.setMax(MAX);
+
+        try {
+            validator.validate(action);
+        } catch (Exception e) {
+            throw new IllegalStateException(e);
+        }
+        return context;
+    }
+
+    private LocalDateRangeFieldValidator prepareValidator(LocalDateAction 
action, ValidatorContext context) {
+        ValueStack valueStack = 
container.getInstance(ValueStackFactory.class).createValueStack();
+        valueStack.push(action);
+
+        LocalDateRangeFieldValidator validator = new 
LocalDateRangeFieldValidator();
+        validator.setValueStack(valueStack);
+        validator.setValidatorContext(context);
+        validator.setFieldName("birthday");
+        validator.setDefaultMessage("Birthday must be within ${min} and 
${max}");
+        return validator;
+    }
+
+    @Override
+    public void setUp() throws Exception {
+        super.setUp();
+        tpf = container.getInstance(TextProviderFactory.class);
+    }
+
+    public static class LocalDateAction {
+
+        private final LocalDate birthday;
+
+        public LocalDateAction(LocalDate birthday) {
+            this.birthday = birthday;
+        }
+
+        public LocalDate getBirthday() {
+            return birthday;
+        }
+
+        public LocalDate getMinValue() {
+            return MIN;
+        }
+
+        public LocalDate getMaxValue() {
+            return MAX;
+        }
+    }
+
+}

Reply via email to