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