Вступление

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

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

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

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

Комментарии (16)


  1. igorsmolkako Автор
    27.08.2024 16:53

    Свои мысли по поводу развития:

    1. Вынести функциональность LimitValidationConstraints, отвечающую за "интеграцию" с UniqueValidationConstraints в отдельную анноташку и валидатор. Учитывая, что эта функциональность привязана к LimitValidationConstraints, но при этом задумывалась для проверки уникальности с игнорированием существующей по констрейнту записи в рамках обновления - мы не сможем использовать ее "саму по себе", а только с группой для обновления. Ведь навесить больше одной аннотации над классом мы не можем? Поэтому имеет смысл создать отдельную аннотацию, например, CheckUniqueAnnotationIgnoringOneMatch. И отвязать эту функциональность от LimitValidationConstraints. Хотя я не уверен, что кому-то вообще эта самая LimitValidationConstraints будет нужна "сама по себе")

    2. Внедрять entityManager, наверное, было бы правильно через entityManagerFactory. Т.е. внедряем в валидаторы его, внутри валидаторов создаем через фактори entityManager. Для валидаторов можно сделать интерфейс с сеттером для entityManagerFactory. И создать "свой" validatorFactoryBean, расширяющий базовую LocalValidatorFactoryBean. В него в конфиге будем передавать нужную нам entityManagerFactory. И он, соответственно, будет прокидывать в наши валидаторы этот entityManagerFactory. Ведь в рамках одной прилаги могут работать разные датасурсы со своим "сопровождением". Но тогда еще о transactionManager стоило бы задуматься. Пока тут все заточено на "дефолт".

    3. Само собой оформление кода + рефакторинг. И нейминги местами так себе.


    1. igorsmolkako Автор
      27.08.2024 16:53

      И еще функционал для проверки изменений в объектах/коллекциях можно вынести в отдельную "вкусность", которая будет полезна и за пределами валидации.


  1. gochaorg
    27.08.2024 16:53
    +2

    Получается проверка на уникальность в java, а в бд его нет ?, если так, тогда странное:

    По скольку уникальность выступает требованием для сущности в таблице, тогда перед вставкой необходимо обеспечить атомарный доступ к таблице, ну то есть одно-поточный.

    Если не обеспечить эту атомарность, то будет такое

    | # | client 1      | client 2       |
    |---|---------------|----------------|
    | 1 | insert begin  |                |
    | 2 |               |  insert begin  |
    | 3 |               |  insert finish |
    | 4 | insert finish |                |
    

    client 1 и client 2 - хотят вставить одну и ту же запись - по идее только один должен суметь
    но будут вставлены обе записи, по скольку каждый клиент работает в своей сессии/транзакции и включен режим MVCC или SnapShot isolation... read commited

    по скольку на начало операции insert никто из них не видит изменений соседа

    предварительная проверка в духе (table.field1Constraint1 = value1Constraint1 AND table.field2Constraint1 = value2Constraint1...) OR (...другой констрейнт)

    не сработает в этой ситуации, по скольку опять же read commited еще не произошел, да же если insert был, но не было еще commit - то, опять же сосед ничего не узнает

    В любом случае как не крути, необходима блокировка, по скольку id - должно быть уникальным значением

    блокировка может быть и не явная, например по просить новый id у базы, например sequence (oracle)

    ну а поскольку выполняется на стороне java все это, тогда запуск второго экземпляра java - все сломает - конечно, если не пользоваться менеджерами блокировок, но вообще-то менеджеры блокировок есть во многих бд, что бы не изобретать велосипед.

    я бы придерживался первого подхода и мапил все сообщения от разных бд в java, меньше головной боли и спокойная ночь обеспечена, после релиза хотя бы по этому пункту


    1. igorsmolkako Автор
      27.08.2024 16:53

      Могу согласиться... Аномалии в рамках параллельной работы множества транзакций - вообще проблема широкого круга, вы (на мой взгляд) по сути описали что-то подобное на "чтение фантомов", скажем. Но в данном случае, слава Богу, есть уровень изоляции read_commited.

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


      1. igorsmolkako Автор
        27.08.2024 16:53

        Вернее, serializable*


        1. saymon_says
          27.08.2024 16:53
          +1

          То есть при многопоточной записи новых данных в таблице предлагается только вариант serializable? Как выше верно заметили, при старте потоки видят что в таблице нет записи. Вроде как по уникальной колонке только один поток сохранит данные, но остальные упадут с ошибкой. А если нужно чтобы сама запись была в отдельной транзакции..


          1. igorsmolkako Автор
            27.08.2024 16:53

            Ну, есть еще и другой вариант, который предложили ниже. Это как минимум.

            По поводу "... а если нужно чтобы сама запись была в отдельной транзакции" - я пока, наверное, не до конца уловил)


    1. igorsmolkako Автор
      27.08.2024 16:53

      Кстати, тут вот, например, завязалась похожая дискуссия.

      https://stackoverflow.com/questions/3495368/unique-constraint-with-jpa-and-bean-validation


      1. gochaorg
        27.08.2024 16:53
        +1

        Собственно не обязательно лочить всю таблицу, достаточно лочить только один объект, например какую ни будь спец запись в таблице, которая будет содержать maxId

        А вообще можно положиться на UUID или hash значения - и выполнять upsert/merge - только, тогда возникает в случае hash - ситуация как race condition, а uuid - по сути не связан с полезными данными

        оба варианта, так себе... без lock не обойтись

        вопрос, а вот действительно ли нужно выносить такую важную штуку как id/Fk/Pk в java ? если только для читабельности ошибок, так вроде кол-во субд у нас в мире не бесконечно, основных/mainstream 4, если считать еще всяких недавно появишся, так не более 20-30 штук, маппинг ошибки конкретной бд в свою ошибку приложения сделать вроде не сложно, да и в модели можно комментарий на русском/англиском/ оставить - что вот это поле(я) являются unique


        1. igorsmolkako Автор
          27.08.2024 16:53
          +1

          Думаю, тоже вариант, но делать это нужно будет во всей транзакции разом, под капот моих валидаторов это не спрятать, например... И в начале каждого транзакционного метода вручную реализовывать эту логику, т.к Spring какого-то готового решения а-ля "поле в анноташке" не предоставляет.

          Ну, вынести можно в любом случае. Можно и для читабельности ошибок, и для "прозрачности" кода. Но полагаться исключительно на Java, видимо, не стоит. Оно в любом случае должно быть на обоих уровнях в данном случае.

          Тут вопрос мб даже не в количестве СУБД, а в самой реализации обработки... Помню попытку на одном из проектов, выглядело не очень, и даже там по итогу были проверки на уникальность и на уровне Java.

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


          1. gochaorg
            27.08.2024 16:53

            Можно дополнить либу, я бы так делал:
            либа - отдельный maven - jar
            плагин для бд - другой maven jar
            связка lib - plugin через ServiceLoader (https://habr.com/ru/articles/118488/)

            выбор какой плагин активировать через либо jdbc url (https://docs.oracle.com/javase/8/docs/api/java/sql/DatabaseMetaData.html#getURL--) , либо jdbc driver class (instance of)


  1. LordBarov
    27.08.2024 16:53

    Подскажите плиз, а чем вам условные проверки из коробки не нравятся?
    Если мы хотим проверить что-то на уникальность, добавляем @Column(not_null = true) и т.д.

    Далее пишем кастомный хендлер ошибок и делаем нужные нам операции. Это все работает со всеми видами relations(OneToOne, OneToMany, ManyToMany...).


    1. igorsmolkako Автор
      27.08.2024 16:53

      Не очень понял про проверку на уникальность с not_null = true. Это же проверки на NN уже.

      Если вы имели в виду unique в анноташке Column - это, насколько я понимаю, скорее для генерации DDL.

      А что вы имеете в виду под кастомным хендлером ошибок? Условно, класс, который будет отлавливать и декодировать исключения из БД при сохранении, например? Это вполне себе вариант. Основная проблема, наверное, в том, чтобы написать красиво)


  1. AlexViolin
    27.08.2024 16:53
    +1

    Решал аналогичную задачу в проекте на технологии asp.net.
    Валидаторы входят в функционал presentation layer и логично, что валидаторы должны быть вписаны в общую архитектуру приложения. Внедряя в валидаторы EntityManager мы напрямую подключаем их к ORM - то есть выносим валидаторы за скобки используемой в приложении архитектуры.
    Поэтому логично из валидатора обращаться к слою persistence layer приложения, который работает с базами данных. А уже этот слой может использовать ORM или прямые запросы к бд. В этом случае от валидатора будут скрыты механизмы взаимодействия с бд.


    1. igorsmolkako Автор
      27.08.2024 16:53

      Да, я тоже над чем-то таким подумывал.

      Пока можно остановиться на двух "основных" видах persistence layer: JPA и нативка (можно использовать JdbcTemplate - библиотека пишется со spring-boot-starter-data-jpa в уме, там оно тянется). Имеет смысл написать абстракцию, которая будет осуществлять работу с persistence-layer, и валидаторы будут анализировать плоды ее работы. Под нее как минимум две дефолтные реализации - JPA + entityManager и нативка. И оставить возможность интегрировать для persistence layer валидаторов свое решение - вдруг, скажем, свою ORM-ку пишем?) Ну или какой-нибудь NoSQL - там уже всякие свои OGM бывают.

      По сути даже эдакий мини-фреймворк вырисовывается в этом случае.

      Это пока идея "в ящик")

      А как в целом ваше мнение о либе? Полезное решение хотя бы в рамках JPA? Стали бы использовать? Стоит развивать и в перспективе залить Maven Central, если доведется до ума?

      В целом вопрос подобных валидаций на уровне Java дискуссионный. Это и здесь комментарии показывают.


      1. AlexViolin
        27.08.2024 16:53
        +1

        Не могу дать оценку либе, так как работаю с c# технологиями. Но если дать правильное архитектурное решение для этой задачи, то оно будет работать единообразно в разных технологиях и разных языках разработки.