Issue description
When using a wrapper type such as Optional and unwrapping a constraints on a given field (i.e using payload = Unwrapping.Unwrap.class
) the behaviour I would expect is that the generic information of the type unwrapped is preserved and used for matching a validator, but this does not seem to be the case. I.e when an Optional<List<String>>
is unwrapped the type used to find a matching validator is the raw List
instead of List<String>
.
Example
Here is an example to better showcase this issue:
Assume a custom constraint used to validate that a list of the string is sorted alphabetically:
@Target({FIELD, METHOD, PARAMETER, ANNOTATION_TYPE, TYPE_USE})
@Retention(RUNTIME)
@Constraint(validatedBy = {})
public @interface AlphabeticallyOrderedList {
String message() default "The list must be sorted alphabetically!";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
Assume a dto with the following structure:
public record TestDto(@AlphabeticallyOrderedList(payload = Unwrapping.Unwrap.class) Optional<List<String>> listFieldToUnwrap) {}
And finally the following validator for @AlphabeticallyOrderedList
(note that we assume the existence of a utility method which can use to validate that a list of string is sorted alphabetically and the method has the following signature: private static boolean validateStringListIsAlphabeticallySorted(List<String> list)
):
public static class AlphabeticallyOrderedListValidatorWithGenerics implements ConstraintValidator<AlphabeticallyOrderedList, List<String>> {
@Override
public boolean isValid(List<String> list, ConstraintValidatorContext context) {
return validateStringListIsAlphabeticallySorted(list);
}
}
Now if try to use this validator to validate TestDto
validation will fail as hibernate validator will first unwrap listFieldToUnwrap
but while doing so it erases the generic type information. Therefore when attempting to validate @AlphabeticallyOrderedList
it will look for a validator targeting List
while the one which was registered targets List<String>
. Here is a test which showcases what happens:
@Test
void testDtoUnwrappingContainerTypesLeadsToExceptionWhenValidatorUsesGenerics() {
// setup validator using generics when validating @AlphabeticallyOrderedList
HibernateValidatorConfiguration configuration = Validation.byProvider(HibernateValidator.class).configure();
ConstraintMapping constraintMapping = configuration.createConstraintMapping();
constraintMapping.constraintDefinition(AlphabeticallyOrderedList.class).validatedBy(AlphabeticallyOrderedListValidatorWithGenerics.class);
configuration.addMapping(constraintMapping);
Validator validator = configuration.buildValidatorFactory().getValidator();
// no matter if the dto is valid or invalid the validation will fail with an exception
List<String> validList = List.of("A", "B");
Assertions.assertThrows(UnexpectedTypeException.class, () -> validator.validate(new TestDto(Optional.of(validList))));
List<String> invalidList = List.of("B", "A");
Assertions.assertThrows(UnexpectedTypeException.class, () -> validator.validate(new TestDto(Optional.of(invalidList))));
}
The problem becomes clear as validation can work when the validator for @AlphabeticallyOrderedList
targets a raw List
instead of a List<String>
. This can be seen with this example:
public static class AlphabeticallyOrderedListValidatorWithRawTypes implements ConstraintValidator<AlphabeticallyOrderedList, List> {
@Override
public boolean isValid(List list, ConstraintValidatorContext context) {
return validateStringListIsAlphabeticallySorted((List<String>)list);
}
}
@Test
void testDtoUnwrappingContainerTypesWorksWhenValidatorUsesRawTypes() {
// setup validator using raw types when validating @AlphabeticallyOrderedList
HibernateValidatorConfiguration configuration = Validation.byProvider(HibernateValidator.class).configure();
ConstraintMapping constraintMapping = configuration.createConstraintMapping();
constraintMapping.constraintDefinition(AlphabeticallyOrderedList.class).validatedBy(AlphabeticallyOrderedListValidatorWithRawTypes.class);
configuration.addMapping(constraintMapping);
Validator validator = configuration.buildValidatorFactory().getValidator();
List<String> validList = List.of("A", "B");
// validation is correctly performed and dto results valid
Assertions.assertEquals(0, validator.validate(new TestDto(Optional.of(validList))).size());
List<String> invalidList = List.of("B", "A");
// validation is correctly performed and dto results invalid
Assertions.assertEquals(1, validator.validate(new TestDto(Optional.of(invalidList))).size());
}
Cause of the issue
The underlying cause of the issue seems to be that the generic information is erased when invoking MetaConstraints#getWrappedValueType
after that the container type is unwrapped. This behaviour seems incorrect to me also because it’s inconsistent with how types are handled in the absence of any ValueExtractor
(i.e generics information is preserved and used to find a matching Validator
for a given constraint). Should this be fixed in the library by propagating the generics or is there some other way which I did not notice in which I can always preserve the underlying generic information when unwrapping container types?