Nesse post continuaremos falando da JSR-303 Bean Validation, falare-mos sobre validação Cross-Field, haverão situações onde será necessário validar um campo, dependendo do valor de outro, também podemos tirar vantagem da JSR-303 nesse cenário, usaremos os validadores de classe

# Validação baseada em múltiplos campos

Veja o seguinte validator/anotação, usado para verificar a unicidade do campo userName na classe User

@Entity
@Table(name = "USER")
public class User implements Serializable {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;
        
    @NotBlank
    private String fullName;
        
    @NotBlank
    private String preferredName;
    
    @UniqueUsername
    @NotBlank
    private String userName;

    // Getters/Setters

}
@Documented
@Constraint(validatedBy = UniqueUsernameValidator.class)
@Target({ ElementType.PARAMETER, ElementType.FIELD, ElementType.ANNOTATION_TYPE })
@Retention(RetentionPolicy.RUNTIME)
public @interface UniqueUsername {

    String message() default "already in use";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};

}
@Component
public class UniqueUsernameValidator implements ConstraintValidator<UniqueUsername,String> {

    private UserRepository userRepository;

    @Autowired
    public UniqueUsernameValidator(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if(!isEmpty(value)){
            return !userRepository.existsByUserName(value);
        }
        return true;
    }
}

Consegue ver o problema?! Se usarmos a anotação no atributo userName o validador recebera apenas o valor do campo, e no caso de uma operação de atualização, podemos ter uma falsa falha na validação, no caso de informar o mesmo userName para o registro que desejamos atualizar. Veja:

{
   "id": 1,
   //other fields

   "userName": "sameExistingUsername"
}

Se enviarmos esse payload para o endpoint de atualização, o campo userName lançará um erro de ConstraintViolation, pois, o método userRepository.existsByUserName(value) vai retornar true. O problema aqui é que não levamos em consideração o id quando ele existe, para isso, vamos alterar nosso validador

@Component
public class UniqueUsernameValidator implements ConstraintValidator<UniqueUsername, User> {

    private UserRepository userRepository;

    @Autowired
    public UniqueUsernameValidator(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public boolean isValid(User value, ConstraintValidatorContext context) {
        if(!isEmpty(value)){
            boolean exists = false;
            if(value.getId() != null) {
                exists = userRepository.existsByUserNameEqualsAndIdNot(value.getUserName() , value.getId());
            }else {
                exists = userRepository.existsByUserName(value.getUserName());
            }
            if(exists){
                context.disableDefaultConstraintViolation();
                context.buildConstraintViolationWithTemplate(UniqueUsername.MESSAGE)
                        .addPropertyNode("userName")
                        .addConstraintViolation();
                return false;
            }
        }
        return true;
    }
}

Agora o validador espera a classe User, ao invés de String, com isso teremos acesso a todos os atributos em User, e conseguimos fazer nossa validação como bem entendermos.

Como agora estamos validando a classe, e queremos marcar um campo específico como errado, usamos context.disableDefaultConstraintViolation() para desabilitar o comportamento padrão, e usamos:

// iniciamos o builder com a mensagem padrão

context.buildConstraintViolationWithTemplate(UniqueUsername.MESSAGE) 
        .addPropertyNode("userName")// Marcamos o atributo violado

        .addConstraintViolation(); // adicionamos a violação manualmente

Para que tudo funcione como esperado, também temos que atualizar nossa anotação:

@Documented
@Constraint(validatedBy = UniqueUsernameValidator.class)
@Target({ ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
public @interface UniqueUsername {

    String MESSAGE = "already in use";
    String message() default MESSAGE;
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};

}

Com isso, sinalizamos que essa anotação só pode ser usada a nível de classe. Por fim temos nosso modelo

@UniqueUsername
@Entity
@Table(name = "USER")
public class User implements Serializable {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;

    @NotBlank
    private String fullName;

    @NotBlank
    private String preferredName;

    @NotBlank
    private String userName;
    
    // Getters/Setters

}

O projeto completo no seguinte link