diff --git a/spring-context/src/main/java/org/springframework/validation/DataBinder.java b/spring-context/src/main/java/org/springframework/validation/DataBinder.java index e2b3389544..25400efbf8 100644 --- a/spring-context/src/main/java/org/springframework/validation/DataBinder.java +++ b/spring-context/src/main/java/org/springframework/validation/DataBinder.java @@ -1032,7 +1032,9 @@ public class DataBinder implements PropertyEditorRegistry, TypeConverter { } int size = (indexes.last() < this.autoGrowCollectionLimit ? indexes.last() + 1 : 0); List list = (List) CollectionFactory.createCollection(paramType, size); - indexes.forEach(i -> list.add(null)); + for (int i = 0; i < size; i++) { + list.add(null); + } for (int index : indexes) { list.set(index, (V) createObject(elementType, paramPath + "[" + index + "].", valueResolver)); } diff --git a/spring-context/src/main/java/org/springframework/validation/beanvalidation/MethodValidationAdapter.java b/spring-context/src/main/java/org/springframework/validation/beanvalidation/MethodValidationAdapter.java index d9829014eb..a8571d0be1 100644 --- a/spring-context/src/main/java/org/springframework/validation/beanvalidation/MethodValidationAdapter.java +++ b/spring-context/src/main/java/org/springframework/validation/beanvalidation/MethodValidationAdapter.java @@ -367,7 +367,7 @@ public class MethodValidationAdapter implements MethodValidator { container = null; } - if (node.getKind().equals(ElementKind.PROPERTY)) { + if (node.getKind().equals(ElementKind.PROPERTY) || node.getKind().equals(ElementKind.BEAN)) { nestedViolations .computeIfAbsent(parameterNode, k -> new ParamErrorsBuilder(parameter, value, container, index, key)) diff --git a/spring-context/src/test/java/org/springframework/validation/DataBinderConstructTests.java b/spring-context/src/test/java/org/springframework/validation/DataBinderConstructTests.java index e375b2832a..fa63a1dd03 100644 --- a/spring-context/src/test/java/org/springframework/validation/DataBinderConstructTests.java +++ b/spring-context/src/test/java/org/springframework/validation/DataBinderConstructTests.java @@ -121,6 +121,24 @@ class DataBinderConstructTests { assertThat(list.get(2).param1()).isEqualTo("value3"); } + @Test // gh-34145 + void listBindingWithNonconsecutiveIndices() { + MapValueResolver valueResolver = new MapValueResolver(Map.of( + "dataClassList[0].param1", "value1", "dataClassList[0].param2", "true", + "dataClassList[1].param1", "value2", "dataClassList[1].param2", "true", + "dataClassList[3].param1", "value3", "dataClassList[3].param2", "true")); + + DataBinder binder = initDataBinder(ListDataClass.class); + binder.construct(valueResolver); + + ListDataClass dataClass = getTarget(binder); + List list = dataClass.dataClassList(); + + assertThat(list.get(0).param1()).isEqualTo("value1"); + assertThat(list.get(1).param1()).isEqualTo("value2"); + assertThat(list.get(3).param1()).isEqualTo("value3"); + } + @Test void mapBinding() { MapValueResolver valueResolver = new MapValueResolver(Map.of( diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/MethodValidationTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/MethodValidationTests.java index ca7e06a356..bfee95c745 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/MethodValidationTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/MethodValidationTests.java @@ -16,19 +16,32 @@ package org.springframework.web.servlet.mvc.method.annotation; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; import java.lang.reflect.Method; +import java.util.Arrays; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Set; import java.util.function.Consumer; import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.Constraint; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import jakarta.validation.ConstraintValidatorFactory; import jakarta.validation.ConstraintViolation; +import jakarta.validation.Payload; import jakarta.validation.Valid; import jakarta.validation.constraints.Size; import jakarta.validation.executable.ExecutableValidator; import jakarta.validation.metadata.BeanDescriptor; +import org.hibernate.validator.internal.engine.constraintvalidation.ConstraintValidatorFactoryImpl; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -39,11 +52,12 @@ import org.springframework.http.MediaType; import org.springframework.http.converter.StringHttpMessageConverter; import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; import org.springframework.validation.Errors; -import org.springframework.validation.FieldError; +import org.springframework.validation.ObjectError; import org.springframework.validation.Validator; import org.springframework.validation.annotation.Validated; import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; import org.springframework.validation.beanvalidation.SpringValidatorAdapter; +import org.springframework.validation.method.ParameterErrors; import org.springframework.validation.method.ParameterValidationResult; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.WebDataBinder; @@ -91,13 +105,17 @@ class MethodValidationTests { private InvocationCountingValidator jakartaValidator; + private final TestConstraintValidator testConstraintValidator = new TestConstraintValidator(); + @BeforeEach void setup() throws Exception { LocaleContextHolder.setDefaultLocale(Locale.UK); LocalValidatorFactoryBean validatorBean = new LocalValidatorFactoryBean(); + validatorBean.setConstraintValidatorFactory(new TestConstraintValidatorFactory(this.testConstraintValidator)); validatorBean.afterPropertiesSet(); + this.jakartaValidator = new InvocationCountingValidator(validatorBean); this.handlerAdapter = initHandlerAdapter(this.jakartaValidator); @@ -296,6 +314,30 @@ class MethodValidationTests { arguments []; default message [length must be 10 or under]"""); } + @Test // gh-34105 + void typeConstraint() { + this.testConstraintValidator.setReject(true); + + HandlerMethod hm = handlerMethod(new ValidController(), c -> c.handle(mockPerson, "")); + this.request.addHeader("header", "12345"); + this.request.setContentType("application/json"); + this.request.setContent("{\"name\":\"Faustino\"}".getBytes(UTF_8)); + + HandlerMethodValidationException ex = catchThrowableOfType(HandlerMethodValidationException.class, + () -> this.handlerAdapter.handle(this.request, this.response, hm)); + + List results = ex.getParameterValidationResults(); + assertThat(results).hasSize(1); + ParameterValidationResult result = results.get(0); + assertThat(result).isInstanceOf(ParameterErrors.class); + + assertBeanResult((Errors) result, "person", List.of(""" + Error in object 'person': codes [TestConstraint.person,TestConstraint]; \ + arguments [org.springframework.context.support.DefaultMessageSourceResolvable: \ + codes [person]; arguments []; default message []]; default message [Fail message]\ + """ + )); + } @SuppressWarnings("unchecked") private static HandlerMethod handlerMethod(T controller, Consumer mockCallConsumer) { @@ -306,8 +348,8 @@ class MethodValidationTests { @SuppressWarnings("SameParameterValue") private static void assertBeanResult(Errors errors, String objectName, List fieldErrors) { assertThat(errors.getObjectName()).isEqualTo(objectName); - assertThat(errors.getFieldErrors()) - .extracting(FieldError::toString) + assertThat(errors.getAllErrors()) + .extracting(ObjectError::toString) .containsExactlyInAnyOrderElementsOf(fieldErrors); } @@ -323,6 +365,7 @@ class MethodValidationTests { } + @TestConstraint @SuppressWarnings("unused") private record Person(@Size(min = 1, max = 10) @JsonProperty("name") String name) { @@ -356,6 +399,9 @@ class MethodValidationTests { void handle(@Valid @RequestBody List persons) { } + + void handle(@Valid @RequestBody Person person, @RequestHeader @Size(min=4) String header) { + } } @@ -477,4 +523,57 @@ class MethodValidationTests { } } + + + @Constraint(validatedBy = TestConstraintValidator.class) + @Target({ElementType.TYPE}) + @Retention(RetentionPolicy.RUNTIME) + public @interface TestConstraint { + + String message() default "Fail message"; + + Class[] groups() default {}; + + Class[] payload() default {}; + } + + + private static class TestConstraintValidator implements ConstraintValidator { + + private boolean reject; + + public void setReject(boolean reject) { + this.reject = reject; + } + + @Override + public boolean isValid(Person person, ConstraintValidatorContext context) { + return !this.reject; + } + } + + + private static class TestConstraintValidatorFactory implements ConstraintValidatorFactory { + + private final Map, ConstraintValidator> validators; + + private final ConstraintValidatorFactory delegate = new ConstraintValidatorFactoryImpl(); + + private TestConstraintValidatorFactory(ConstraintValidator... validators) { + this.validators = new LinkedHashMap<>(validators.length); + Arrays.stream(validators).forEach(validator -> this.validators.put(validator.getClass(), validator)); + } + + @SuppressWarnings("unchecked") + @Override + public > T getInstance(Class aClass) { + ConstraintValidator validator = this.validators.get(aClass); + return (validator != null ? (T) validator : this.delegate.getInstance(aClass)); + } + + @Override + public void releaseInstance(ConstraintValidator constraintValidator) { + } + } + }