Вступление
Вот и моя первая статья на Хабре.
Посвящена она будет презентации своего небольшого решения для валидации моделей с использованием запросов к БД и 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-ам (по крайней мере пока). Но в целом - это уже минимально-рабочий вариант, который можно пощупать.
Пишите свои мысли на счет этой либы и ее надобности. Рекомендации и замечания по коду тоже приветствуются.
Всем спасибо!