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 b7d696e68b9..11b76a27ba2 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 @@ -301,8 +301,8 @@ public class MethodValidationAdapter implements MethodValidator { Function parameterFunction, Function argumentFunction) { - Map paramViolations = new LinkedHashMap<>(); - Map beanViolations = new LinkedHashMap<>(); + Map paramViolations = new LinkedHashMap<>(); + Map nestedViolations = new LinkedHashMap<>(); for (ConstraintViolation violation : violations) { Iterator itr = violation.getPropertyPath().iterator(); @@ -322,59 +322,62 @@ public class MethodValidationAdapter implements MethodValidator { } Object arg = argumentFunction.apply(parameter.getParameterIndex()); - if (!itr.hasNext()) { - paramViolations - .computeIfAbsent(parameter, p -> new ParamResultBuilder(target, parameter, arg)) + + // If the arg is a container, we need to element, but the only way to extract it + // is to check for and use a container index or key on the next node: + // https://github.com/jakartaee/validation/issues/194 + + Path.Node parameterNode = node; + if (itr.hasNext()) { + node = itr.next(); + } + + Object value; + Object container; + Integer index = node.getIndex(); + Object key = node.getKey(); + if (index != null && arg instanceof List list) { + value = list.get(index); + container = list; + } + else if (index != null && arg instanceof Object[] array) { + value = array[index]; + container = array; + } + else if (key != null && arg instanceof Map map) { + value = map.get(key); + container = map; + } + else if (arg instanceof Optional optional) { + value = optional.orElse(null); + container = optional; + } + else { + Assert.state(!node.isInIterable(), "No way to unwrap Iterable without index"); + value = arg; + container = null; + } + + if (node.getKind().equals(ElementKind.PROPERTY)) { + nestedViolations + .computeIfAbsent(parameterNode, k -> + new ParamErrorsBuilder(parameter, value, container, index, key)) .addViolation(violation); } else { - - // https://github.com/jakartaee/validation/issues/194 - // If the argument is a container of elements, we need the element, but - // the only option is to see if the next part of the property path has - // a container index/key for its parent and use it. - - Path.Node paramNode = node; - node = itr.next(); - - Object bean; - Object container; - Integer containerIndex = node.getIndex(); - Object containerKey = node.getKey(); - if (containerIndex != null && arg instanceof List list) { - bean = list.get(containerIndex); - container = list; - } - else if (containerIndex != null && arg instanceof Object[] array) { - bean = array[containerIndex]; - container = array; - } - else if (containerKey != null && arg instanceof Map map) { - bean = map.get(containerKey); - container = map; - } - else if (arg instanceof Optional optional) { - bean = optional.orElse(null); - container = optional; - } - else { - Assert.state(!node.isInIterable(), "No way to unwrap Iterable without index"); - bean = arg; - container = null; - } - - beanViolations - .computeIfAbsent(paramNode, k -> - new BeanResultBuilder(parameter, bean, container, containerIndex, containerKey)) + paramViolations + .computeIfAbsent(parameterNode, p -> + new ParamValidationResultBuilder(target, parameter, value, container, index, key)) .addViolation(violation); } + break; } } List resultList = new ArrayList<>(); paramViolations.forEach((param, builder) -> resultList.add(builder.build())); - beanViolations.forEach((key, builder) -> resultList.add(builder.build())); + nestedViolations.forEach((key, builder) -> resultList.add(builder.build())); resultList.sort(resultComparator); return MethodValidationResult.create(target, method, resultList); @@ -430,21 +433,35 @@ public class MethodValidationAdapter implements MethodValidator { * Builds a validation result for a value method parameter with constraints * declared directly on it. */ - private final class ParamResultBuilder { + private final class ParamValidationResultBuilder { private final Object target; private final MethodParameter parameter; @Nullable - private final Object argument; + private final Object value; + + @Nullable + private final Object container; + + @Nullable + private final Integer containerIndex; + + @Nullable + private final Object containerKey; private final List resolvableErrors = new ArrayList<>(); - public ParamResultBuilder(Object target, MethodParameter parameter, @Nullable Object argument) { + public ParamValidationResultBuilder( + Object target, MethodParameter parameter, @Nullable Object value, @Nullable Object container, + @Nullable Integer containerIndex, @Nullable Object containerKey) { this.target = target; this.parameter = parameter; - this.argument = argument; + this.value = value; + this.container = container; + this.containerIndex = containerIndex; + this.containerKey = containerKey; } public void addViolation(ConstraintViolation violation) { @@ -452,7 +469,9 @@ public class MethodValidationAdapter implements MethodValidator { } public ParameterValidationResult build() { - return new ParameterValidationResult(this.parameter, this.argument, this.resolvableErrors); + return new ParameterValidationResult( + this.parameter, this.value, this.resolvableErrors, this.container, + this.containerIndex, this.containerKey); } } @@ -462,7 +481,7 @@ public class MethodValidationAdapter implements MethodValidator { * Builds a validation result for an {@link jakarta.validation.Valid @Valid} * annotated bean method parameter with cascaded constraints. */ - private final class BeanResultBuilder { + private final class ParamErrorsBuilder { private final MethodParameter parameter; @@ -482,7 +501,7 @@ public class MethodValidationAdapter implements MethodValidator { private final Set> violations = new LinkedHashSet<>(); - public BeanResultBuilder( + public ParamErrorsBuilder( MethodParameter param, @Nullable Object bean, @Nullable Object container, @Nullable Integer containerIndex, @Nullable Object containerKey) { diff --git a/spring-context/src/main/java/org/springframework/validation/method/ParameterErrors.java b/spring-context/src/main/java/org/springframework/validation/method/ParameterErrors.java index bf8f7b1c1e4..3fc328146f7 100644 --- a/spring-context/src/main/java/org/springframework/validation/method/ParameterErrors.java +++ b/spring-context/src/main/java/org/springframework/validation/method/ParameterErrors.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -32,12 +32,6 @@ import org.springframework.validation.ObjectError; * {@link Errors#getAllErrors()}, but this subclass provides access to the same * as {@link FieldError}s. * - *

When the method parameter is a container such as a {@link List}, array, - * or {@link java.util.Map}, then a separate {@link ParameterErrors} is created - * for each element that has errors. In that case, the properties - * {@link #getContainer() container}, {@link #getContainerIndex() containerIndex}, - * and {@link #getContainerKey() containerKey} provide additional context. - * * @author Rossen Stoyanchev * @since 6.1 */ @@ -45,15 +39,6 @@ public class ParameterErrors extends ParameterValidationResult implements Errors private final Errors errors; - @Nullable - private final Object container; - - @Nullable - private final Integer containerIndex; - - @Nullable - private final Object containerKey; - /** * Create a {@code ParameterErrors}. @@ -62,45 +47,8 @@ public class ParameterErrors extends ParameterValidationResult implements Errors MethodParameter parameter, @Nullable Object argument, Errors errors, @Nullable Object container, @Nullable Integer index, @Nullable Object key) { - super(parameter, argument, errors.getAllErrors()); + super(parameter, argument, errors.getAllErrors(), container, index, key); this.errors = errors; - this.container = container; - this.containerIndex = index; - this.containerKey = key; - } - - - /** - * When {@code @Valid} is declared on a container of elements such as - * {@link java.util.Collection}, {@link java.util.Map}, - * {@link java.util.Optional}, and others, this method returns the container - * of the validated {@link #getArgument() argument}, while - * {@link #getContainerIndex()} and {@link #getContainerKey()} provide - * information about the index or key if applicable. - */ - @Nullable - public Object getContainer() { - return this.container; - } - - /** - * When {@code @Valid} is declared on an indexed container of elements such as - * {@link List} or array, this method returns the index of the validated - * {@link #getArgument() argument}. - */ - @Nullable - public Integer getContainerIndex() { - return this.containerIndex; - } - - /** - * When {@code @Valid} is declared on a container of elements referenced by - * key such as {@link java.util.Map}, this method returns the key of the - * validated {@link #getArgument() argument}. - */ - @Nullable - public Object getContainerKey() { - return this.containerKey; } diff --git a/spring-context/src/main/java/org/springframework/validation/method/ParameterValidationResult.java b/spring-context/src/main/java/org/springframework/validation/method/ParameterValidationResult.java index 614b0b41889..aeb0f3493bc 100644 --- a/spring-context/src/main/java/org/springframework/validation/method/ParameterValidationResult.java +++ b/spring-context/src/main/java/org/springframework/validation/method/ParameterValidationResult.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -35,6 +35,12 @@ import org.springframework.util.ObjectUtils; * {@link ParameterErrors}. * * + *

When the method parameter is a container such as a {@link List}, array, + * or {@link java.util.Map}, then a separate {@link ParameterValidationResult} + * is created for each element with errors. In that case, the properties + * {@link #getContainer() container}, {@link #getContainerIndex() containerIndex}, + * and {@link #getContainerKey() containerKey} provide additional context. + * * @author Rossen Stoyanchev * @since 6.1 */ @@ -47,18 +53,43 @@ public class ParameterValidationResult { private final List resolvableErrors; + @Nullable + private final Object container; + + @Nullable + private final Integer containerIndex; + + @Nullable + private final Object containerKey; + /** * Create a {@code ParameterValidationResult}. */ public ParameterValidationResult( - MethodParameter param, @Nullable Object arg, Collection errors) { + MethodParameter param, @Nullable Object arg, Collection errors, + @Nullable Object container, @Nullable Integer index, @Nullable Object key) { Assert.notNull(param, "MethodParameter is required"); Assert.notEmpty(errors, "`resolvableErrors` must not be empty"); this.methodParameter = param; this.argument = arg; this.resolvableErrors = List.copyOf(errors); + this.container = container; + this.containerIndex = index; + this.containerKey = key; + } + + /** + * Create a {@code ParameterValidationResult}. + * @deprecated in favor of + * {@link ParameterValidationResult#ParameterValidationResult(MethodParameter, Object, Collection, Object, Integer, Object)} + */ + @Deprecated(since = "6.1.3", forRemoval = true) + public ParameterValidationResult( + MethodParameter param, @Nullable Object arg, Collection errors) { + + this(param, arg, errors, null, null, null); } @@ -100,6 +131,39 @@ public class ParameterValidationResult { return this.resolvableErrors; } + /** + * When {@code @Valid} is declared on a container of elements such as + * {@link java.util.Collection}, {@link java.util.Map}, + * {@link java.util.Optional}, and others, this method returns the container + * of the validated {@link #getArgument() argument}, while + * {@link #getContainerIndex()} and {@link #getContainerKey()} provide + * information about the index or key if applicable. + */ + @Nullable + public Object getContainer() { + return this.container; + } + + /** + * When {@code @Valid} is declared on an indexed container of elements such as + * {@link List} or array, this method returns the index of the validated + * {@link #getArgument() argument}. + */ + @Nullable + public Integer getContainerIndex() { + return this.containerIndex; + } + + /** + * When {@code @Valid} is declared on a container of elements referenced by + * key such as {@link java.util.Map}, this method returns the key of the + * validated {@link #getArgument() argument}. + */ + @Nullable + public Object getContainerKey() { + return this.containerKey; + } + @Override public boolean equals(@Nullable Object other) { @@ -111,7 +175,9 @@ public class ParameterValidationResult { } ParameterValidationResult otherResult = (ParameterValidationResult) other; return (getMethodParameter().equals(otherResult.getMethodParameter()) && - ObjectUtils.nullSafeEquals(getArgument(), otherResult.getArgument())); + ObjectUtils.nullSafeEquals(getArgument(), otherResult.getArgument()) && + ObjectUtils.nullSafeEquals(getContainerIndex(), otherResult.getContainerIndex()) && + ObjectUtils.nullSafeEquals(getContainerKey(), otherResult.getContainerKey())); } @Override @@ -119,14 +185,18 @@ public class ParameterValidationResult { int hashCode = super.hashCode(); hashCode = 29 * hashCode + getMethodParameter().hashCode(); hashCode = 29 * hashCode + ObjectUtils.nullSafeHashCode(getArgument()); + hashCode = 29 * hashCode + ObjectUtils.nullSafeHashCode(getContainerIndex()); + hashCode = 29 * hashCode + ObjectUtils.nullSafeHashCode(getContainerKey()); return hashCode; } @Override public String toString() { - return "Validation results for method parameter '" + this.methodParameter + - "': argument [" + ObjectUtils.nullSafeConciseToString(this.argument) + "]; " + - getResolvableErrors(); + return getClass().getSimpleName() + " for " + this.methodParameter + + ", argument value '" + ObjectUtils.nullSafeConciseToString(this.argument) + "'," + + (this.containerIndex != null ? "containerIndex[" + this.containerIndex + "]," : "") + + (this.containerKey != null ? "containerKey['" + this.containerKey + "']," : "") + + " errors: " + getResolvableErrors(); } } diff --git a/spring-context/src/test/java/org/springframework/validation/beanvalidation/MethodValidationAdapterTests.java b/spring-context/src/test/java/org/springframework/validation/beanvalidation/MethodValidationAdapterTests.java index a63b28b6db0..0a0febd29eb 100644 --- a/spring-context/src/test/java/org/springframework/validation/beanvalidation/MethodValidationAdapterTests.java +++ b/spring-context/src/test/java/org/springframework/validation/beanvalidation/MethodValidationAdapterTests.java @@ -161,7 +161,7 @@ class MethodValidationAdapterTests { } @Test - void validateListArgument() { + void validateBeanListArgument() { MyService target = new MyService(); Method method = getMethod(target, "addPeople"); @@ -195,6 +195,24 @@ class MethodValidationAdapterTests { }); } + @Test + void validateValueListArgument() { + MyService target = new MyService(); + Method method = getMethod(target, "addHobbies"); + + testArgs(target, method, new Object[] {List.of(" ")}, ex -> { + + assertThat(ex.getAllValidationResults()).hasSize(1); + + assertValueResult(ex.getValueResults().get(0), 0, " ", List.of(""" + org.springframework.context.support.DefaultMessageSourceResolvable: \ + codes [NotBlank.myService#addHobbies.hobbies,NotBlank.hobbies,NotBlank.java.util.List,NotBlank]; \ + arguments [org.springframework.context.support.DefaultMessageSourceResolvable: \ + codes [myService#addHobbies.hobbies,hobbies]; \ + arguments []; default message [hobbies]]; default message [must not be blank]""")); + }); + } + private void testArgs(Object target, Method method, Object[] args, Consumer consumer) { consumer.accept(this.validationAdapter.validateArguments(target, method, null, args, new Class[0])); } @@ -250,6 +268,9 @@ class MethodValidationAdapterTests { public void addPeople(@Valid List people) { } + public void addHobbies(List<@NotBlank String> hobbies) { + } + } diff --git a/spring-web/src/test/java/org/springframework/web/method/support/HandlerMethodValidationExceptionTests.java b/spring-web/src/test/java/org/springframework/web/method/support/HandlerMethodValidationExceptionTests.java index 59bf93402b2..03a7604f4ef 100644 --- a/spring-web/src/test/java/org/springframework/web/method/support/HandlerMethodValidationExceptionTests.java +++ b/spring-web/src/test/java/org/springframework/web/method/support/HandlerMethodValidationExceptionTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -128,7 +128,7 @@ public class HandlerMethodValidationExceptionTests { } else { MessageSourceResolvable error = new DefaultMessageSourceResolvable("Size"); - return new ParameterValidationResult(param, "123", List.of(error)); + return new ParameterValidationResult(param, "123", List.of(error), null, null, null); } }) .toList());