Description
The title is pretty vague, sorry - couldn't think of something better. There is plenty of detail here to make up for that though.
Spring 6.1.x brought method bean validation (#30645) - as part of the feedback to this enhancement, I provided a PR #31530 to attempt to refine this further.
In hindsight, the solution I provided also has its problems. Essentially, all the issues stem from the use of the leaf bean - whose behaviour is, unfortunately, not as I had understood at the time. As I have come to release, the change I submitted had some unintended side effects that only became clear as I fully started migrating to Spring Boot 3.2.
1. Cascaded validation can fail
In cases where a validation error occurs on a nested bean, SpringValidatorAdopt#getRejectedValue
may attempt to get the raw value for the property. As a result, one will see an error like JSR-303 validated property 'professor.name' does not have a corresponding accessor for Spring data binding
. This is because the code attempts to walk the full property path, starting from the provided bean. Unfortunately, the leaf bean points to the nested bean with the cascaded validation, not the initial bean where validation started. Thus, the property path is invalid from this context (Go figure, despite giving the exact bean in question, it is no good). The JSR error is not thrown in all cases, as paths which include collections with non-indexed/keyed access are fine because the getRejectedValue
method explicitly skips walking paths with []
in them - instead, it uses the value on the violation directly - because there is no way to access the element in question.
As an example, consider the following code:
public void registerCourse(@Valid Course course) {
}
private record Course(@NotBlank String title, @Valid Set<Person> students, @Valid Person professor) {
}
private record Person(@Size(min = 1, max = 10) String name, List<@NotBlank String> hobbies) {
}
The failure can be caused by having a validation error on the professor parameter - say the name, as my error above uses.
After looking at the information available in MethodValidationAdaptor
, I'm not sure how to get that root bean for all reasonable data types - specifically types like Set
where there is no direct way to access the element. The leaf bean was supposed to solve that issue. It is unfortunate that the ConstraintViolation
doesn't expose this either, given that it had to have known the bean to produce the path nodes in the first place. At this point, I'm not sure where else to try drawing this information from. I wonder if it is worth looking at the downstream constraint violating processing to work this behaviour in there instead if nothing better comes up.
The next two are more minor in my opinion. I found them accidentally while debugging rather than because they strictly posed a problem.
2. Container element result argument incorrect when validating non-field object error
Since the leaf bean appears to represent the last bean accessed, the opposite problem to 1 can happen. Rather than the leaf bean being too precise, it isn't precise enough, though here it doesn't cause an error. Essentially, in the case of a situation like
public void addHobbies(@Valid Set<String> hobbies) {
}
the leafBean
will represent the service/controller/etc. that has the parameter since there is no other bean to set the leaf to. As a result, all violations will be under the same ParameterErrors
, rather than having a ParameterErrors
for each element with violations. Because of this, it should only impact types which rely on the leaf bean to provide the unique side of the BeanResultKey
. Even then, it only means that the documented behaviour is broken. All the violations are there, so no information is lost - the structure is just a bit off.
All container types are impacted in some way, however, as the argument set on the parameter error will be incorrect, regardless of whether the errors are split by element or not.
I haven't had a chance to dig too deep into this one, as a solution for problem 1 may address this too. However, I did consider a few things:
- Could be possible to check if the next node is of type
CONTAINER_ELEMENT
, and then simply pass the invalid value as the bean- Unsure if this would misbehave with cascaded validation, but it does seem like it might be fragile
- Also, for basic types like
String
, is it fine to be lumping these under the bean validation umbrella? Even if not, can an invalid string and say a null passed for a custom Bean type be reasonably distinguished?
3. ParameterErrors may incorrectly set the container parameter
In the case of cascaded validation the condition here
this.container = (arg != null && !arg.equals(leafBean) ? arg : null);
can be evaluated as true, even if there is no container. Once again, the doing of my friend the leaf bean.
Using the same example code from problem 1, if one of the student elements has a validation error when calling registerCourse
, the leaf bean will be for the Person
while the arg is the Course
. The Course
will then be set as a container even though it isn't one. (Note this example only works because of the Set
type, which sidesteps problem 1.)
In this case, there could be a solution that relies on neither the leaf bean nor the arg class. Path.Node
has a few subtypes (BeanNode
, ContainerElementNode
and PropertyNode
) which expose a method to check if a node is in a container. I had some success with something like:
private static boolean isContainerElement(Path.Node node) {
return switch (node.getKind()) {
case BEAN -> node.as(Path.BeanNode.class).getContainerClass() != null;
case PROPERTY -> node.as(Path.PropertyNode.class).getContainerClass() != null;
case CONTAINER_ELEMENT -> true;
default -> false;
};
}
I've added some additional tests on my forked repo to exercise this and ensure the container is properly set, so I could provide that pretty easily. That said, I am making some assumption that since the others don't provide this getContainerClass
, it is reasonable to expect them not to be container elements.
If it is okay, I'd like your perspective on this @rstoyanchev, since we discussed the original change I provided. I'd for sure like to continue contributing - though I clearly need to spend more time to get more context around any changes to avoid a mishap like this again.