Java + Spring + Jakarta Validation. Самописный «аддон» для валидации Entity через БД и EntityManager

от автора

Вступление

Вот и моя первая статья на Хабре.

Посвящена она будет презентации своего небольшого решения для валидации моделей с использованием запросов к БД и EntityManager.

Вариант этот пока черновой, «на коленке» и будет развиваться. Критика и рекомендации горячо приветствуются. Пока что мне важно понять, есть ли у сообщества запрос на что-то подобное и имеет ли смысл как-то публично развивать это решение.

Понятно, что статья рассчитана на тех, кто уже знаком с той же Jakarta Validation.

Для чего вот это вот все

Допустим, мы пишем свой CRUD для какой-то сущности с обилием связей.

Из чего будет состоять процесс валидации?

Условно, ее можно разделить на две части.

Первая и самая простая — это валидация на уровне исключительно входных данных. Всякие проверки на NotNull, NotBlank, возможно и какие-нибудь Regex-ы и пр.

Вторая, чуть посложнее — это валидация на стыке входных данных и текущего состояния БД, не пересекающаяся с первой (потому notnull-констрейнты, например, здесь можно не рассматривать — их можно отсеять и на первом этапе). Здесь можно было бы выделить такие наиболее типичные операции:

  • Проверка поля на уникальность при создании новой сущности (записей со значением X поля N на момент сохранения быть не должно).

  • Проверка поля на уникальность при обновлении сущности (при обновлении запись со значением X поля N должна оставаться только одна).

  • Проверка существования проставленных FK-связей.

  • Проверка существования самой сущности в случае обновления (делается, как правило, по ее ID-шке).

  • Возможно, проверка unmodified-полей для обновления, т.е. если поле неизменяемое, но во входных данных мы пытаемся его изменить — исключение.

Надеюсь, ничего не забыл)

Валидацию (по моему опыту) в Spring-приложениях либо пишут сами (создавая, например, отдельный слой самописных валидаторов в стиле «if-else»), либо все же используют jakarta-решение (или что-то по-старше), представленное, например, в последних версиях spring-boot-starter-validation.

Рассмотрим «красивый» второй вариант.

Валидации «первого круга» в jakarta.validation представлены прекрасно. Это и есть всякие NotNull, NotBlank и пр. аннотации. Ну и, соответственно, реализация валидаторов от того же Hibernate. Валидации «второго круга», насколько мне удалось выяснить — никак не представлены. Что с этим делать?

Можно полагаться целиком на СУБД и выставленные для таблиц констрейнты. Это иногда сомнительный вариант. Во-первых, получается некоторое «смешение» подходов к валидации, а на мой взгляд лучше, когда все решается в одном стиле. Во-вторых, СУБД ругается не очень «удобными» сообщениями, еще и разными от СУБД к СУБД. Нужно как-то отдельно предусматривать какой-то «декодинг» этих сообщений, если мы хотим приводить их к более понятному для нас/пользователя формату.

Можно опять-таки смешать стили. Операции «первого круга» — через анноташки. Для операций «второго круга» — отдельный слой своих валидаторов. Но опять-таки, мне кажется, что лучше уж все делать в одном стиле. Да и писать придется многовато.

Ну а можно попытаться дополнить механизм проверки через Jakarta Validation собственными аннотациями, предназначенными для валидации «второго круга». Что я и попытался сделать.

Попытка реализации

Здесь я не буду сильно вдаваться в детали реализации — их можно будет посмотреть в моем репозитории. Больше остановлюсь на «спецификации».

Проверка констрейнта на уникальность.

На это есть следующая аннотация.

@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 {}; }

Где ConstraintKey — это

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

ConstraintKey перечисляет все поля нашей Entity, входящие в констрейнт.

UniqueValidationConstraints, соответственно, агрегирует все наши констрейнты.

Когда аннотация проверяется — в БД через EntityManager формируется запрос с условием вида (table.field1Constraint1 = value1Constraint1 AND table.field2Constraint1 = value2Constraint1…) OR (…другой констрейнт) …

В случае первого совпадения оно вернет нам boolean-кортеж для совпавшей по какому-то констрейнту/констрейнтам записи, где каждый элемент равен true, если определенный констрейнт нарушен. Дальше при обработке негативного результата для каждого нарушенного констрейнта мы кладем в HibernateConstraintValidatorContext для violation следующие параметры: constraintErrorFields — перечисление полей в констрейнте через запятую, constraintErrorFieldsValues — перечисление значений в констрейнте через запятую.

Пример.

@UniqueValidationConstraints(constraintKeys = {         @ConstraintKey("libraryCode"),         @ConstraintKey({"name", "authorName"}) }) public class Book {      private Long id;      private String libraryCode;          private String name;          private String authorName; }

Инвентарный номер книги уникален, так же уникальна связка «название книги — автор книги».

Для создания все работать будет хорошо. Но как быть с обновлением? При обновлении-то данные с таким констрейнтом в БД могут существовать (когда, скажем, передаем на обновление нашу запись, но не меняем в ней этот констрейнт), но после обновления мы должны гарантировать, что он будет оставаться в таблице только один.

Первое, что пришло в голову: можно добавить поле в аннотацию UniqueValidationConstraints вроде groupWithIgnoringOneMatch. Обозначаем таким образом, для какой группы мы будем игнорировать одно вхождение. И передаем туда группу, отвечающую за обновление. В валидаторе же получим группы, с которыми его дернули, и проверим, есть ли среди них указанная. Если есть — тогда игнорируем.

Но, увы, судя по всему получить в валидаторе группы, с которыми он дернулся — невозможно. Потому родилась идея для другой аннотации.

Проверка констрейнта на лимит вхождений.

@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; }

Эта аннотация работает похожим образом, но теперь проверяет не просто единственное вхождение, а нарушение определенного лимита вхождений. Все констрейнты теперь группируются в LimitValidationConstraintGroup, где как раз для всех перечисленных констрейнтов будет указан лимит вхождений. Если лимит достигнут — тогда уже исключение. В качестве дополнения к уже существующим параметрам для violation добавился еще limit.

alsoCheckByUniqueAnnotationWithIgnoringOneMatch — своеобразная «интеграция» с UniqueValidationConstraints. Если выставляется в true — тогда заодно валидатор берет информацию из UniqueValidationConstraints и делает отдельный чек с игнорированием записи по ID. Таким образом, проблему, описанную для обновления и UniqueValidationConstraints можно решить так:

@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; }

Проверка существования связей.

Это уже дело посложней.

@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(); }

Все FK-констрейнты мы перечисляем для CheckRelationsExistsConstraints в value.

RelationCheckConstraint содержит информацию о конкретном релейшене, который нужно проверить. Здесь мы можем указать поле-источник для relationField (можно заполнять, когда в сущности есть поле с релейшеном, помеченное как OneToOne, JoinColumn и пр.); relationClass (необязательно, если указан relationField); relationMapping (можно вручную расписать, как будет осуществляться сопоставление); relationErrorMessageNaming — можно отдельно обозначить для violation, как показывать в сообщении нарушенный релейшен.

Все работает в один запрос и выглядит, например, вот так

//... @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;      //... }

И для violation представлены параметры relationDoesntExist — какой релейшен нарушен, relationDoesntExistField — по какому полю, relationDoesntExistFieldValue — с каким значением.

Проверка существования сущности по констрейнту + unmodifiable

А вот и босс моей качалки.

@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 }

Первое, что нас интересует — это CheckExistingByConstraintAndUnmodifiableAttributes, наш каркас, и constraintKey.

По constraintKey, собственно, и будет проверяться существование нашей сущности.

В дополнение к этому сделаны два поля.

  • loadByConstraint — entityManager в этом случае вернет не просто boolean, а наш объект. Полезно. Вдруг захотим, чтобы загруженная сущность после успешной валидации уже лежала в кэше, скажем? А для проверки неизменяемых полей (о которой чуть ниже) — так и вовсе обязательно true.

  • loadingByUsingNamedEntityGraph — указываем, с каким NamedEntityGraph нам стоит подгружать «под капотом» нашу сущность. На всякий.

  • stopUnmodifiableCheckOnFirstMismatch — останавливать проверку unmodifiable-полей на первом несовпадении (своеобразный break в случае, если у нас, например, в коллекциях подразумевается куча элементов).

И самое интересное — неизменяемые атрибуты/коллекции.

Начнем с атрибутов (UnmodifiableAttribute).

  • value — тут будет лежать название нашего поля.

  • equalsMethodName — определяет, по какому методу внутри класса поля будет идти сопоставление. Если оно вернет false — значит, все плохо и атрибут был изменен. Таким образом, сопоставление можно кастомизировать, использовать не «дефолтный» equals, а что-то свое.

  • если такой вариант кастомизации не устраивает — есть еще equalsFields. Тут перечисляются поля, по каким будет идти Objects.equals. Чтобы не пришлось писать какой-то свой «кастомный» equals внутри класса, а определить это на уровне анноташки.

  • attributeErrorMessageNaming — «кастомное» название атрибута для violation.

И переходим к коллекциям (UnmodifiableCollection).

  • value, equalsMethodName, equalsFields, collectionErrorMessageNaming — все аналогично с UnmodifiableAttribute.

  • fieldsForMatching — мы определяем, по какому ключу будут сопоставляться элементы в рамках коллекции. Т.е. если данные поля совпадают — значит, эти элементы можно сопоставлять уже по equalsFields/equalsMethodName. Если оно не определено — в качестве ключа будет «номер» элемента в коллекции.

  • forbiddenOperations — какие операции в коллекции запрещены. По умолчанию запрещены все. Это операция ADD — добавление нового элемента; REMOVE — удаление; UPDATE — изменение существующего.

  • collectionGenericClass — информация про generic-класс коллекции. По умолчанию Object.

Пример с коллекциями.

//... @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;      //... }

Ну и параметры для violation-ов.

  • doesntExistFields — по какому констрейнту не существует.

  • doesntExistFieldValues — по каким значениям констрейнта не существует.

  • fieldDiffName — какое поле не совпадает.

  • fieldDiffValueNew — новое значение

  • fieldDiffValueOld — старое значение

Цепочка простых «кастомных» валидаторов

Здесь стоит обратить внимание на такую аннотацию.

@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 {}; }

И на такой интерфейс.

public interface ValidationChainElement<T> {      boolean isValid(T object, ConstraintValidatorContext context); }

Для аннотации ValidationChain в value можно передать классы-реализации данного интерфейса. Валидатор получает бины этих классов, организуя из них своего рода chain-of-responsibility. Объект прогоняется через эту цепочку, и на первом негативном результате возвращается false.

Подобное может пригодиться, если у нас есть логика валидации, не укладывающаяся в предыдущие кейсы. Наши «самописные» валидаторы (не завязанные на те или иные аннотации) будут существовать в виде полноценных бинов, где прописана какая угодно логика валидации, но по итогу все равно оказываются встроенными в инфраструктуру Jakarta-валидации и «пользуются» благами вроде ConstraintValidatorContext.

Заключение

Как примерно в итоге будет выглядеть сущность со всеми этими «наворотами» — можно увидеть тут.

Скрытый текст
@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);     } }

Конечно, блок с аннотациями большой, но это, наверное, все еще более выигрышно, нежели прописывать все это руками.

Репозиторий можно посмотреть по этой ссылке.

Все пока черновое и не задокументированное. Нужно будет как минимум подумать, как лучше внедрять в валидаторы EntityManager. Так же покрыть разными unit-тестами, особенно уделив внимание всяким null-ам (по крайней мере пока). Но в целом — это уже минимально-рабочий вариант, который можно пощупать.

Пишите свои мысли на счет этой либы и ее надобности. Рекомендации и замечания по коду тоже приветствуются.

Всем спасибо!


ссылка на оригинал статьи https://habr.com/ru/articles/839006/