From a1f1282a387c2903f5f08564761b15e6e907f3e0 Mon Sep 17 00:00:00 2001 From: Lukasz Lenart Date: Sun, 25 Oct 2026 09:45:49 +0100 Subject: [PATCH 1/3] 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, 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 --- ...otationValidationConfigurationBuilder.java | 43 +++++ .../LocalDateRangeFieldValidator.java | 181 ++++++++++++++++++ .../validator/annotations/Validations.java | 7 + .../LocalDateRangeFieldValidator.java | 75 ++++++++ .../struts2/validator/validators/default.xml | 1 + .../AnnotationLocalDateRangeAction.java | 40 ++++ ...notationMalformedLocalDateRangeAction.java | 32 ++++ ...ionValidationConfigurationBuilderTest.java | 36 ++++ .../LocalDateRangeFieldValidatorTest.java | 154 +++++++++++++++ 9 files changed, 569 insertions(+) create mode 100644 core/src/main/java/org/apache/struts2/validator/annotations/LocalDateRangeFieldValidator.java create mode 100644 core/src/main/java/org/apache/struts2/validator/validators/LocalDateRangeFieldValidator.java create mode 100644 core/src/test/java/org/apache/struts2/validator/AnnotationLocalDateRangeAction.java create mode 100644 core/src/test/java/org/apache/struts2/validator/AnnotationMalformedLocalDateRangeAction.java create mode 100644 core/src/test/java/org/apache/struts2/validator/validators/LocalDateRangeFieldValidatorTest.java 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 7c9650a057..ac08446666 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.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.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 @@ else if (a instanceof DateRangeFieldValidator v) { } } + // 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 @@ private void processValidationAnnotation(Annotation a, String fieldName, String } } } + 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 @@ private ValidatorConfig processCreditCardValidatorAnnotation(CreditCardValidator .build(); } + private ValidatorConfig processLocalDateRangeFieldValidatorAnnotation(LocalDateRangeFieldValidator v, String fieldName, String methodName) { + String validatorType = "localdate"; + + Map 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 0000000000..428ee76059 --- /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; + +/** + * + * 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). + * + * + *

Annotation usage:

+ * + * + *

The annotation must be applied at method level.

+ * + * + *

Annotation parameters:

+ * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
Parameter Required Default Notes
messageyes field error message
keyno i18n key from language specific properties file.
messageParamsno Additional params to be used to customize message - will be evaluated against the Value Stack
fieldNameno  
shortCircuitnofalseIf this validator should be used as shortCircuit.
typeyesValidatorType.FIELDEnum value from ValidatorType. Either FIELD or SIMPLE can be used here.
min no   ISO date (yyyy-MM-dd). The minimum the date must be.
minExpressionno OGNL expression used to obtain the minimum the date must be.
max no   ISO date (yyyy-MM-dd). The maximum date can be.
maxExpressionno OGNL expression used to obtain the maximum date can be.
+ * + *

If neither min nor max is set, nothing will be done.

+ * + * + *

Example code:

+ * + *
+ * 
+ * @LocalDateRangeFieldValidator(message = "Default message", key = "i18n.key", shortCircuit = true, min = "2005-01-01", max = "2005-12-31")
+ * 
+ * 
+ * + * @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 true. + * @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 aee0bfe856..7cfbb8627d 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 @@ * Add list of DateRangeFieldValidators * * + * localDateRangeFields + * no + * Add list of LocalDateRangeFieldValidators + * + * * emails * no * Add list of EmailValidators @@ -186,6 +191,8 @@ 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 0000000000..157cde2598 --- /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; + +/** + * + * + * 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. + * + * + * + * + *
    + *
  • fieldName - The field name this validator is validating. Required if using Plain-Validator Syntax otherwise not required
  • + *
  • min - the min date as an ISO date (yyyy-MM-dd). If not specified will not be checked.
  • + *
  • max - the max date as an ISO date (yyyy-MM-dd). If not specified will not be checked.
  • + *
  • minExpression - expression to calculate the minimum value (if none is specified, it will not be checked)
  • + *
  • maxExpression - expression to calculate the maximum value (if none is specified, it will not be checked)
  • + *
+ * + * + * + * Do not use ${minExpression} and ${maxExpression} as an expression as this will turn into infinitive loop! + * + * + *
+ * 
+ * <validators>
+ *     <field name="birthday">
+ *         <field-validator type="localdate">
+ *             <param name="min">1990-01-01</param>
+ *             <param name="max">2000-01-01</param>
+ *             <message>Birthday must be within ${min} and ${max}</message>
+ *         </field-validator>
+ *     </field>
+ *
+ *     <field name="birthday">
+ *         <field-validator type="localdate">
+ *             <param name="minExpression">${minValue}</param> <!-- will be evaluated as: LocalDate getMinValue() -->
+ *             <param name="maxExpression">${maxValue}</param> <!-- will be evaluated as: LocalDate getMaxValue() -->
+ *             <message>Birthday must be within ${min} and ${max}</message>
+ *         </field-validator>
+ *     </field>
+ * </validators>
+ * 
+ * 
+ * + * @since 7.5.0 + */ +public final class LocalDateRangeFieldValidator extends RangeValidatorSupport { + + 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 a3e6f93659..bb3968da80 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 @@ + 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 0000000000..b0693b4b6e --- /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 0000000000..7177096129 --- /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 0e55dbb796..b7d5bcc271 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.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 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 void testValidationsContainerWithRangeValidators() throws Exception { assertEquals(2, shortValidators); } + public void testLocalDateRangeAnnotations() throws Exception { + AnnotationActionValidatorManager manager = createValidationManager(AnnotationLocalDateRangeAction.class, Locale.US); + + List 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 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 0000000000..b1d6eb4956 --- /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 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; + } + } + +} From 4662de4711f3abc2681484d87eeee3bbe25a70aa Mon Sep 17 00:00:00 2001 From: Lukasz Lenart Date: Sun, 25 Oct 2026 10:05:42 +0100 Subject: [PATCH 2/3] WW-5816 fix(validation): parse localdate XML bounds strictly as ISO dates XML min/max params reached setMin/setMax through DateConverter, which tries the application's struts.date.format before ISO: with yyyy-dd-MM, a min of 2026-01-02 became February 1. The annotation bounds were already ISO-only. LocalDateRangeFieldValidator-conversion.properties maps min and max to LocalDateRangeBoundConverter, which parses them with LocalDate.parse, so XML bounds no longer depend on the display format. Co-Authored-By: Claude Opus 5.5 --- .../LocalDateRangeBoundConverter.java | 51 +++++++++++++++++++ ...eRangeFieldValidator-conversion.properties | 20 ++++++++ .../LocalDateRangeFieldValidatorTest.java | 16 ++++++ 3 files changed, 87 insertions(+) create mode 100644 core/src/main/java/org/apache/struts2/validator/validators/LocalDateRangeBoundConverter.java create mode 100644 core/src/main/resources/org/apache/struts2/validator/validators/LocalDateRangeFieldValidator-conversion.properties diff --git a/core/src/main/java/org/apache/struts2/validator/validators/LocalDateRangeBoundConverter.java b/core/src/main/java/org/apache/struts2/validator/validators/LocalDateRangeBoundConverter.java new file mode 100644 index 0000000000..e6d7be4748 --- /dev/null +++ b/core/src/main/java/org/apache/struts2/validator/validators/LocalDateRangeBoundConverter.java @@ -0,0 +1,51 @@ +/* + * 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.conversion.TypeConversionException; +import org.apache.struts2.conversion.impl.DefaultTypeConverter; + +import java.lang.reflect.Member; +import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import java.util.Map; + +/** + * Converts the {@code min} / {@code max} params of {@link LocalDateRangeFieldValidator} strictly as ISO dates, + * mapped in {@code LocalDateRangeFieldValidator-conversion.properties}, so a bound does not depend on the + * application's {@code struts.date.format}. + * + * @since 7.5.0 + */ +public final class LocalDateRangeBoundConverter extends DefaultTypeConverter { + + @Override + public Object convertValue(Map context, Object target, Member member, String propertyName, + Object value, Class toType) { + if (value instanceof String bound) { + try { + return LocalDate.parse(bound.trim()); + } catch (DateTimeParseException e) { + throw new TypeConversionException("Could not parse " + propertyName + " as an ISO date (yyyy-MM-dd)", e); + } + } + return value; + } + +} diff --git a/core/src/main/resources/org/apache/struts2/validator/validators/LocalDateRangeFieldValidator-conversion.properties b/core/src/main/resources/org/apache/struts2/validator/validators/LocalDateRangeFieldValidator-conversion.properties new file mode 100644 index 0000000000..b9fa510e83 --- /dev/null +++ b/core/src/main/resources/org/apache/struts2/validator/validators/LocalDateRangeFieldValidator-conversion.properties @@ -0,0 +1,20 @@ +# +# 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. +# +min=org.apache.struts2.validator.validators.LocalDateRangeBoundConverter +max=org.apache.struts2.validator.validators.LocalDateRangeBoundConverter 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 index b1d6eb4956..c4fc4e968d 100644 --- a/core/src/test/java/org/apache/struts2/validator/validators/LocalDateRangeFieldValidatorTest.java +++ b/core/src/test/java/org/apache/struts2/validator/validators/LocalDateRangeFieldValidatorTest.java @@ -21,6 +21,7 @@ import org.apache.struts2.ActionContext; import org.apache.struts2.ObjectFactory; import org.apache.struts2.XWorkTestCase; +import org.apache.struts2.text.StubTextProvider; import org.apache.struts2.text.TextProviderFactory; import org.apache.struts2.util.ValueStack; import org.apache.struts2.util.ValueStackFactory; @@ -97,6 +98,21 @@ public void testConvertsIsoBoundsFromConfigurationParams() throws Exception { assertEquals(MAX, validator.getMax()); } + public void testReadsConfigurationBoundsAsIsoWhateverTheGlobalDateFormat() throws Exception { + ActionContext.getContext().getValueStack() + .push(new StubTextProvider(Map.of(org.apache.struts2.components.Date.DATETAG_PROPERTY, "yyyy-dd-MM"))); + Map context = ActionContext.getContext().getContextMap(); + Map params = new HashMap<>(); + params.put("min", "2026-01-02"); + params.put("max", "2026-03-04"); + + LocalDateRangeFieldValidator validator = (LocalDateRangeFieldValidator) container.getInstance(ObjectFactory.class) + .buildValidator(LocalDateRangeFieldValidator.class.getName(), params, context); + + assertEquals(LocalDate.of(2026, 1, 2), validator.getMin()); + assertEquals(LocalDate.of(2026, 3, 4), validator.getMax()); + } + private ValidatorContext validate(LocalDate birthday) { LocalDateAction action = new LocalDateAction(birthday); ValidatorContext context = new DummyValidatorContext(action, tpf); From a5efbda69470bdfd301687027157ae9d7af75c87 Mon Sep 17 00:00:00 2001 From: Lukasz Lenart Date: Sun, 25 Oct 2026 10:17:29 +0100 Subject: [PATCH 3/3] WW-5816 test(validation): cover LocalDateRangeBoundConverter directly The converter's error branch is unreachable through buildValidator, which swallows conversion errors on validator params (WW-5818/WW-5819), so it was uncovered. Test the converter on its own: an ISO bound is parsed (trimmed), a non-ISO bound throws TypeConversionException naming the param with the DateTimeParseException as cause, and an already converted LocalDate passes through. Co-Authored-By: Claude Opus 5.5 --- .../LocalDateRangeBoundConverterTest.java | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 core/src/test/java/org/apache/struts2/validator/validators/LocalDateRangeBoundConverterTest.java diff --git a/core/src/test/java/org/apache/struts2/validator/validators/LocalDateRangeBoundConverterTest.java b/core/src/test/java/org/apache/struts2/validator/validators/LocalDateRangeBoundConverterTest.java new file mode 100644 index 0000000000..99c598d65b --- /dev/null +++ b/core/src/test/java/org/apache/struts2/validator/validators/LocalDateRangeBoundConverterTest.java @@ -0,0 +1,60 @@ +/* + * 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.conversion.TypeConversionException; +import org.junit.Test; + +import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import java.util.HashMap; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class LocalDateRangeBoundConverterTest { + + private final LocalDateRangeBoundConverter converter = new LocalDateRangeBoundConverter(); + + @Test + public void testParsesAnIsoBound() { + Object value = converter.convertValue(new HashMap<>(), null, null, "min", " 2026-01-02 ", LocalDate.class); + + assertEquals(LocalDate.of(2026, 1, 2), value); + } + + @Test + public void testRejectsANonIsoBound() { + TypeConversionException e = assertThrows(TypeConversionException.class, + () -> converter.convertValue(new HashMap<>(), null, null, "max", "01/02/2026", LocalDate.class)); + + assertEquals("Could not parse max as an ISO date (yyyy-MM-dd)", e.getMessage()); + assertTrue(e.getCause() instanceof DateTimeParseException); + } + + @Test + public void testPassesAnAlreadyConvertedBoundThrough() { + LocalDate bound = LocalDate.of(2026, 1, 2); + + assertSame(bound, converter.convertValue(new HashMap<>(), null, null, "min", bound, LocalDate.class)); + } + +}