12

A little greedy question here, hope this one could also help others who want to know more about annotation validation

I am currently studying Spring, and for now, I am planning to try out the customize annotated validation.

I have searched a lot and now I know there are mainly two kinds of validations, one is used for the controller, and the other is the annotation method using @Valid

So here's my scenario: Suppose I have two or more fields which can be null when they are ALL NULL. But only when one of those fields contains any value except an empty string, those fields are required to have input. And I had two ideas but didn't know how to implement them correctly.

Here's the Class Example:

public class Subscriber {
    private String name;
    private String email;
    private Integer age;
    private String phone;
    private Gender gender;
    private Date birthday;
    private Date confirmBirthday;
    private String birthdayMessage;
    private Boolean receiveNewsletter;

    //Getter and Setter
}

Suppose I want that the birthday and confirmBirthday field need to be both null or the oppose, I may want to annotate them using one annotation for each of them and looks like this:

public class Subscriber {
    private String name;
    private String email;
    private Integer age;
    private String phone;
    private Gender gender;

    @NotNullIf(fieldName="confirmBirthday")
    private Date birthday;

    @NotNullIf(fieldName="birthday")
    private Date confirmBirthday;

    private String birthdayMessage;
    private Boolean receiveNewsletter;

    //Getter and Setter
}

So i do need to create the validation Annotation like this:

@Documented
@Constraint(validatedBy = NotNullIfConstraintValidator.class)
@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.METHOD, ElementType.FIELD })
public @interface NotNullIf {

    String fieldName();

    String message() default "{NotNullIf.message}";
    Class<?>[] group() default {};
    Class<? extends Payload>[] payload() default {};
}

And After that i will need to create the Validator itself:

public class NotNullIfConstraintValidator implements ConstraintValidator<NotNullIf, String>{

    private String fieldName;

    public void initialize(NotNullIf constraintAnnotation) {
        fieldName = constraintAnnotation.fieldName();
    }

    public boolean isValid(String value, ConstraintValidatorContext context) {
        if(value == null) {
            return true;
        };
        //TODO Validation
        return false;
    }

}

So how can it be achievable?

For another idea using the same Class as an example which said that i want birthday, confirmBirthday and birthdayMessdage can only be null or the oppose at the same time. I may require to use the class annotated validation this time for cross-field validation.

Here's how i suppose to annotate the class:

@NotNullIf(fieldName={"birthday", "confirmBirthday", "birthdayMessage"})
public class Subscriber {
    //Those field same as the above one
}

So when one of that field is not null, the rest of them also needs to be entered on the client size. Is it Possible?

I have read this article: How to access a field which is described in annotation property

But I still confusing on how the annotation validation works from those elements I listed above. Maybe I need some detail explanation on that code or even worse I may need some basic concept inspection.

Please Help!

Sam
  • 376
  • 3
  • 13
Hyperventilate
  • 145
  • 1
  • 2
  • 7

2 Answers2

21

For this you can use a type level annotation only because a field level annotation has no access to other fields!

I did something similar to allow a choice validation (exactly one of a number of properties has to be not null). In your case the @AllOrNone annotation (or whatever name you prefer) would need an array of field names and you will get the whole object of the annotated type to the validator:

@Target(ElementType.TYPE)
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = AllOrNoneValidator.class)
public @interface AllOrNone {
    String[] value();

    String message() default "{AllOrNone.message}";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

public class AllOrNoneValidator implements ConstraintValidator<AllOrNone, Object> {
    private static final SpelExpressionParser PARSER = new SpelExpressionParser();
    private String[] fields;

    @Override
    public void initialize(AllOrNone constraintAnnotation) {
        fields = constraintAnnotation.value();
    }

    @Override
    public boolean isValid(Object value, ConstraintValidatorContext context) {
        long notNull = Stream.of(fields)
                .map(field -> PARSER.parseExpression(field).getValue(value))
                .filter(Objects::nonNull)
                .count();
        return notNull == 0 || notNull == fields.length;
    }
}

(As you said you use Spring I used SpEL to allow even nested fields access)

Now you can annotate your Subscriber type:

@AllOrNone({"birthday", "confirmBirthday"})
public class Subscriber {
    private String name;
    private String email;
    private Integer age;
    private String phone;
    private Gender gender;
    private Date birthday;
    private Date confirmBirthday;
    private String birthdayMessage;
    private Boolean receiveNewsletter;
}
Arne Burmeister
  • 20,046
  • 8
  • 53
  • 94
  • Is there anything else apart from this that we need to do in order to start using the custom annotation? – Tushar Banne Dec 17 '17 at 06:53
  • @TusharBanne if you have a running validation framework like hibernate validator in place, no – Arne Burmeister Dec 17 '17 at 09:51
  • Is there a way to validate the arguments being passed into @AllOrNone are field names that exist at compile time? @AllOrNone({"birthdayX", "confirmBirthdayZ"}) will compile, but will bomb at runtime. – Joseph Fitzgerald May 01 '20 at 18:36
  • @JosephFitzgerald not really, but I would recommend a partial integration test like a Spring Mock MVC Test if the validated type is a request body or a validator test using the real annotated type – Arne Burmeister May 01 '20 at 21:27
  • Actually @ArneBurmeister, you can! If you make your AllOrNoneValidator extend AbstractProcessor. You'll override the init method to grab the messager (I didn't misspell it). Then you'll override the process method and it's kind of complicated, but you can test that the field names match a field in the object. – Joseph Fitzgerald May 06 '20 at 14:03
0

Consider adding compile-time validation for the field names. For example, in @Arne answer the strings "birthday" and "confirmBirthday" are not guaranteed to match actual field names at compile time. If you want to add that functionality, here's an example from my code for a slightly different example that assumes there are exactly two fields. The purpose is to assert that two fields are ordered... For example, it could be used for "beginDate" and "endDate".

public class OrderedValidator extends AbstractProcessor implements ConstraintValidator<Ordered, Object>
{
    private String field1;
    private String field2;

    private Messager messager;

    public void initialize(Ordered constraintAnnotation)
    {
        this.field1 = constraintAnnotation.field1();
        this.field2 = constraintAnnotation.field2();
    }

    @Override
    public synchronized void init(ProcessingEnvironment processingEnv)
    {
        super.init(processingEnv);
        messager = processingEnv.getMessager();
    }

    @SuppressWarnings("unchecked")
    public boolean isValid(Object value, ConstraintValidatorContext context)
    {
        Object field1Value = new BeanWrapperImpl(value).getPropertyValue(field1);
        Object field2Value = new BeanWrapperImpl(value).getPropertyValue(field2);

        boolean valid = true;

        if (field1Value != null && field2Value != null)
        {
            if (field1Value.getClass().equals(field2Value.getClass()))
            {
                valid = ((Comparable) field1Value).compareTo((Comparable) field2Value) <= 0;
            }
        }

        return valid;
    }

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv)
    {
        for (Element annotatedElement : roundEnv.getElementsAnnotatedWith(Ordered.class))
        {
            if (annotatedElement.getKind() != ElementKind.CLASS)
            {
                messager.printMessage(Diagnostic.Kind.ERROR, "Only classes can be annotated with " + Ordered.class.getSimpleName());
                return true;
            }

            TypeElement typeElement = (TypeElement) annotatedElement;

            List<? extends Element> elements = typeElement.getEnclosedElements();
            boolean field1Found = false;
            boolean field2Found = false;
            for (Element e : elements)
            {
                if (e.getKind() == ElementKind.FIELD && field1 != null && field1.equals(e.getSimpleName()))
                {
                    field1Found = true;
                }
                else if (e.getKind() == ElementKind.FIELD && field2 != null && field2.equals(e.getSimpleName()))
                {
                    field2Found = true;
                }
            }

            if (field1 != null && !field1Found)
            {
                messager.printMessage(Diagnostic.Kind.ERROR, "Could not find field named " + field1);
                return true;
            }

            if (field2 != null && !field2Found)
            {
                messager.printMessage(Diagnostic.Kind.ERROR, "Could not find field named " + field2);
                return true;
            }
        }

        return false;
    }
}
  • Hi @Joseph Fitzgerald, your answer seems more generic/good. Can you please make it complete, ie add a domain object e.g Person/subscriber, add annotation/usage. Thanks – Espresso May 30 '23 at 17:38