Java + Spring + Jakarta Validation. Self-written “addon” for Entity validation via DB and EntityManager

This version is still a draft, “on the knee” and will be developed. Criticism and recommendations are warmly welcomed. For now, it is important for me to understand whether the community has a demand for something similar and whether it makes sense to somehow publicly develop this solution.

It is clear that the article is intended for those who are already familiar with the same Jakarta Validation.

What is all this for?

Let's say we write our own CRUD for some entity with an abundance of connections.

What will the validation process consist of?

Conventionally, it can be divided into two parts.

The first and simplest is validation at the level of input data only. All sorts of checks for NotNull, NotBlank, possibly some Regex, etc.

The second, slightly more complicated, is validation at the junction of input data and the current state of the database, not intersecting with the first (therefore, notnull-constraints, for example, can be ignored here – they can be filtered out at the first stage). Here, the following most typical operations could be distinguished:

  • Checking the field for uniqueness when creating a new entity (there should be no records with the value X of field N at the time of saving).

  • Checking the field for uniqueness when updating an entity (when updating, there should be only one record with value X of field N).

  • Checking the existence of the specified FK connections.

  • Checking the existence of the entity itself in case of an update (usually done by its ID).

  • Perhaps checking unmodified fields for updating, i.e. if the field is unchangeable, but in the input data we are trying to change it – an exception.

I hope I haven't forgotten anything)

Validation (in my experience) in Spring applications is either written by yourself (creating, for example, a separate layer of self-written validators in the “if-else” style), or they still use the jakarta solution (or something older), presented, for example, in the latest versions of spring-boot-starter-validation.

Let's consider the “beautiful” second option.

The “first circle” validations are perfectly represented in jakarta.validation. These are all sorts of NotNull, NotBlank, etc. annotations. And, accordingly, the implementation of validators from the same Hibernate. The “second circle” validations, as far as I was able to find out, are not represented at all. What to do with this?

You can rely entirely on the DBMS and constraints set for tables. This is sometimes a questionable option. Firstly, it results in some “mixing” of approaches to validation, and in my opinion it is better when everything is solved in one style. Secondly, the DBMS complains with not very “convenient” messages, and they also differ from DBMS to DBMS. It is necessary to somehow separately provide for some “decoding” of these messages if we want to bring them to a format that is more understandable for us/the user.

You can again mix styles. “First circle” operations – via annotations. For “second circle” operations – a separate layer of your own validators. But again, it seems to me that it is better to do everything in one style. And you will have to write a lot.

Well, you can try to supplement the verification mechanism via Jakarta Validation with your own annotations intended for “second-round” validation. That's what I tried to do.

Attempt at implementation

I won't go into much detail about the implementation here – you can see them in my repository. I'll focus more on the “specification”.

Checking a constraint for uniqueness.

There is the following annotation for this.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = UniqueValidationConstraintValidator.class)
public @interface UniqueValidationConstraints {

    String message() default "{com.ismolka.validation.constraints.UniqueValidationConstraint.message}";

    Class<?>[] groups() default { };

    Class<? extends Payload>[] payload() default { };

    ConstraintKey[] constraintKeys() default {};
}

Where ConstraintKey is

@Target(ElementType.ANNOTATION_TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface ConstraintKey {
    String[] value();
}

ConstraintKey lists all the fields of our Entity that are included in the constraint.

UniqueValidationConstraints, accordingly, aggregates all our constraints.

When the annotation is checked, a query with a condition of the form (table.field1Constraint1 = value1Constraint1 AND table.field2Constraint1 = value2Constraint1…) OR (…another constraint) is generated in the database via EntityManager …

In case of the first match, it will return us a boolean tuple for the record that matched for some constraint/constraints, where each element is equal to true if a certain constraint is violated. Then, when processing a negative result for each violated constraint, we put the following parameters in the HibernateConstraintValidatorContext for violation: constraintErrorFields – a comma-separated list of fields in the constraint, constraintErrorFieldsValues ​​- a comma-separated list of values ​​in the constraint.

Example.

@UniqueValidationConstraints(constraintKeys = {
        @ConstraintKey("libraryCode"),
        @ConstraintKey({"name", "authorName"})
})
public class Book {

    private Long id;

    private String libraryCode;
    
    private String name;
    
    private String authorName;
}

The inventory number of a book is unique, and the link “book title – book author” is also unique.

For creation, everything will work fine. But what about updating? When updating, data with such a constraint in the DB may exist (when, say, we pass our record for updating, but do not change this constraint in it), but after updating, we must guarantee that it will remain in the table only one.

The first thing that came to mind: you can add a field to the UniqueValidationConstraints annotation like groupWithIgnoringOneMatch. We designate in this way for which group we will ignore one occurrence. And we pass there the group responsible for the update. In the validator, we will get the groups with which it was pulled, and check whether the specified one is among them. If so, then we ignore it.

But, alas, it seems impossible to get the groups he twitched with in the validator. That's why the idea for another annotation was born.

Checking the constraint for the limit of occurrences.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = LimitValidationConstraintValidator.class)
public @interface LimitValidationConstraints {

    String message() default "{com.ismolka.validation.constraints.LimitConstraint.message}";

    Class<?>[] groups() default { };

    Class<? extends Payload>[] payload() default { };

    LimitValidationConstraintGroup[] limitValueConstraints() default {};

    boolean alsoCheckByUniqueAnnotationWithIgnoringOneMatch() default false;
}
@Target(ElementType.ANNOTATION_TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface LimitValidationConstraintGroup {
    ConstraintKey[] constraintKeys();
    int limit() default 1;
}

This annotation works in a similar way, but now it checks not just a single occurrence, but a violation of a certain occurrence limit. All constraints are now grouped into LimitValidationConstraintGroup, where the occurrence limit will be specified for all the listed constraints. If the limit is reached, then an exception is made. As an addition to the existing parameters for violation, another limit has been added.

alsoCheckByUniqueAnnotationWithIgnoringOneMatch – a kind of “integration” with UniqueValidationConstraints. If set to true – then the validator also takes information from UniqueValidationConstraints and makes a separate check with ignoring the record by ID. Thus, the problem described for updating and UniqueValidationConstraints can be solved as follows:

@LimitValidationConstraints(alsoCheckByUniqueAnnotationWithIgnoringOneMatch = true, groups = { Validation.Update.class })
@UniqueValidationConstraints(constraintKeys = {
        @ConstraintKey("libraryCode"),
        @ConstraintKey({"name", "authorName"}),
}, groups = { Validation.Create.class })
public class Book {

    private Long id;

    private String libraryCode;

    private String name;

    private String authorName;
}

Checking for the existence of connections.

This is already a more complicated matter.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = CheckRelationsExistsConstraintsValidator.class)
public @interface CheckRelationsExistsConstraints {

    String message() default "{com.ismolka.validation.constraints.CheckRelationsExistsConstraints.message}";

    Class<?>[] groups() default { };

    Class<? extends Payload>[] payload() default { };

    RelationCheckConstraint[] value();
}
@Target(ElementType.ANNOTATION_TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface RelationCheckConstraint {

    String relationField() default "";

    RelationCheckConstraintFieldMapping[] relationMapping() default {};

    Class<?> relationClass() default Object.class;

    String message() default "{com.ismolka.validation.constraints.inner.RelationCheckConstraint.message}";

    String relationErrorMessageNaming() default "";
}
@Target(ElementType.ANNOTATION_TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface RelationCheckConstraintFieldMapping {

    String fromForeignKeyField();

    String toPrimaryKeyField();
}

We list all FK constraints for CheckRelationsExistsConstraints in value.

RelationCheckConstraint contains information about a specific relation that needs to be checked. Here we can specify the source field for relationField (can be filled in when the entity has a field with a relation marked as OneToOne, JoinColumn, etc.); relationClass (optional if relationField is specified); relationMapping (you can manually describe how the mapping will be performed); relationErrorMessageNaming – you can separately specify for violation how to show the violated relation in the message.

Everything works in one request and looks like this, for example.

//...
@CheckRelationsExistsConstraints(
        value = {
                @RelationCheckConstraint(
                        relationField = "country",
                        relationMapping = {
                                @RelationCheckConstraintFieldMapping(fromForeignKeyField = "countryId", toPrimaryKeyField = "id")
                        }
                )
        }, groups = { CommonValidationGroups.OnCreate.class, CommonValidationGroups.OnUpdate.class }
)
public class District {

    //...

    @Column(name = "country_id")
    private Long countryId;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "country_id", referencedColumnName = "id", insertable = false, updatable = false)
    private Country country;

    //...
}

And for violation the parameters relationDoesntExist are presented – which relation is violated, relationDoesntExistField – by which field, relationDoesntExistFieldValue – with which value.

Checking the existence of an entity by constraint + unmodifiable

And here is the boss of my gym.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = CheckExistingByConstraintAndUnmodifiableAttributesValidator.class)
public @interface CheckExistingByConstraintAndUnmodifiableAttributes {

    String message() default "{com.ismolka.validation.constraints.ExistsByConstraint.message}";

    Class<?>[] groups() default { };

    Class<? extends Payload>[] payload() default { };

    ConstraintKey constraintKey();

    UnmodifiableAttribute[] unmodifiableAttributes() default {};

    UnmodifiableCollection[] unmodifiableCollections() default {};

    boolean stopUnmodifiableCheckOnFirstMismatch() default false;

    boolean loadByConstraint() default false;

    String loadingByUsingNamedEntityGraph() default "";
}
@Target(ElementType.ANNOTATION_TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface UnmodifiableAttribute {
    String value();

    String equalsMethodName() default "equals";

    String message() default "{com.ismolka.validation.constraints.inner.UnmodifiableAttribute.message}";

    String attributeErrorMessageNaming() default "";

    String[] equalsFields() default {};
}
@Target(ElementType.ANNOTATION_TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface UnmodifiableCollection {

    String value();

    String equalsMethodName() default "equals";

    Class<?> collectionGenericClass() default Object.class;

    String[] fieldsForMatching() default {};

    String message() default "{com.ismolka.validation.constraints.inner.UnmodifiableCollection.message}";

    String collectionErrorMessageNaming() default "";

    CollectionOperation[] forbiddenOperations() default { CollectionOperation.REMOVE, CollectionOperation.ADD, CollectionOperation.UPDATE };

    String[] equalsFields() default {};
}
public enum CollectionOperation {

    ADD,

    REMOVE,

    UPDATE
}

The first thing we're interested in is CheckExistingByConstraintAndUnmodifiableAttributes , our framework, and constraintKey .

The existence of our entity will actually be checked by constraintKey.

In addition to this, two fields have been made.

  • loadByConstraint – entityManager in this case will return not just a boolean, but our object. Useful. What if we want the loaded entity to be cached after successful validation, say? And to check immutable fields (which we'll discuss below) – true is absolutely necessary.

  • loadingByUsingNamedEntityGraph – we specify with which NamedEntityGraph we should load our entity “under the hood”. Just in case.

  • stopUnmodifiableCheckOnFirstMismatch – stop checking unmodifiable fields at the first mismatch (a kind of break in case, for example, we have a bunch of elements in our collections).

And the most interesting thing is immutable attributes/collections.

Let's start with attributes (UnmodifiableAttribute).

  • value – the name of our field will be here.

  • equalsMethodName – defines which method inside the field class will be used for comparison. If it returns false, then everything is bad and the attribute has been changed. Thus, the comparison can be customized, using not the “default” equals, but something of your own.

  • if this customization option does not suit you, there is also equalsFields. Here the fields are listed, by which Objects.equals will go. So that you do not have to write some “custom” equals inside the class, but define it at the annotation level.

  • attributeErrorMessageNaming – “custom” attribute name for violation.

And we move on to collections (UnmodifiableCollection).

  • value, equalsMethodName, equalsFields, collectionErrorMessageNaming – everything is the same with UnmodifiableAttribute.

  • fieldsForMatching – we define by which key the elements will be matched as part of the collection. That is, if the field data matches, then these elements can be compared by equalsFields/equalsMethodName. If it is not defined, the “number” of the element in the collection will be used as the key.

  • forbiddenOperations – which operations are forbidden in the collection. By default, all are forbidden. This is the operation ADD – adding a new element; REMOVE – deleting; UPDATE – changing an existing one.

  • collectionGenericClass – information about the collection's generic class. Defaults to Object.

Example with collections.

//...
@NamedEntityGraph(name = "test", attributeNodes = {
        @NamedAttributeNode(value = "testSubList", subgraph = "test.sub")
        },
        subgraphs = {
        @NamedSubgraph(name = "test.sub", attributeNodes = {
                @NamedAttributeNode("test")
        })
})
@CheckExistingByConstraintAndUnmodifiableAttributes(
        constraintKey = @ConstraintKey("id"),
        unmodifiableCollections = {
                @UnmodifiableCollection(value = "testSubList",
                        collectionGenericClass = TestSub.class,
                        equalsFields = {
                            "value"
                        },
                        fieldsForMatching = {
                            "id"
                        }
                )
        },
        loadingByUsingNamedEntityGraph = "test",
        loadByConstraint = true,
        groups = CommonValidationGroups.OnCreate.class
)
public class Test {

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

    @OneToMany(mappedBy = "test")
    public List<TestSub> testSubList;
  
    //...
}
//...
public class TestSub {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
  
    @ManyToOne
    @JoinColumn(name = "test_id", referencedColumnName = "id", insertable = false, updatable = false)
    private Test test;

    private String value;

    //...
}

Well, and the parameters for violations.

  • doesntExistFields – by what constraint does not exist.

  • doesntExistFieldValues ​​- for which values ​​the constraint does not exist.

  • fieldDiffName – which field does not match.

  • fieldDiffValueNew – new value

  • fieldDiffValueOld – old value

Chain of simple “custom” validators

Here it is worth paying attention to the following annotation.

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = ValidationChainValidator.class)
public @interface ValidationChain {

    String message() default "{com.ismolka.validation.constraints.ValidationChain.message}";

    Class<?>[] groups() default { };

    Class<? extends Payload>[] payload() default { };

    Class<? extends ValidationChainElement<?>>[] value() default {};
}

And on such an interface.

public interface ValidationChainElement<T> {

    boolean isValid(T object, ConstraintValidatorContext context);
}

For the ValidationChain annotation, you can pass implementation classes of this interface to the value. The validator receives beans of these classes, organizing a kind of chain-of-responsibility from them. The object is run through this chain, and false is returned on the first negative result.

This can be useful if we have validation logic that does not fit into the previous cases. Our “self-written” validators (not tied to any annotations) will exist as full-fledged beans, where any validation logic is written, but in the end they still end up built into the Jakarta validation infrastructure and “use” benefits like ConstraintValidatorContext.

Conclusion

What the entity with all these “bells and whistles” will look like in the end can be seen here.

Hidden text
@Entity
@Table(name = "district")
@Data
@NoArgsConstructor
@AllArgsConstructor
@LimitValidationConstraints(alsoCheckByUniqueAnnotationWithIgnoringOneMatch = true, groups = CommonValidationGroups.OnUpdate.class)
@UniqueValidationConstraints(constraintKeys = {
        @ConstraintKey({"name"})
}, groups = CommonValidationGroups.OnCreate.class)
@NamedEntityGraph(name = "district.eg", attributeNodes = {
        @NamedAttributeNode("country")
})
@CheckExistingByConstraintAndUnmodifiableAttributes(
        constraintKey = @ConstraintKey("id"),
        groups = CommonValidationGroups.OnUpdate.class,
        loadingByUsingNamedEntityGraph = "district.eg",
        loadByConstraint = true
)
@CheckRelationsExistsConstraints(
        value = {
                @RelationCheckConstraint(
                        relationField = "country",
                        relationMapping = {
                                @RelationCheckConstraintFieldMapping(fromForeignKeyField = "countryId", toPrimaryKeyField = "id")
                        }
                )
        }, groups = { CommonValidationGroups.OnCreate.class, CommonValidationGroups.OnUpdate.class }
)
public class District {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @NotNull(message = "{id.null}", groups = { CommonValidationGroups.OnUpdate.class })
    private Long id;

    @NotBlank(message = "{district.name.blank}", groups = { CommonValidationGroups.OnCreate.class, CommonValidationGroups.OnUpdate.class })
    private String name;

    @Column(name = "country_id")
    @NotNull(message = "{district.country.null}", groups = { CommonValidationGroups.OnCreate.class, CommonValidationGroups.OnUpdate.class })
    private Long countryId;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "country_id", referencedColumnName = "id", insertable = false, updatable = false)
    private Country country;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        District district = (District) o;
        return Objects.equals(id, district.id);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id);
    }
}

Of course, the block with annotations is large, but it is probably still more advantageous than writing it all out by hand.

The repository can be viewed at this link.

Everything is still draft and undocumented. It will be necessary to at least think about how best to implement it into EntityManager validators. Also cover it with various unit tests, paying special attention to all sorts of nulls (at least for now). But in general, this is already a minimal working version that can be tested.

Write your thoughts about this lib and its necessity. Recommendations and comments on the code are also welcome.

Thank you all!

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *