Skip to content

Commit d7ce13c

Browse files
committed
Revert use of leafBean in MethodValidationAdapter
The goal for #31530 was to support bean validation on Set and other method parameters that are containers of value(s) for which there is a registered Jakarta Validation ValueExtractor. Unfortunately, bean validation does not expose the unwrapped value for a Path.Node, which we need for a method parameter in order to create a BindingResult for the specific bean within the container, and the leafBean that we tried to use is really the node at the very bottom of the property path (i.e. not what we need). This change removes the use of beanLeaf, restores the logic as it was before, adds support for arrays, and a new test class for scenarios with cascaded violations. See gh-31746
1 parent 7b9037b commit d7ce13c

File tree

4 files changed

+290
-71
lines changed

4 files changed

+290
-71
lines changed

spring-context/src/main/java/org/springframework/validation/beanvalidation/MethodValidationAdapter.java

+41-26
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
import org.springframework.core.ParameterNameDiscoverer;
5050
import org.springframework.core.annotation.AnnotationUtils;
5151
import org.springframework.lang.Nullable;
52+
import org.springframework.util.Assert;
5253
import org.springframework.util.ClassUtils;
5354
import org.springframework.util.function.SingletonSupplier;
5455
import org.springframework.validation.BeanPropertyBindingResult;
@@ -302,7 +303,7 @@ private MethodValidationResult adaptViolations(
302303
Function<Integer, Object> argumentFunction) {
303304

304305
Map<MethodParameter, ParamResultBuilder> paramViolations = new LinkedHashMap<>();
305-
Map<BeanResultKey, BeanResultBuilder> beanViolations = new LinkedHashMap<>();
306+
Map<Path.Node, BeanResultBuilder> beanViolations = new LinkedHashMap<>();
306307

307308
for (ConstraintViolation<Object> violation : violations) {
308309
Iterator<Path.Node> itr = violation.getPropertyPath().iterator();
@@ -328,10 +329,37 @@ else if (node.getKind().equals(ElementKind.RETURN_VALUE)) {
328329
.addViolation(violation);
329330
}
330331
else {
331-
Object leafBean = violation.getLeafBean();
332-
BeanResultKey key = new BeanResultKey(node, leafBean);
332+
// If the argument is a container of elements, we need the specific element,
333+
// but the only option is to check for a parent container index/key in the
334+
// next part of the property path.
335+
Path.Node paramNode = node;
336+
node = itr.next();
337+
338+
Object bean;
339+
Object container;
340+
Integer containerIndex = node.getIndex();
341+
Object containerKey = node.getKey();
342+
if (containerIndex != null && arg instanceof List<?> list) {
343+
bean = list.get(containerIndex);
344+
container = list;
345+
}
346+
else if (containerIndex != null && arg instanceof Object[] array) {
347+
bean = array[containerIndex];
348+
container = array;
349+
}
350+
else if (containerKey != null && arg instanceof Map<?, ?> map) {
351+
bean = map.get(containerKey);
352+
container = map;
353+
}
354+
else {
355+
Assert.state(!node.isInIterable(), "No way to unwrap Iterable without index");
356+
bean = arg;
357+
container = null;
358+
}
359+
333360
beanViolations
334-
.computeIfAbsent(key, k -> new BeanResultBuilder(parameter, arg, itr.next(), leafBean))
361+
.computeIfAbsent(paramNode, k ->
362+
new BeanResultBuilder(parameter, bean, container, containerIndex, containerKey))
335363
.addViolation(violation);
336364
}
337365
break;
@@ -448,13 +476,16 @@ private final class BeanResultBuilder {
448476

449477
private final Set<ConstraintViolation<Object>> violations = new LinkedHashSet<>();
450478

451-
public BeanResultBuilder(MethodParameter param, @Nullable Object arg, Path.Node node, @Nullable Object leafBean) {
479+
public BeanResultBuilder(
480+
MethodParameter param, @Nullable Object bean, @Nullable Object container,
481+
@Nullable Integer containerIndex, @Nullable Object containerKey) {
482+
452483
this.parameter = param;
453-
this.bean = leafBean;
454-
this.container = (arg != null && !arg.equals(leafBean) ? arg : null);
455-
this.containerIndex = node.getIndex();
456-
this.containerKey = node.getKey();
457-
this.errors = createBindingResult(param, leafBean);
484+
this.bean = bean;
485+
this.container = container;
486+
this.containerIndex = containerIndex;
487+
this.containerKey = containerKey;
488+
this.errors = createBindingResult(param, this.bean);
458489
}
459490

460491
public void addViolation(ConstraintViolation<Object> violation) {
@@ -470,22 +501,6 @@ public ParameterErrors build() {
470501
}
471502

472503

473-
/**
474-
* Unique key for cascaded violations associated with a bean.
475-
* <p>The bean may be an element within a container such as a List, Set, array,
476-
* Map, Optional, and others. In that case the {@link Path.Node} represents
477-
* the container element with its index or key, if applicable, while the
478-
* {@link ConstraintViolation#getLeafBean() leafBean} is the actual
479-
* element instance. The pair should be unique. For example in a Set, the
480-
* node is the same but element instances are unique. In a List or Map the
481-
* node is further qualified by an index or key while element instances
482-
* may be the same.
483-
* @param node the path to the bean associated with the violation
484-
* @param leafBean the bean instance
485-
*/
486-
record BeanResultKey(Path.Node node, Object leafBean) { }
487-
488-
489504
/**
490505
* Default algorithm to select an object name, as described in
491506
* {@link #setObjectNameResolver(ObjectNameResolver)}.

spring-context/src/main/java/org/springframework/validation/method/ParameterErrors.java

+5-6
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,11 @@
3232
* {@link Errors#getAllErrors()}, but this subclass provides access to the same
3333
* as {@link FieldError}s.
3434
*
35-
* <p>When the method parameter is a container with multiple elements such as a
36-
* {@link List}, {@link java.util.Set}, array, {@link java.util.Map}, or others,
37-
* then a separate {@link ParameterErrors} is created for each element that has
38-
* errors. In that case, the {@link #getContainer() container},
39-
* {@link #getContainerIndex() containerIndex}, and {@link #getContainerKey() containerKey}
40-
* provide additional context.
35+
* <p>When the method parameter is a container such as a {@link List}, array,
36+
* or {@link java.util.Map}, then a separate {@link ParameterErrors} is created
37+
* for each element that has errors. In that case, the properties
38+
* {@link #getContainer() container}, {@link #getContainerIndex() containerIndex},
39+
* and {@link #getContainerKey() containerKey} provide additional context.
4140
*
4241
* @author Rossen Stoyanchev
4342
* @since 6.1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
/*
2+
* Copyright 2002-2023 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.validation.beanvalidation;
18+
19+
import java.lang.reflect.Method;
20+
import java.util.Collections;
21+
import java.util.List;
22+
import java.util.Map;
23+
24+
import jakarta.validation.Valid;
25+
import jakarta.validation.constraints.NotBlank;
26+
import jakarta.validation.constraints.Size;
27+
import org.junit.jupiter.api.Nested;
28+
import org.junit.jupiter.api.Test;
29+
30+
import org.springframework.lang.Nullable;
31+
import org.springframework.util.ClassUtils;
32+
import org.springframework.validation.FieldError;
33+
import org.springframework.validation.method.MethodValidationResult;
34+
import org.springframework.validation.method.ParameterErrors;
35+
36+
import static org.assertj.core.api.Assertions.assertThat;
37+
38+
/**
39+
* Test method validation scenarios with cascaded violations on different types
40+
* of method parameters and return values.
41+
*
42+
* @author Rossen Stoyanchev
43+
*/
44+
public class MethodValidationAdapterPropertyPathTests {
45+
46+
private static final Person validPerson = new Person("John");
47+
48+
private static final Person invalidPerson = new Person("Long John Silver");
49+
50+
private static final Class<?>[] HINTS = new Class<?>[0];
51+
52+
53+
private final MethodValidationAdapter validationAdapter = new MethodValidationAdapter();
54+
55+
56+
@Nested
57+
class ArgumentTests {
58+
59+
@Test
60+
void fieldOfObjectPropertyOfBean() {
61+
Method method = getMethod("addCourse");
62+
Object[] args = {new Course("CS 101", invalidPerson, Collections.emptyList())};
63+
64+
MethodValidationResult result =
65+
validationAdapter.validateArguments(new MyService(), method, null, args, HINTS);
66+
67+
assertThat(result.getAllErrors()).hasSize(1);
68+
ParameterErrors errors = result.getBeanResults().get(0);
69+
assertSingleFieldError(errors, 1, null, null, null, "professor.name", invalidPerson.name());
70+
}
71+
72+
@Test
73+
void fieldOfObjectPropertyOfListElement() {
74+
Method method = getMethod("addCourseList");
75+
List<Course> courses = List.of(new Course("CS 101", invalidPerson, Collections.emptyList()));
76+
77+
MethodValidationResult result = validationAdapter.validateArguments(
78+
new MyService(), method, null, new Object[] {courses}, HINTS);
79+
80+
assertThat(result.getAllErrors()).hasSize(1);
81+
ParameterErrors errors = result.getBeanResults().get(0);
82+
assertSingleFieldError(errors, 1, courses, 0, null, "professor.name", invalidPerson.name());
83+
}
84+
85+
@Test
86+
void fieldOfObjectPropertyOfListElements() {
87+
Method method = getMethod("addCourseList");
88+
List<Course> courses = List.of(
89+
new Course("CS 101", invalidPerson, Collections.emptyList()),
90+
new Course("CS 102", invalidPerson, Collections.emptyList()));
91+
92+
MethodValidationResult result = validationAdapter.validateArguments(
93+
new MyService(), method, null, new Object[] {courses}, HINTS);
94+
95+
assertThat(result.getAllErrors()).hasSize(2);
96+
for (int i = 0; i < 2; i++) {
97+
ParameterErrors errors = result.getBeanResults().get(i);
98+
assertThat(errors.getContainerIndex()).isEqualTo(i);
99+
assertThat(errors.getFieldError().getField()).isEqualTo("professor.name");
100+
}
101+
102+
}
103+
104+
@Test
105+
void fieldOfObjectPropertyUnderListPropertyOfListElement() {
106+
Method method = getMethod("addCourseList");
107+
Course cs101 = new Course("CS 101", invalidPerson, Collections.emptyList());
108+
Course cs201 = new Course("CS 201", validPerson, List.of(cs101));
109+
Course cs301 = new Course("CS 301", validPerson, List.of(cs201));
110+
List<Course> courses = List.of(cs301);
111+
Object[] args = {courses};
112+
113+
MethodValidationResult result =
114+
validationAdapter.validateArguments(new MyService(), method, null, args, HINTS);
115+
116+
assertThat(result.getAllErrors()).hasSize(1);
117+
ParameterErrors errors = result.getBeanResults().get(0);
118+
119+
assertSingleFieldError(errors, 1, courses, 0, null,
120+
"requiredCourses[0].requiredCourses[0].professor.name", invalidPerson.name());
121+
}
122+
123+
@Test
124+
void fieldOfObjectPropertyOfArrayElement() {
125+
Method method = getMethod("addCourseArray");
126+
Course[] courses = new Course[] {new Course("CS 101", invalidPerson, Collections.emptyList())};
127+
128+
MethodValidationResult result = validationAdapter.validateArguments(
129+
new MyService(), method, null, new Object[] {courses}, HINTS);
130+
131+
assertThat(result.getAllErrors()).hasSize(1);
132+
ParameterErrors errors = result.getBeanResults().get(0);
133+
assertSingleFieldError(errors, 1, courses, 0, null, "professor.name", invalidPerson.name());
134+
}
135+
136+
@Test
137+
void fieldOfObjectPropertyOfMapValue() {
138+
Method method = getMethod("addCourseMap");
139+
Map<String, Course> courses = Map.of("CS 101", new Course("CS 101", invalidPerson, Collections.emptyList()));
140+
141+
MethodValidationResult result = validationAdapter.validateArguments(
142+
new MyService(), method, null, new Object[] {courses}, HINTS);
143+
144+
assertThat(result.getAllErrors()).hasSize(1);
145+
ParameterErrors errors = result.getBeanResults().get(0);
146+
assertSingleFieldError(errors, 1, courses, null, "CS 101", "professor.name", invalidPerson.name());
147+
}
148+
149+
}
150+
151+
152+
@Nested
153+
class ReturnValueTests {
154+
155+
@Test
156+
void fieldOfObjectPropertyOfBean() {
157+
Method method = getMethod("getCourse");
158+
Course course = new Course("CS 101", invalidPerson, Collections.emptyList());
159+
160+
MethodValidationResult result =
161+
validationAdapter.validateReturnValue(new MyService(), method, null, course, HINTS);
162+
163+
assertThat(result.getAllErrors()).hasSize(1);
164+
ParameterErrors errors = result.getBeanResults().get(0);
165+
assertSingleFieldError(errors, 1, null, null, null, "professor.name", invalidPerson.name());
166+
}
167+
168+
@Test
169+
void fieldOfObjectPropertyOfListElement() {
170+
Method method = getMethod("addCourseList");
171+
List<Course> courses = List.of(new Course("CS 101", invalidPerson, Collections.emptyList()));
172+
173+
MethodValidationResult result = validationAdapter.validateArguments(
174+
new MyService(), method, null, new Object[] {courses}, HINTS);
175+
176+
assertThat(result.getAllErrors()).hasSize(1);
177+
ParameterErrors errors = result.getBeanResults().get(0);
178+
assertSingleFieldError(errors, 1, courses, 0, null, "professor.name", invalidPerson.name());
179+
}
180+
181+
}
182+
183+
184+
private void assertSingleFieldError(
185+
ParameterErrors errors, int errorCount,
186+
@Nullable Object container, @Nullable Integer index, @Nullable Object key,
187+
String field, Object rejectedValue) {
188+
189+
assertThat(errors.getErrorCount()).isEqualTo(errorCount);
190+
assertThat(errors.getErrorCount()).isEqualTo(1);
191+
assertThat(errors.getContainer()).isEqualTo(container);
192+
assertThat(errors.getContainerIndex()).isEqualTo(index);
193+
assertThat(errors.getContainerKey()).isEqualTo(key);
194+
195+
FieldError fieldError = errors.getFieldError();
196+
assertThat(fieldError).isNotNull();
197+
assertThat(fieldError.getField()).isEqualTo(field);
198+
assertThat(fieldError.getRejectedValue()).isEqualTo(rejectedValue);
199+
}
200+
201+
202+
private static Method getMethod(String methodName) {
203+
return ClassUtils.getMethod(MyService.class, methodName, (Class<?>[]) null);
204+
}
205+
206+
207+
@SuppressWarnings("unused")
208+
private static class MyService {
209+
210+
public void addCourse(@Valid Course course) {
211+
}
212+
213+
public void addCourseList(@Valid List<Course> courses) {
214+
}
215+
216+
public void addCourseArray(@Valid Course[] courses) {
217+
}
218+
219+
public void addCourseMap(@Valid Map<String, Course> courses) {
220+
}
221+
222+
@Valid
223+
public Course getCourse(Course course) {
224+
throw new UnsupportedOperationException();
225+
}
226+
227+
@Valid
228+
public List<Course> getCourseList() {
229+
throw new UnsupportedOperationException();
230+
}
231+
232+
}
233+
234+
235+
private record Course(@NotBlank String title, @Valid Person professor, @Valid List<Course> requiredCourses) {
236+
}
237+
238+
239+
@SuppressWarnings("unused")
240+
private record Person(@Size(min = 1, max = 5) String name) {
241+
}
242+
243+
}

0 commit comments

Comments
 (0)