1

Тема: Spring Data JPA

Декілька приміток:

0.Перегляд логів

Не забувайте додавати деякі властивості, які дозволяють бачити корисні логи. Вони допоможуть краще розуміти, як саме додаток відпрацьовує з БД.

Ось для прикладу додавши дані властивості:

  • spring.jpa.show-sql=true

  • spring.jpa.properties.hibernate.format_sql=true

  • logging.level.org.hibernate.type.descriptor.sql.BasicBinder=trace

  • logging.level.org.springframework.transaction.interceptor=trace

  • spring.jpa.properties.hibernate.generate_statistics=true

Ми будемо мати змогу бачити форматовані запити та значення параметрів в параметризованих запитах, логи транзакцій та статистику. Щось на зразок цього:

2020-02-16 13:27:30.557 TRACE 31971 --- [nio-8080-exec-1] o.s.t.i.TransactionInterceptor           : Getting transaction for [com.example.jpa.service.UserServiceImpl.get]
2020-02-16 13:27:30.564 TRACE 31971 --- [nio-8080-exec-1] o.s.t.i.TransactionInterceptor           : Getting transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.findById]
Hibernate:
    select
        user0_.id as id1_1_0_,
        user0_.email as email2_1_0_
    from
        users user0_
    where
        user0_.id=?
2020-02-16 13:27:30.576 TRACE 31971 --- [nio-8080-exec-1] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [BIGINT] - [1]
2020-02-16 13:27:30.590 TRACE 31971 --- [nio-8080-exec-1] o.s.t.i.TransactionInterceptor           : Completing transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.findById]
2020-02-16 13:27:30.591 TRACE 31971 --- [nio-8080-exec-1] o.s.t.i.TransactionInterceptor           : Completing transaction for [com.example.jpa.service.UserServiceImpl.get]
2020-02-16 13:27:30.606  INFO 31971 --- [nio-8080-exec-1] i.StatisticalLoggingSessionEventListener : Session Metrics {
    59289 nanoseconds spent acquiring 1 JDBC connections;
    0 nanoseconds spent releasing 0 JDBC connections;
    212908 nanoseconds spent preparing 1 JDBC statements;
    203080 nanoseconds spent executing 1 JDBC statements;
    0 nanoseconds spent executing 0 JDBC batches;
    0 nanoseconds spent performing 0 L2C puts;
    0 nanoseconds spent performing 0 L2C hits;
    0 nanoseconds spent performing 0 L2C misses;
    0 nanoseconds spent executing 0 flushes (flushing a total of 0 entities and 0 collections);
    0 nanoseconds spent executing 0 partial-flushes (flushing a total of 0 entities and 0 collections)
}

1.Типи JOINів
  • JOIN (нічого незвичного в порівнянні з SQL INNER JOIN)

  • LEFT JOIN (теж все очікувано в порівнянні з SQL)

  • FETCH (FETCH JOIN/LEFT FETCH JOIN)

Для прикладу візьмемо такі дві сутності:

@Entity
@Table(name = "users")
@Data
@EqualsAndHashCode(exclude = {"cars"})
@ToString(exclude = {"cars"})
public class User {

    @Id
    private Long id;

    private String email;

    @OneToMany(mappedBy = "user")
    private List<Car> cars;

}

@Entity
@Table(name = "cars")
@Data
@EqualsAndHashCode(exclude = {"user"})
@ToString(exclude = {"user"})
public class Car {

    @Id
    private Long id;

    private String name;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "id_user")
    private User user;

}

Та врахуємо, що в БД є наступні дані:

USERS
id,email
1,test@gmail.com
2,asd@gmail.com

CARS
id,name,id_user
1,Audi,1
2,BMW,1

Використаємо в запиті JOIN:

select
        user0_.id as id1_1_,
        user0_.email as email2_1_
    from
        users user0_
    inner join
        cars cars1_
            on user0_.id=cars1_.id_user

Отримаємо список, який має розмір - 2, але з одним і тим самим користувачем (id=1)

Тепер спробуємо LEFT JOIN:

select
        user0_.id as id1_1_,
        user0_.email as email2_1_
    from
        users user0_
    left outer join
        cars cars1_
            on user0_.id=cars1_.id_user

Результат теж очікуваний - список, який має розмір - 3, з двома користувачами, хоч користувач (id=1) двічі буде міститися в даному списку.
Якщо ж захочемо глянути на автомобілі даних користувачів, то відповідно до БД будуть надіслані запити аби отримати дану інформацію.

Спробуємо FETCH JOIN:

select
        user0_.id as id1_1_0_,
        cars1_.id as id1_0_1_,
        user0_.email as email2_1_0_,
        cars1_.name as name2_0_1_,
        cars1_.id_user as id_user3_0_1_,
        cars1_.id_user as id_user3_0_0__,
        cars1_.id as id1_0_0__
    from
        users user0_
    inner join
        cars cars1_
            on user0_.id=cars1_.id_user

Маємо список, з розміром - 2, але з одним і тим самим користувачем (id=1)

Тепер LEFT FETCH JOIN:

select
        user0_.id as id1_1_0_,
        cars1_.id as id1_0_1_,
        user0_.email as email2_1_0_,
        cars1_.name as name2_0_1_,
        cars1_.id_user as id_user3_0_1_,
        cars1_.id_user as id_user3_0_0__,
        cars1_.id as id1_0_0__
    from
        users user0_
    left outer join
        cars cars1_
            on user0_.id=cars1_.id_user

Результат - список, який має розмір - 3, з двома користувачами, хоч користувач (id=1) двічі буде міститися в даному списку.
Відмінність даних запитів від попередніх можна помітити в блоках SELECT. Відбувається ініціалізація колекцій (автомобілі) і якщо ми захочемо глянути на автомобілі даних користувачів, то запити для отримання авто в БД вже відправлятися не будуть.

Ще деякі цікавинки, які інколи дивують новачків:

1.1.Вкладені транзакції

З одного боку цей момент пов’язаний суто з Spring AOP. Але для підтримки транзакцій за допомогою анотацій @Transactional АОП й застосовується :) Тому тут слід розуміти поведінку додатку.
Поглянемо на код в якому з одного методу виконується виклик іншого. Який в свою чергу має створювати нову транзакцію:

@Service
public class UserServiceImpl implements UserService {

    @Override
    @Transactional
    public void methodFirst() {
        //do something
        methodSecond();
    }

    @Override
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void methodSecond() {
        //do something
    }

}

Тобто в момент виклику methodFirst() насправді йде виклик методу проксі об'єкта. Cпочатку створюється нова транзакція, а вже далі відбувається виклик methodFirst(). В момент виклику methodSecond() з методу methodFirst() ніякий проксі не задіюється. Ну і відповідно анотація над методом methodSecond() - @Transactional(propagation = Propagation.REQUIRES_NEW), не відіграє ніякої ролі. Відповідно нова транзакція не буде створена. Тому, щоб дійсно все відпрацьовувало, як ми хочемо, слід додати посилання на проксі та змінити виклик методу methodSecond(). З Spring версії 4.3 це можна зробити, так:

@Service
public class UserServiceImpl implements UserService {

    @Autowired
    private UserService proxy;

    @Override
    @Transactional
    public void methodFirst() {
        //do something
        proxy.methodSecond();
    }

    @Override
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void methodSecond() {
        //do something
    }

}
1.2.Запит до БД, якого не видно в коді

Для прикладу маємо код:

    @Transactional
    public void updateSomeProperty() {
        User user = userRepository.findById(1L).get();
        user.setEmail("newemail@gmail.com");
    }

В певний момент даний метод викликається в коді. Користувач дивиться логи і бачить, що до БД відправляється запит на оновлення сутності, хоч він сам нічого такого в методі не прописував. Але дана поведінка цілком нормальна. Так, як даний метод позначений анотацією @Transactional в якій по замовчуванню вказано - (readOnly=false) й відповідно всі зміни в сутності відслідковуються (Hibernate - dirty checking). Тому при завершенні методу виконується запит на оновлення сутності.

1.3.Кеш першого рівня

Контекст зберігання, його також часто називають - кешем першого рівня. Тобто якщо в одному методі (звісно враховуємо, що всі звернення до БД в даному методі виконуються в одній транзакції), ми зробимо декілька викликів методу репозиторія - findById(SOME_ID) (пошук по ідентифікатору), то спочатку буде перевірятися кеш першого рівня. Якщо ж в ньому не знайдеться відповідного екземпляра сутності, то лише тоді до БД буде чергове звернення. В іншому випадку - повторних запитів до БД не буде.

2.Створення запитів

Варіантів, створення запитів до БД в Spring Data JPA є дійсно багато:

Самий простий варіант. Тобто методи в репозиторії з відповідними назвами будуть давати потрібний результат - findByEmail(String email), getAllByCarsNameIgnoreCase(String carName) і тд.

Даний варіант є більш гнучким. За допомогою анотації @Query можна написати відповідний запит. Для прикладу використавши JPQL:

@Query("SELECT c FROM Car c WHERE LOWER(c.name) = LOWER(:name)")
List<Car> query(@Param("name") String name);

Також є можливість писати запити, використовуючи SQL (головне не забути в анотації для цього зробити примітку nativeQuery = true):

@Query(nativeQuery = true, value = "SELECT * FROM cars  WHERE LOWER(name) = LOWER(:name)")
List<Car> someMethod(@Param("name") String name);

А якщо в результаті запиту необхідно отримати лише певні поля? Для прикладу маємо такий клас:

@Entity
@Table(name = "users")
@Data
@EqualsAndHashCode(exclude = {"cars"})
@ToString(exclude = {"cars"})
public class User {

    @Id
    private Long id;

    @Column(name = "last_name")
    private String lastName;

    @Column(name = "first_name")
    private String firstName;

    private String email;

    @Column(name = "phone_number")
    private String phoneNumber;

    @OneToMany(mappedBy = "user")
    private List<Car> cars;

}

І все що потрібно це контактна інформація(електронна пошта та номер телефону). Все що потрібно для цього - інтерфейс та методи в ньому з відповідними назвами:

public interface ContactInfo {

    String getEmail();

    String getPhoneNumber();

}

Пропишемо ось такий метод:

List<ContactInfo> getByLastNameIgnoreCase(String lastName);

Якщо ми викличемо даний метод, то побачимо в логах наступний запит:

select
        user0_.email as col_0_0_,
        user0_.phone_number as col_1_0_
    from
        users user0_
    where
        upper(user0_.last_name)=upper(?)

Для більш широкого використання методу, можна його підправити:

<T> List<T> getByLastNameIgnoreCase(String lastName, Class<T> type);

Тобто фільтрація завжди буде однаковою, проте поля які нам потрібно отримати ми можемо змінювати.

Зручний варіант для створення динамічних запитів. Для цього інтерфейс репозиторія має наслідуватися від QueryByExampleExecutor<T> або JpaRepository<T, ID> (який у свою чергу наслідується від QueryByExampleExecutor<T>). Після цього слід буде надати сутність на основі якої і буде генеруватися запит:

User probe = new User();
probe.setEmail("TeST@gmail.com");

ExampleMatcher matcher = ExampleMatcher.matching()
    .withIgnoreCase();

Example<User> example = Example.of(probe, matcher);

userRepository.findAll(example);

Отриманий запит:

select
        user0_.id as id1_1_,
        user0_.email as email2_1_,
        user0_.first_name as first_na3_1_,
        user0_.last_name as last_nam4_1_,
        user0_.phone_number as phone_nu5_1_
    from
        users user0_
    where
        lower(user0_.email)=?

Додавши в попередній код ще одну стрічку:

...
probe.setFirstName("...");
…

Отримаємо інший запит:

select
        user0_.id as id1_1_,
        user0_.email as email2_1_,
        user0_.first_name as first_na3_1_,
        user0_.last_name as last_nam4_1_,
        user0_.phone_number as phone_nu5_1_
    from
        users user0_
    where
        lower(user0_.email)=?
        and lower(user0_.first_name)=?

При використанні слід враховувати деякі моменти:
- За замовчуванням поля сутності, які мають значення null ігноруються при формуванні запиту.
- Вкладені або згруповані обмеження не підтримуються // email = ... or (email = ... and firstName = ...)
- На даний момент, для фільтрації в запитах, можна користуватися лише властивостями SingularAttribute.

Теж використовується для створення динамічних запитів. Великий плюс в тому, що можна поєднувати декілька специфікацій для формування запиту. В даному випадку репозиторій теж має наслідуватися від конкретного інтерфейсу - JpaSpecificationExecutor<T>
Напишемо ось такі специфікації (для цього слід реалізувати інтерфейс Specification<T>):

public class UserSpecification {

    public static Specification<User> emailStartsWith(@NonNull String email) {
        return (Specification<User>) (root, query, criteriaBuilder) -> criteriaBuilder.like(root.get("email"), email + "%");
    }

    public static Specification<User> phoneNumberEqual(String phoneNumber) {
        return new Specification<User>() {

            @Override
            public Predicate toPredicate(Root<User> root, CriteriaQuery<?> query, CriteriaBuilder criteriaBuilder) {
                if (phoneNumber == null) {
                    return criteriaBuilder.isNull(root.get("phoneNumber"));
                } else {
                    return criteriaBuilder.equal(root.get("phoneNumber"), phoneNumber);
                }
            }

        };
    }

}

А тепер глянемо, як їх можна використовувати для формування запитів і які саме запити будуть згенеровані:

userRepository.findAll(UserSpecification.emailStartsWith("test"));

select
        user0_.id as id1_1_,
        user0_.email as email2_1_,
        user0_.first_name as first_na3_1_,
        user0_.last_name as last_nam4_1_,
        user0_.phone_number as phone_nu5_1_
    from
        users user0_
    where
        user0_.email like ?

userRepository.findAll(UserSpecification.phoneNumberEqual(null));

select
        user0_.id as id1_1_,
        user0_.email as email2_1_,
        user0_.first_name as first_na3_1_,
        user0_.last_name as last_nam4_1_,
        user0_.phone_number as phone_nu5_1_
    from
        users user0_
    where
        user0_.phone_number is null

userRepository.findAll(UserSpecification.emailStartsWith("test").or(UserSpecification.phoneNumberEqual("123456789")));

select
        user0_.id as id1_1_,
        user0_.email as email2_1_,
        user0_.first_name as first_na3_1_,
        user0_.last_name as last_nam4_1_,
        user0_.phone_number as phone_nu5_1_
    from
        users user0_
    where
        user0_.phone_number=?
        or user0_.email like ?

Тобто, в даному випадку повністю розв'язані руки для створення динамічних запитів. Все залежить від фантазії. Але не слід забувати про деякі моменти:
- Якщо глянути в код специфікацій, то можна помітити, що поля класу по яких відбувається фільтрація прописані стрічковими літералами. Тобто, якщо змінити назву якогось поля в сутності, але забути зробити зміни в специфікації то ми отримаємо помилку під час виконання програми. Щоб уникнути такого варіанту пропонується використовувати метамодель (автоматична генерація класів метамоделі).
- Код який пишеться при використанні специфікацій досить важко читати і відповідно супроводжувати. Тому специфікації бажано використовувати у випадках коли інших варіантів немає.

Якщо в проекті також використовується Spring MVC + немає бажання реалізовувати свої специфікації, то можна глянути сюди.

3.Проблема “N+1 SELECT”

Відома проблема “N+1 SELECT”, яка виникає при роботі з сутностями, які мають в собі колекції інших сутностей. Тобто, якщо потрібно буде вибрати всіх користувачів, а потім дізнатися кількість автомобілів у кожного з них:

List<User> users = userRepository.findAll();
for (User user : users) {
    user.getCars().size();
}

В результаті отримаємо 1 запит, який поверне всіх користувачів, та N запитів щоб отримати колекцію авто для кожного юзера (умовно, якщо буде 5 користувачів, то виконається 6 запитів).

  • FetchType.EAGER vs FetchType.LAZY

Самий простий варіант, але перед його використанням слід тричі подумати:

...
@OneToMany(mappedBy = "user", fetch = FetchType.EAGER)
private List<Car> cars;
…
User user = userRepository.findById(1L).get();
user.getCars().size();

select
        user0_.id as id1_1_0_,
        user0_.email as email2_1_0_,
        user0_.first_name as first_na3_1_0_,
        user0_.last_name as last_nam4_1_0_,
        user0_.phone_number as phone_nu5_1_0_,
        cars1_.id_user as id_user3_0_1_,
        cars1_.id as id1_0_1_,
        cars1_.id as id1_0_2_,
        cars1_.name as name2_0_2_,
        cars1_.id_user as id_user3_0_2_
    from
        users user0_
    left outer join
        cars cars1_
            on user0_.id=cars1_.id_user
    where
        user0_.id=?

Оскільки прописавши даний параметр, ми не будемо мати змоги змінити поведінку при формуванні запитів. Тому сутність буде витягатися з БД завжди з колекцією, навіть коли потреби в даній колекції не буде. А якщо в самій колекції будуть сутності, які теж мають зв’язок типу FetchType.EAGER, то геть сумно буде :)
Та й при запитах, типу - findAll, findAllByIdIn,... цей варіант не допоможе

  • @BatchSize

В даному випадку ми можемо обрати варіант пакетної вибірки даних. Використаємо анотацію @BatchSize в якій порекомендуємо в разі ініціалізації колекції автомобілів завантажити 3 колекції. Припустимо, що в БД є 5 користувачів:

...
@OneToMany(mappedBy = "user")
@BatchSize(size = 3)
private List<Car> cars;
…
List<User> users = userRepository.findAll();
for (User user : users) {
    user.getCars().size();
}

select
        user0_.id as id1_1_,
        user0_.email as email2_1_,
        user0_.first_name as first_na3_1_,
        user0_.last_name as last_nam4_1_,
        user0_.phone_number as phone_nu5_1_
    from
        users user0_

    select
        cars0_.id_user as id_user3_0_1_,
        cars0_.id as id1_0_1_,
        cars0_.id as id1_0_0_,
        cars0_.name as name2_0_0_,
        cars0_.id_user as id_user3_0_0_
    from
        cars cars0_
    where
        cars0_.id_user in (
            ?, ?, ?
        )

    select
        cars0_.id_user as id_user3_0_1_,
        cars0_.id as id1_0_1_,
        cars0_.id as id1_0_0_,
        cars0_.name as name2_0_0_,
        cars0_.id_user as id_user3_0_0_
    from
        cars cars0_
    where
        cars0_.id_user in (
            ?, ?
        )

В результаті отримаємо 3 запити. Перший запит - поверне всіх користувачів. Наступні 2 запити будуть витягувати колекції автомобілів пакетами (3 та 2 відповідно). Переглянути алгоритм пакетної вибірки можна в даному методі - org.hibernate.internal.util.collections.ArrayHelper.getBatchSizes(int maxBatchSize)

Не слід забувати про певні моменти, для прикладу, глянемо на такий код:

User user1 = userRepository.findById(1L).get();
User user2 = userRepository.findById(2L).get();
user1.getCars().size();


    select
        cars0_.id_user as id_user3_0_1_,
        cars0_.id as id1_0_1_,
        cars0_.id as id1_0_0_,
        cars0_.name as name2_0_0_,
        cars0_.id_user as id_user3_0_0_
    from
        cars cars0_
    where
        cars0_.id_user in (
            ?, ?
        )

Звернемо увагу лише на останній запит. Так, як в кеші першого рівня знаходиться 2 користувача, то запит формується з умовою - “скоріш за все згодиться і для іншого користувача список авто”. Хоч ми в коді ніяких дій з колекцією автомобілів другого юзера не робимо.
Також слід розуміти, що використання даної анотації не обмежується колекціями. Для прикладу її можна застосовувати над класами.

  • JOIN FETCH

Головна перевага даного варіанту над попередніми - динамічність. Тобто лише в потрібний момент можна викликати запит, який разом з сутністю підтягне також і колекцію, яку вона в собі містить. Допишемо ось такий запит:

@Query("SELECT u FROM User u LEFT JOIN FETCH u.cars WHERE u.id = ?1")
Optional<User> findByIdWithJoin(long id);

І ось, що отримаємо коли викличемо даний метод:

select
        user0_.id as id1_1_0_,
        cars1_.id as id1_0_1_,
        user0_.email as email2_1_0_,
        user0_.first_name as first_na3_1_0_,
        user0_.last_name as last_nam4_1_0_,
        user0_.phone_number as phone_nu5_1_0_,
        cars1_.name as name2_0_1_,
        cars1_.id_user as id_user3_0_1_,
        cars1_.id_user as id_user3_0_0__,
        cars1_.id as id1_0_0__
    from
        users user0_
    left outer join
        cars cars1_
            on user0_.id=cars1_.id_user
    where
        user0_.id=?

Також можна даний варіант використовувати при масовій вибірці. Але слід не забувати про результат, який поверне, ось такий запит, використовуючи JOIN:

@Query("SELECT u FROM User u LEFT JOIN FETCH u.cars")
List<User> findAllWithJoin();

Припустимо, що в нас є 2 користувача. І лише 1 з них має автомобілі, для прикладу 3 штуки. Тому, так як результатом даного запиту буде декартів добуток, відповідно і список ми отримаємо розміром - 4 (користувач, який має автомобілі повторюється тричі + один раз, користувач без авто).
В даному випадку нам може допомогти оператор DISTINCT:

@Query("SELECT DISTINCT u FROM User u LEFT JOIN FETCH u.cars")
List<User> findAllWithJoin();

Використовуючи такий запит, ми отримаємо список, який містить 2 користувачів.

  • @EntityGraph

JPA 2.1 подарувала досить зручну цяцьку під назвою граф сутностей. В Spring Data JPA ним можна досить легко скористатися за допомогою відповідної анотації:

@EntityGraph(attributePaths = {"cars"})
Optional<User> getById(long id);

select
        user0_.id as id1_1_0_,
        cars1_.id as id1_0_1_,
        user0_.email as email2_1_0_,
        user0_.first_name as first_na3_1_0_,
        user0_.last_name as last_nam4_1_0_,
        user0_.phone_number as phone_nu5_1_0_,
        cars1_.name as name2_0_1_,
        cars1_.id_user as id_user3_0_1_,
        cars1_.id_user as id_user3_0_0__,
        cars1_.id as id1_0_0__
    from
        users user0_
    left outer join
        cars cars1_
            on user0_.id=cars1_.id_user
    where
        user0_.id=?

Тобто отримуємо результат на який очікували. Якщо ж в сутності є декілька колекцій, то без проблем можна вказати, які саме слід витягати при запиті (attributePaths = {"...", "..."}). Хоча не слід зловживати цим, можливо декілька окремих SQL запитів відпрацюють значно краще, аніж один “важкий” :)

4.JOIN FETCH та пагінація

Ось таке “гарне” повідомлення нам відображається, коли ми хочемо використати пагінацію разом з запитом, який відразу ініціалізує колекцію в сутності:

HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!

Щоб отримати дане повідомлення, можна використати такий метод:

@EntityGraph(attributePaths = "cars")
@Query("SELECT u FROM User u")
Page<User> getAll(Pageable pageable)
userRepository.getAll(PageRequest.of(0, 10));

Якщо ж глянути на результат, який ми отримуємо, то все добре відпрацьовує. Головне слід розуміти, що лімітування виконується на стороні додатку, а не в SQL запиті. Тому слід добре подумати перед тим, як використовувати подібні запити, аби не виникло проблем з пам'яттю.

5.@LazyCollection

Уявімо ситуацію, коли нам слід зробити деякі операції з колекцією, яка знаходиться в сутності. Але при цьому, ми не хочемо витягати всю колекцію з бази даних. Для прикладу, дізнатися її розмір. Зробимо певні зміни в коді:

…
@OneToMany(mappedBy = "user")
@LazyCollection(value = LazyCollectionOption.EXTRA)
private List<Car> cars;
...
User user = userRepository.findById(1L).get();
int count = user.getCars().size();

select
        user0_.id as id1_1_0_,
        user0_.email as email2_1_0_,
        user0_.first_name as first_na3_1_0_,
        user0_.last_name as last_nam4_1_0_,
        user0_.phone_number as phone_nu5_1_0_
    from
        users user0_
    where
        user0_.id=?

    select
        count(id)
    from
        cars
    where
        id_user =?

Переглянувши логи можна помітити, що формується запит до бази даних, який повертає лише загальну кількість автомобілів для певного користувача.

Колекція так само, як і в попередньому випадку не буде завантажуватися в пам’ять, якщо викликати наступні методи - isEmpty(), contains() (в даних випадках теж будуть виконуватися певні запити до бази даних). Окремі запити буду генеруватися у випадках Set - add(), Map - containsKey(), containsValue().

Також є можливість не витягати всю колекцію одним запитом (припустимо, що колекція має досить великий розмір), а доступатися до її елементів по індексу - get(). В такому випадку буде генеруватися окремий запит, щоб отримати певний елемент колекції. Для цього слід додати анотацію @OrderColumn (JPA 2.0). Але з даною анотацією, ми отримаємо додаткові запити (оновлення індекса, для підтримки впорядкованості), наприклад при додаванні нових сутностей. Окрім того, потрібно буде додаткове поле в таблиці та й про можливу помилку (org.hibernate.HibernateException: null index column for collection) не слід забувати. Тому варто добре обдумати всі за і проти, перед використанням даного варіанту:

…
@LazyCollection(value = LazyCollectionOption.EXTRA)
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
@OrderColumn(name = "order_id") //add column
private List<Car> cars;
...
Car car = user.getCars().get(0);
...
List<Car> cars = user.getCars();
...
cars.add(newCar);


    select
        car0_.id as id1_0_0_,
        car0_.name as name2_0_0_,
        car0_.id_user as id_user3_0_0_
    from
        cars car0_
    where
        car0_.id_user=?
        and car0_.order_id=?

    insert
    into
        cars
        (name, id_user, id)
    values
        (?, ?, ?)

    select
        max(order_id) + 1
    from
        cars
    where
        id_user =?

    update
        cars
    set
        order_id=?
    where
        id=?

6.Незмінний об'єкт

А чи завжди потрібно відслідковувати можливі зміни в сутностях (dirty checking)? Тобто, якщо маємо таблицю в БД і в ній зберігаються дані для яких ніколи не буде застосовуватися SQL запит типу UPDATE, то можна вказати, за допомогою анотації org.hibernate.annotations.Immutable над сутністю, що ми відмовляємось від перевірки даної сутності на зміни:

@Entity
@Immutable
public class SomeEntity {
    ...
}

Дана анотація надасть певну оптимізацію, яка зекономить, як пам'ять так і час.
Відмовитися від dirty checking не на рівні сутності, а на рівні певного методу, ми можемо, ось так:

@Transactional(readOnly = true)
public void someMethod() {
    User user = userRepository.findById(1L).get();
    ...
}

Якщо ж ми все одно спробуємо зробити певні зміни в сутності й потім їх зберегти в БД, за допомогою repository.save(entity), що трапиться? Нічого :) Ніякий запит, для оновлення запису, до БД відправлятися не буде (хоча якщо конче необхідно оновити сутність, то ніхто не забороняє для цього скористатися JPQL/SQL).

7.Конвертація атрибутів

Уявімо ситуацію коли нам потрібно виконати конвертацію між типами даних Java та конкретної СУБД. Для прикладу візьмемо MySQL та тип даних JSON. Додамо в таблицю користувачів поле в якому будемо зберігати посилання чи нікнейми на різні соціальні мережі, щось типу цього:

{"skype": "someNickname", "linkedin": … }

Звісно в коді ми не хочемо це тримати у вигляді стрічки, тому напишемо ось такий клас:

@Data
public class SocialInfo {

    @JsonProperty(value = "linkedin")
    private String linkedin;

    @JsonProperty(value = "skype")
    private String skype;

   ...

}

Тепер відповідне поле додамо до нашої сутності:

...
@Column(name = "social")
private SocialInfo socialInfo;
...

Залишився один момент нам слід вказати, як саме проводити конвертацію даних, як в один так і в інший бік.  З JPA 2.1 ми маємо таку можливість, слід імплементувати інтерфейс javax.persistence.AttributeConverter <X, Y>:

@Converter(autoApply = true)
@AllArgsConstructor
public class SomeConverter implements AttributeConverter<SocialInfo, String> {

    //JPA 2.2 - support CDI injection to AttributeConverter
    private final ObjectMapper mapper;

    @Override
    public String convertToDatabaseColumn(SocialInfo data) {
        if (data != null) {
            try {
                return mapper.writeValueAsString(data);
            } catch (JsonProcessingException ex) {
                throw new IllegalArgumentException(ex.getMessage());
            }
        }
        return null;
    }

    @Override
    public SocialInfo convertToEntityAttribute(String json) {
        if (json != null) {
            try {
                return mapper.readValue(json, SocialInfo.class);
            } catch (IOException ex) {
                throw new IllegalArgumentException(ex.getMessage());
            }
        }
        return null;
    }

}

У цьому випадку ми вказали використовувати даний конвертер автоматично (autoApply = true) для відповідного типу даних (SocialInfo) в сутностях. Якщо ж є потреба вказувати конкретний тип конвертера для конкретного атрибута, то слід застосовувати анотацію - @Convert (при необхідності її можна застосувати на рівні класу):

...
@Column(name = "social")
@Convert(converter = SomeConverter.class)
private SocialInfo socialInfo;
…

Конвертер можна використовувати майже для всіх атрибутів. Певні винятки - id атрибути (включаючи атрибути вбудованих ідентифікаторів), атрибути версій, атрибути зв'язку між сутностями та атрибути які анотовані @Temporal або @Enumerated.

8.Native query та DTO

У випадках, де важливою складовою є швидкодія або використовується особливість конкретної СУБД, користуйтеся native query.
Також інколи можна оминати рівень сутностей, тобто відразу результат запиту трансформувати в DTO. Для прикладу, коли потрібно з таблиці БД повернути лише певні поля, то краще скористатися одним з варіантів (аби не витягувати зайві дані та не проводити конвертація сутності в DTO):

  • конструктор в запиті (підійде лише при використанні JPQL)

Ось такі дані нам потрібно повернути клієнту:

@Value
public class ContactInfoDto {

    private String getEmail;

    private String getPhoneNumber;

}

Створюємо відповідний метод:

@Query(value = "SELECT new com.example.jpa.dto.ContactInfoDto(u.email, u.phoneNumber) FROM User u WHERE u.id=:id")
Optional<ContactInfoDto> query(@Param("id") long id);
  • Projections (див. - “Створення запитів”)

Тут головне, аби імена полів збігалися:

@Query(value = "SELECT email, phone_number AS phoneNumber FROM users WHERE id=?",
        nativeQuery = true)
Optional<ContactInfo> query(long id);
  • @SqlResultSetMapping

Уявімо, що нам потрібна деяка інформація про користувачів, але з певною фільтрацією - дані користувачі повинні мати акаунт в скайпі. В даному випадку слід трішки доповнити код в класі сутності:

...
@SqlResultSetMapping(
        name = "contactInfoMapping",
        classes = {
                @ConstructorResult(
                        targetClass = ContactInfoDto.class,
                        columns = {
                                @ColumnResult(name = "email", type = String.class),
                                @ColumnResult(name = "phone_number", type = String.class)
                        }
                )
        }
)
@NamedNativeQuery(name = "User.getAllWhereSkypeIsNotNull",
        resultSetMapping = "contactInfoMapping",
        query = "SELECT email, phone_number FROM users WHERE social ->> '$.skype' IS NOT NULL;"
)
public class User {
...

А вже потім додаємо відповідний метод (назва методу повинна відповідати значенню, яке ми прописали в анотації @NamedNativeQuery):

@Query(nativeQuery = true)
List<ContactInfoDto> getAllWhereSkypeIsNotNull();
  • результат в двовимірному масиві

Досить специфічний варіант, але він існує:

@Query(value = "SELECT social ->> '$.skype', social ->> '$.linkedin' FROM users WHERE phone_number IS NOT NULL;",
        nativeQuery = true)
String[][] query();
9.Точкове оновлення

В нас є сутність - користувач. І в певний момент нам слід змінити його ім’я, ну і звісно зберегти зміни в БД:

...
user.setFirstName("...");
...

Ось такий запит ми побачимо в логах:

update
        users
    set
        email=?,
        first_name=?,
        last_name=?,
        ...
    where
        id=?

Тобто ми змінили лише ім'я, а запит формується все одно зі всіма існуючими полями. Тому, якщо маємо сутність з великою кількістю полів (також не забуваємо, що і розміри даних значень відіграють неабияку роль :) ) слід подумати, а можливо краще оновлювати конкретні поля в таблиці, а не всю сутність:

...
@Modifying
@Query("UPDATE User u SET u.firstName = :firstName WHERE u.id = :id")
int updateFirstNameById(@Param("id") long id, @Param("firstName") String firstName);
…

Ну і при виклику даного методу отримаємо відповідний запит:

update
        users
    set
        first_name=?
    where
        id=?

Також слід зазначити, що використовуючи даний варіант оновлення немає необхідності завантажувати сутність (відповідно дані) в пам’ять.

Ще один варіант - використання анотації @DynamicUpdate. Зробимо певні зміни в коді:

…
@DynamicUpdate
public class User {
…

Ми отримаємо бажаний результат при оновленні сутності. Буде формуватися запит на оновлення лише з полями, які були модифіковані.

В даному варіанті, головне не забувати чому саме так відбувається. Коли додаток стартує, Hibernate генерує SQL вирази (CRUD) для всіх сутностей. Тобто, аби не генерувати кожен раз SQL запити, краще мати їх в пам'яті. Але в такому випадку SQL запит UPDATE генерується зі всіма полями (оскільки заздалегідь передбачити, які саме поля будуть змінюватися неможливо). Використання даної анотації деактивує створення UPDATE запиту для конкретної сутності при старті. Тобто Hibernate не буде використовувати кешований SQL вираз для оновлення. Натомість він буде генерувати потрібний SQL запит щоразу, коли ми оновлюємо сутність.

10.BATCH INSERT & GenerationType.IDENTITY

Hibernate не підтримує пакетної вставки, якщо ідентифікатор сутності генерується за допомогою GenerationType.IDENTITY (поле в таблиці БД з автоматичним збільшенням - AUTO_INCREMENT в MySQL, IDENTITY в SQL Server).

Візьмемо для прикладу СУБД - MySQL. В коді маємо наступну сутність:

...
public class Car {

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

Також не слід забувати вказувати властивість - spring.jpa.properties.hibernate.jdbc.batch_size
Використовуючи дане значення Hibernate буде накопичувати вирази INSERT на рівні JDBC (PreparedStatement.addBatch). Тобто зазначаємо максимальну кількість сутностей, які мають міститися в пакеті:

...
spring.jpa.properties.hibernate.jdbc.batch_size=2
...

Тепер спробуємо додати декілька авто:

...
carRepository.saveAll(Arrays.asList(car1, car2));
…

Глянемо логи:

...
    350204 nanoseconds spent acquiring 1 JDBC connections;
    0 nanoseconds spent releasing 0 JDBC connections;
    2667714 nanoseconds spent preparing 2 JDBC statements;
    13076708 nanoseconds spent executing 2 JDBC statements;
    0 nanoseconds spent executing 0 JDBC batches;
    0 nanoseconds spent performing 0 L2C puts;
    0 nanoseconds spent performing 0 L2C hits;
    0 nanoseconds spent performing 0 L2C misses;
    3788901 nanoseconds spent executing 1 flushes (flushing a total of 2 entities and 0 collections);
    0 nanoseconds spent executing 0 partial-flushes (flushing a total of 0 entities and 0 collections)

І помітимо, що результату на який ми очікували немає (executing 2 JDBC statements, executing 0 JDBC batches).

Для вирішення даної проблеми ми можемо скористатися 1 з 2 варіантів (MySQL):

  • зміна типу ідентифікатора та його генерація на стороні додатку

Використаємо java.util.UUID. Hibernate буде автоматично генерувати значення для даного поля перед зберіганням (в даному варіанті це буде 4 версія - pseudo-random numbers). Зробимо певні зміни в коді:

...
public class Car {

    @Id
    @GeneratedValue
    private UUID id;
…

Останній штрих - трішки підправимо властивість spring.datasource.url:

…
spring.datasource.url=jdbc:mysql://localhost:3306/db?rewriteBatchedStatements=true
...

Допишемо параметр (rewriteBatchedStatements=true), який вказує що при пакетній вставці додавання записів в таблицю слід виконувати єдиним запитом (MySQL).

Спробуємо провести дану операцію і глянемо що в нас відображається в логах:

...
    456894 nanoseconds spent acquiring 1 JDBC connections;
    0 nanoseconds spent releasing 0 JDBC connections;
    1708485 nanoseconds spent preparing 1 JDBC statements;
    0 nanoseconds spent executing 0 JDBC statements;
    2433508 nanoseconds spent executing 1 JDBC batches;
    0 nanoseconds spent performing 0 L2C puts;
    0 nanoseconds spent performing 0 L2C hits;
    0 nanoseconds spent performing 0 L2C misses;
    33143785 nanoseconds spent executing 1 flushes (flushing a total of 2 entities and 0 collections);
    0 nanoseconds spent executing 0 partial-flushes (flushing a total of 0 entities and 0 collections)

Тобто ми досягли своєї цілі (executing 1 JDBC batches). Аби побачити кінцевий варіант запиту глянемо в таблицю general_log (MySQL):

insert into cars (name, id_user, id) values ('Suzuki', 1, x'840EE2D79B9A449093539FD84131FBF2'),('Volkswagen', 1, x'7DAC52F044DC4050A6400F9472E5B3A6')

Якщо ж закоментувати властивість - spring.jpa.properties.hibernate.jdbc.batch_size=2 чи вказати розмір - 1, тоді результат буде такий, як в попередньому варіанті.

Ще один цікавий момент, а давайте спробуємо зберегти екземпляри різних сутностей. Тобто спочатку авто, потім користувача і знову авто:

...
carRepository.save(car1);
...
userRepository.save(user);
...
carRepository.save(car2);
...

Переглянемо логи:

...
    329093 nanoseconds spent acquiring 1 JDBC connections;
    0 nanoseconds spent releasing 0 JDBC connections;
    2556253 nanoseconds spent preparing 4 JDBC statements;
    655464 nanoseconds spent executing 1 JDBC statements;
    3900158 nanoseconds spent executing 3 JDBC batches;
    0 nanoseconds spent performing 0 L2C puts;
    0 nanoseconds spent performing 0 L2C hits;
    0 nanoseconds spent performing 0 L2C misses;
    19662211 nanoseconds spent executing 1 flushes (flushing a total of 3 entities and 0 collections);
    0 nanoseconds spent executing 0 partial-flushes (flushing a total of 0 entities and 0 collections)
...

Помітимо, що в такому випадку ніякої користі від пакетної вставки ми не отримуємо (executing 3 JDBC batches). В даній ситуації слід скористатися наступною властивістю:

...
spring.jpa.properties.hibernate.order_inserts=true
…

Тепер Hibernate буде сортувати операції. Тобто спочатку INSERT для таблиці users, а вже потім INSERT для таблиці  cars:

...
    448639 nanoseconds spent acquiring 1 JDBC connections;
    0 nanoseconds spent releasing 0 JDBC connections;
    2406852 nanoseconds spent preparing 3 JDBC statements;
    714034 nanoseconds spent executing 1 JDBC statements;
    2876363 nanoseconds spent executing 2 JDBC batches;
    0 nanoseconds spent performing 0 L2C puts;
    0 nanoseconds spent performing 0 L2C hits;
    0 nanoseconds spent performing 0 L2C misses;
    19461766 nanoseconds spent executing 1 flushes (flushing a total of 3 entities and 0 collections);
    0 nanoseconds spent executing 0 partial-flushes (flushing a total of 0 entities and 0 collections)
...

  • скористатися JDBC API

В даному випадку слід провести операцію використовуючи JDBC. В цьому нам допоможе - org.hibernate.jdbc.Work (також можна обрати інший інтерфейс - org.hibernate.jdbc.ReturningWork<T>). Додамо відповідний метод, який і буде виконувати додавання записів до БД:

...
@PersistenceContext
private EntityManager entityManager;
...
@Transactional
public void batchInsert(List<Car> cars) {
    Session session = entityManager.unwrap(Session.class);
    session.doWork(connection -> {
        String query = "INSERT INTO cars (name, id_user) VALUES (?, ?)";
        try (PreparedStatement ps = connection.prepareStatement(query)) {
            for (Car car : cars) {
                ps.setString(1, car.getName());
                ps.setLong(2, car.getUser().getId());
                ps.addBatch();
            }
            ps.executeBatch();
        }
    });
}
...

Тепер спробуємо виконати дану операцію:

...
carRepository.batchInsert(Arrays.asList(car1, car2));
…

В логах ми нічого не можемо побачити, оскільки це не логування JDBC, тому слід скористатися іншими варіантами. Один з них - глянути в таблицю general_log (MySQL) й там ми побачимо ось такий запит:

INSERT INTO cars (name, id_user) VALUES ('Suzuki', 1),('Volkswagen', 1)

При використанні даного варіанту бажано не забувати про - max_allowed_packet (MySQL).

Звісно ще є варіант з GenerationType.TABLE, проте ним краще не користуватися.

11.Проксі об’єкт

Існує певний метод в якому ми створюємо автомобіль. Звісно для його подальшого зберігання в БД слід вказати власника:

...
Car car = new Car();
...
userRepository.findById(1L).ifPresent(car::setUser);
...
carRepository.save(car);
…

Що маємо в підсумку - перед тим як виконати INSERT ми звертаємося до бази даних з запитом аби отримати користувача:

...
    select
        user0_.id as id1_2_0_,
        user0_.email as email2_2_0_,
        ...
    from
        users user0_
    where
        user0_.id=?
...
    insert
    into
        cars
        (name, id_user, id)
    values
        (?, ?, ?)
...

Хм, виходить цікава ситуація. Все що нам потрібно від користувача -  лише ідентифікатор, який ми й так маємо. Тобто з точки зору SQL нам би вистачило єдиного запиту INSERT. Чи можемо ми змінити ситуацію? Звісно:

...
Car car = new Car();
...
car.setUser(userRepository.getOne(1L));
...
carRepository.save(car);
...

Ось тепер ніякого запиту на вибірку юзера не буде. В цьому нам допоміг метод - getOne (org.springframework.data.jpa.repository.JpaRepository<T, ID>). Тобто маючи ідентифікатор ми можемо отримати посилання на сутність (своєрідна обгортка - проксі об’єкт). Якщо в кеші першого рівня є об’єкт User з відповідним ідентифікатором, то його даний метод і поверне. В іншому випадку ми отримаємо проксі об’єкт, тобто null даний метод не повертає.

SELECT запит до БД щодо даної сутності, буде надіслано лише при виклику будь-якого методу даного об’єкту (звісно виключенням є метод - getId). Якщо ж під час ініціалізації проксі об’єкта відповідного запису в базі даних не виявиться, ми отримаємо javax.persistence.EntityNotFoundException. Також слід пам'ятати, що в разі виклику методів проксі об'єкта (для прикладу - getEmail), який не був ініціалізований, доки контекст зберігання був відкритим, ми отримаємо  досить “відомий” org.hibernate.LazyInitializationException. Тому завжди завантажуйте всі потрібні дані до закриття контектсу зберігання.

12.Послідовність виконання SQL запитів

Потрібно в методі видалити певного користувача з бази даних і відразу додати нового. При цьому у нового користувача електронна пошта має бути така, як в попереднього (в таблиці де зберігаються користувачі, поле - електронна пошта, зазначене, як унікальне):

...
User user1 = userRepository.findById(1L).get();
userRepository.delete(user1);
...
User user2 = new User();
user2.setEmail(user1.getEmail());
...
userRepository.save(user2);
...

Спробуємо виконати даний метод:

...
    insert
    into
        users
        (email, first_name, last_name, phone_number, social, id)
    values
        (?, ?, ?, ?, ?, ?)
...
o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 1062, SQLState: 23000
o.h.engine.jdbc.spi.SqlExceptionHelper   : Duplicate entry 'test@gmail.com' for key 'email'
...

І отримаємо цікавий результат. По-перше, запит на видалення ми не помітимо. По-друге, отримуємо повідомлення про дублювання електронної пошти в таблиці юзерів. Чому так?

Та послідовність операцій яку ми бачимо в коді, не гарантується при виконанні SQL запитів. Hibernate групує запити по типу і виконує їх в наступному порядку (даний список наведено без врахування елементів колекцій):

  • INSERT

  • UPDATE

  • DELETE

Тобто порядок в якому виконуються SQL запити визначає - org.hibernate.engine.spi.ActionQueue (EXECUTABLE_LISTS_MAP).

Ось тому в логах і не помітно запита на видалення користувача. Оскільки спочатку відбувається додавання нового запису. А саме додавання запису призводить до дублювання електронної пошти.

P.S. На початку дана стаття планувалася "трішки іншою" (розмір, формат і тд.), проте прийшлося внести деякі правки :( Тому, якщо в когось є час та бажання доповнити чи підкорегувати, ласкаво прошу...

Подякували: /KIT\, leofun012

2

Re: Spring Data JPA

13.Пагінація за допомогою одного SQL запиту

Якщо виконувати пагінацію, шляхом використання наявних методів, які є в репозиторіях (SimpleJpaRepository - findAll(Pageable pageable), findAll(Example<S> example, Pageable pageable), findAll(Specification spec, Pageable pageable)), то зазвичай до СУБД буде надіслано 2 SQL запити:

...
Page<User> data = userRepository.findAll(PageRequest.of(0, 10, DESC, "id"));
...
...
    select
        user0_.id as id1_2_,
        user0_.email as email2_2_,
        user0_.first_name as first_na3_2_,
        user0_.last_name as last_nam4_2_,
        user0_.phone_number as phone_nu5_2_,
        user0_.social as social6_2_ 
    from
        users user0_ 
    order by
        user0_.id desc limit ?
…
    select
        count(user0_.id) as col_0_0_ 
    from
        users user0_
...

Всі наявні методи (див. вище) повертають об’єкт, який реалізує інтерфейс Page - PageImpl. Завдяки додатковому SQL запиту, який повертає загальну кількість записів, маємо можливість використовувати метод getTotalElements.

Звісно при певних умовах все може обмежитися єдиним SQL запитом. Якщо розмір сторінки, яка використовується при запиті до СУБД, буде мати значення більше загальної кількості записів, які відповідають критеріям даного SQL запиту:

...
Page<User> data = userRepository.findAll(PageRequest.of(0, 999, DESC, "id"));
...
...
    select
        user0_.id as id1_2_,
        user0_.email as email2_2_,
        user0_.first_name as first_na3_2_,
        user0_.last_name as last_nam4_2_,
        user0_.phone_number as phone_nu5_2_,
        user0_.social as social6_2_ 
    from
        users user0_ 
    order by
        user0_.id desc limit ?
...

Оскільки в таблиці, є лише 15 записів, відповідно виконувався 1 SQL запит.

Якщо ж, при реалізації пагінації, нема потреби отримувати загальну кількість записів (достатньо логічного значення, яке повертає метод hasNext), то можна використати інший метод, який буде повертати Slice - SliceImpl. На це може бути багато причин, оскільки підрахунок загальної кількості записів інколи може бути занадто дорогою операцією (досить великий об’єм даних, ті ж самі партиції і тд.). Та й можливо дане значення (загальна кількість) не завжди буде актуальне, якщо надходження нових даних відбувається досить швидко.
Створюємо відповідний метод:

...
@Query("SELECT u FROM User u")
Slice<User> getAll(Pageable pageable);
...

Перевіряємо чи дійсно все відпрацює з одним SQL запитом:

...
Slice<User> data = userRepository.getAll(PageRequest.of(0, 10, DESC, "id"));
...

Й дійсно, в логах помічаємо лише один запит:

...
    select
        user0_.id as id1_2_,
        user0_.email as email2_2_,
        user0_.first_name as first_na3_2_,
        user0_.last_name as last_nam4_2_,
        user0_.phone_number as phone_nu5_2_,
        user0_.social as social6_2_ 
    from
        users user0_ 
    order by
        user0_.id desc limit ?
...
14.Логування повільних SQL запитів

Нерідко буває, що “важкі” або “погані” SQL запити сповільнюють виконання певних операцій. Один з варіантів, як про дані запити дізнатися - очікування “гарних слів” від DBA :) Якщо ж серйозно, то існує багато різних інструментів аби мати різного роду статистику, щодо виконання SQL запитів.

Починаючи з Hibernate 5.4.5 з’явилася можливість відображати дані про повільні запити в логах. Даний функціонал особливо корисний тим, що дозволяє зрозуміти, під час якої саме операції, SQL запит відпрацював повільно. Для цього потрібно визначити порогове значення за допомогою властивості hibernate.session.events.log.LOG_QUERIES_SLOWER_THAN_MS:

...
spring.jpa.properties.hibernate.session.events.log.LOG_QUERIES_SLOWER_THAN_MS=500
...

Тобто інформація про SQL запити, час виконання яких, понад 500 мілісекунд, буде записуватися до журналу на рівні INFO. Тому варто переконатися, що рівень логування налаштовано коректно:

...
logging.level.org.hibernate.SQL_SLOW=INFO
...

Штучно створимо повільний SQL запит. Для цього використаємо функцію SLEEP - СУБД MySQL:

...
@Query("SELECT u FROM User u WHERE u.id = :id AND function('sleep', 1) = 0")
Optional<User> slowQuery(@Param("id") long id);
...

За допомогою даного методу спробуємо знайти потрібного нам користувача:

...
userRepository.slowQuery(1L);
...

Помітимо в логах наступний запис:

...
INFO 35088 --- [nio-8080-exec-4] org.hibernate.SQL_SLOW                   : SlowQuery: 1002 milliseconds. SQL: 'HikariProxyPreparedStatement@344376280 wrapping com.mysql.cj.jdbc.ClientPreparedStatement: select user0_.id as id1_2_, user0_.email as email2_2_, user0_.first_name as first_na3_2_, user0_.last_name as last_nam4_2_, user0_.phone_number as phone_nu5_2_, user0_.social as social6_2_ from users user0_ where user0_.id=1 and sleep(1)=0'
...
15.UnexpectedRollbackException

Саме це виключення з таким повідомленням - Transaction silently rolled back because it has been marked as rollback-only спробуємо отримати аби зрозуміти чому саме так відбувається. Для прикладу нам потрібно створити нового користувача, звісно зберегти дані про нього в БД. В доповнення до даної операції, ще потрібно робити якісь маніпуляції з іншими таблицями в БД (оновлювати певні записи чи створювати нові), в нашому випадку - додавання запису в іншу таблицю про те що юзер був створений. Але саме головне, те що нас найбільше цікавить саме створення користувача, а якщо виникнуть певні проблеми зі зберіганням додаткової інформації про цю операцію то це не має вплинути на створення користувача в БД. В результаті маємо щось типу такого (один контролер та два сервіси):

...
@RestController
@RequestMapping("users")
@AllArgsConstructor
public class UserController {

    private final UserService userService;
    
    @PostMapping
    public void unexpected(@RequestBody UserDto dto) {
        userService.create(dto);
    }
    
}
...
...
@Service
@AllArgsConstructor
public class UserService {

    private final UserRepository userRepository;
    private final OtherService otherService;
    
    @Transactional
    public void create(UserDto dto) {
        User user = new User();
        //do something
        userRepository.save(user);
        
        try {
            otherService.someMethod(dto);
        } catch (Exception ex) {
            //do something
        }
    }

}
...
...
@Service
@AllArgsConstructor
public class OtherService {

    private final OtherRepository otherRepository;

    @Transactional
    public void someMethod(UserDto dto) {
        OtherEntity entity = new OtherEntity();
        //do something
        someRepository.save(entity);
        
        throw new RuntimeException("Some message");
    }

}
...

Тобто в методі create (сервіс - UserService) зберігаємо дані про нового користувача в БД, а також викликаємо ще один метод - someMethod іншого сервісу - OtherService. Оскільки пріоритетом є зберігання даних про юзера, то виклик методу someMethod обгортаємо в блок try-catch. Ну а в самому методі someMethod штучно ініціюємо виникнення певних проблем. Якщо ми спробуємо використати даний код, то отримаємо наступне:

...
org.springframework.transaction.UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only
...

Чому таке відбувається? Оскільки використовуються два сервіси, методи, яких позначені анотацією - @Transactional (без вказування будь-яких атрибутів), відповідно всі маніпуляції відбуваються в єдиній транзакції. Так, як в одному з транзакційних методів виникає виключення, яке не відловлюється в цьому ж методі, то Spring помічає дану транзакцію, як rollback-only. Відповідно всі подальші дії не мають жодного сенсу, оскільки відбудеться відкат транзакції.

Що ж можна зробити в даній ситуації? Один з варіантів - видалити анотацію @Transactional над методом someMethod (сервіс - OtherService):

...
public void someMethod(UserDto dto) {
…
}
...

В такому випадку дані про користувача будуть успішно зберігатися в БД. Проте слід розуміти, що всі дії в даному методі, не будуть виконуватися, як єдина транзакція. Й скоріш за все, такий варіант в 99 випадках зі 100, буде неприйнятний.

Трішки інший варіант, вказати в анотації @Transactional  (сервіс - OtherService, метод - someMethod) атрибут noRollbackFor:

...
@Transactional(noRollbackFor = RuntimeException.class)
public void someMethod(UserDto dto) {
...

Але тут досить схожа проблема з попереднім варіантом. Оскільки ніякого відкату транзакції відбуватися не буде, тому такий варіант, м’яко кажучи, теж не є хорошим.

Ще один варіант - перенести блок try-catch в метод someMethod (сервіс - OtherService):

...
@Transactional
public void someMethod(UserDto dto) {
    try {
        SomeEntity entity = new SomeEntity();
        //do something
        someRepository.save(entity);
        
        throw new RuntimeException("Some message");
    } catch (Exception ex) {
        //do something
    }
}
...

Й дійсно після цього все буде відпрацьовувати добре. Але можуть виникнути нюанси. Для прикладу, якщо в сутності SomeEntity ми беремо на себе відповідальність за вказування ідентифікатора, а не покладаємося на БД. Проте забуваємо в коді вказати ідентифікатор для нової сутності, відповідно ми отримаємо виключення IdentifierGenerationException й транзакція знову буде помічена, як rollback-only (ця проблема також буде актуальна й для двох інших варіантів, які були згадані вище).

Тому, в такому випадку, єдиний вірний варіант - використовувати окрему транзакцію (сервіс - OtherService, метод - someMethod):

...
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void someMethod(UserDto dto) {
...
16.Specification + Pagination + FETCH JOIN

Уявімо ситуацію, коли в нас для певної сутності мають створюватися динамічні запити. Тобто є умовна сутність Car й кінцевий користувач має можливість вказувати багато різних варіантів фільтрації аби переглядати лише потрібні йому записи. Для цього звісно будемо використовувати  Specifications (див. 2.Створення запитів):

...
public class CarSpecification {

public static Specification<Car> equalByName(String name) {
    return (root, query, criteriaBuilder) -> {
        if (name == null) {
            return criteriaBuilder.isNull(root.get("name"));
        } else {
            return criteriaBuilder.equal(root.get("name"), name);
        }
    };
}
    
//other specifications
...

В потрібному місці, виконуємо відповідну вибірку:

...
Specification<Car> specification = CarSpecification.equalByName("someName")
        .and(...)
        .and(...);
List<Car> data = carRepository.findAll(specification);
...

Все начебто працює, але додаються наступні вимоги - додати пагінацію. Начебто не проблема, трішки підправимо код й маємо результат:

...
Specification<Car> specification = CarSpecification.equalByName("someName")
        .and(...);
        .and(...);
Pageable pageable = PageRequest.of(0, 10, (Sort.by(DESC, "id")));
Page<Car> data = carRepository.findAll(specification, pageable);
...

Згодом помічаємо, що в нас надсилається досить багато SQL запитів (це не найгірший варіант в такій ситуації :) , оскільки не виключається можливість отримати LazyInitializationException - див. 11.Проксі об’єкт тут все залежить від того, коли та де саме, буде відбуватися звертання до поля user об’єктів Car). Оскільки кожна сутність Car, має посилання на іншу сутність - User й тут приходить розуміння, що варто скористатися FETCH JOIN (див. 1.Типи JOINів). Додаємо новий метод в клас CarSpecification аби одночасно з БД діставати дані про авто та відповідного юзера:

...
public static Specification<Car> fetchJoinUser() {
    return (root, query, criteriaBuilder) -> {
        root.fetch("user", JoinType.LEFT);
        return null;
    };
}
...

Також вносимо відповідні зміни в створення загальної специфікації:

...
Specification<Car> specification = CarSpecification.equalByName("someName")
        .and(...);
        .and(...);
        .and(CarSpecification.fetchJoinUser());
...

Все супер, начебто працює й виконується лише один SQL запит. Тут згадуємо про те, що при використанні наявних методів до БД буде надсилатися 2 SQL запити (див. 13.Пагінація за допомогою одного SQL запиту). Розуміємо, чому надсилається лише 1 SQL запит - “розмір сторінки, яка використовується при запиті до СУБД, буде мати значення більше загальної кількості записів, які відповідають критеріям даного SQL запиту”. Відповідно трішки змінюємо код, аби повноцінно протестувати даний функціонал (з надсиланням двох SQL запитів):

...
Pageable pageable = PageRequest.of(0, 1, (Sort.by(DESC, "id")));
...

Й тут ми натрапляємо на помилку при формуванні другого SQL запиту (загальна кількість записів, які відповідають критеріям запиту):

...
org.hibernate.QueryException: query specified join fetching, but the owner of the fetched association was not present in the select list
...

Вся проблема в тому, що ми використовуємо Specification й відповідно на основі специфікацій формуються всі SQL запити. Для прикладу, якби ми описували запит JPQL чи навіть SQL за допомогою анотації @Query то могли б вказати окремий запит для підрахунку загальної кількості записів (атрибут - countQuery).

Найшвидший варіант аби все запрацювало, трішки “підказати” коли саме варто використовувати FETCH JOIN (маленькі доповнення в методі fetchJoinUser, клас - CarSpecification):

...
public static Specification<Car> fetchJoinUser() {
    return (root, query, criteriaBuilder) -> {
        if (query.getResultType().equals(Car.class)) {
            root.fetch("user", JoinType.LEFT);
        }
        return null;
    };
}
...

Перевіряємо зміни й бачимо, що помилок немає. Також помічаємо в логах 2 SQL запити. Один з яких, якраз й виконує запит на отримання загальної кількості записів, а саме головне, без JOIN (тобто JOIN FETCH відпрацював саме при створенні першого SQL запиту, де результатом був Car, а при формуванні запиту на загальну кількість він не використовувався, оскільки результатом даного запиту був Long):

...
Hibernate: 
    select
        count(car0_.id) as col_0_0_ 
    from
        cars car0_ 
    where
        car0_.name=?
...
17.Встановлення часового поясу для сесії БД

Використаємо для даного прикладу СУБД - PostgreSQL. В нас є таблиця в якій є умовне поле some_field (тип - TIMESTAMP). Маємо певну задачу - потрібно зробити вибірку з даної таблиці фільтруючи записи за допомогою функції now():

...
SELECT
    * 
FROM
    example_table 
WHERE
    some_field >= (NOW() - interval '1 hour')
...

Тобто даний запит має повертати всі записи в залежності від значення в полі some_field. Воно має бути більше або дорівнювати часу (даті та часу), який був годину тому.

Тут виникає питання, а що саме поверне функція now(), якщо сервер СУБД має TimeZone = UTC, а ми підключаємося до нього локально (в процесі розробки, використовуємо власний ноутбук на якому TimeZone = Europe/Uzhgorod)? Бо саме від результату функції now() й буде залежати, що саме ми отримаємо у відповідь на запит. А дана функція (now()) буде повертати значення, з врахуванням клієнтської TimeZone.

Створимо 4 методи в репозиторії:

...
@Query(nativeQuery = true, value = "SELECT :now\\:\\:timestamp")
String sendLocalDateTime(LocalDateTime now);

@Query(nativeQuery = true, value = "SELECT CAST (now() AS text)")
String getNowWithTimeZone();

@Query(nativeQuery = true, value = "SELECT CAST(now()\\:\\:timestamp AS text)")
String getNowWithoutTimeZone();

@Query(nativeQuery = true, value = "SHOW TIMEZONE")
String getTimezone();
...

Тепер скористаємося ними (зараз на моєму ноутбуці відображається наступний час - 2022-07-23 15:06:38 +03:00):

...
LocalDateTime now = LocalDateTime.now();

log.info("LocalDateTime::TIMESTAMP -> {} ", someRepository.sendLocalDateTime(now));
log.info("NOW() -> {}", someRepository.getNowWithTimeZone());
log.info("NOW()::TIMESTAMP -> {}", someRepository.getNowWithoutTimeZone());
log.info("TIMEZONE -> {}", someRepository.getTimezone());
...

Переглянемо логи:

...
LocalDateTime::TIMESTAMP -> 2022-07-23 15:06:39.380155
NOW() -> 2022-07-23 15:06:39.418604+03
NOW()::TIMESTAMP -> 2022-07-23 15:06:39.421465
TIMEZONE -> Europe/Uzhgorod
...

Спробуємо використати єдину для всіх TimeZone - UTC. Досить популярне рішення, як саме Java програму перевести на потрібний часовий пояс:

...
@SpringBootApplication
public class JpaApplication {

    @PostConstruct
    public void init() {
        TimeZone.setDefault(TimeZone.getTimeZone("UTC"));
    }
...

Знову спробуємо викликати 4 методи, які ми додали й переглянемо логи:

...
LocalDateTime::TIMESTAMP -> 2022-07-23 12:07:39.852117
NOW() -> 2022-07-23 15:07:39.889758+03
NOW()::TIMESTAMP -> 2022-07-23 15:07:39.892655
TIMEZONE -> Europe/Uzhgorod
...

Трішки не той результат на який очікували :(

Інший варіант - вказати необхідний аргумент під час запуску JVM -Duser.timezone=UTC. Викликаємо ті ж самі методи й переглядаємо логи:

...
LocalDateTime::TIMESTAMP -> 2022-07-23 12:09:44.786065 
NOW() -> 2022-07-23 12:09:44.824609+00
NOW()::TIMESTAMP -> 2022-07-23 12:09:44.827771
TIMEZONE -> UTC
...

А, ось цей варіант відпрацював добре.

Але якщо ми хочемо змінити, лише, часовий пояс для сесії БД? Тоді варто скористатися іншим варіантом й використати властивість (за замовчуванням використовується пул підключень до БД - HikariCP) - spring.datasource.hikari.connection-init-sql:

...
spring.datasource.hikari.connection-init-sql=SET TIME ZONE 'UTC'
...

Повторюємо виклики методів й переглядаємо результат:

...
LocalDateTime::TIMESTAMP -> 2022-07-23 15:11:02.324791 
NOW() -> 2022-07-23 12:11:02.363577+00
NOW()::TIMESTAMP -> 2022-07-23 12:11:02.366602
TIMEZONE -> UTC
...

Ось й результат, TimeZone - UTC, лише на рівні взаємодії з БД.

Подякували: leofun011