JavaRush /Курси /Hibernate deep-dive /JOIN FETCH в одному ...

JOIN FETCH в одному запиті

Hibernate deep-dive
Рівень 8 , Лекція 0
Відкрита

1. Коли потрібен JOIN FETCH

Якщо ви тільки почали розбиратися в Hibernate, легко потрапити в пастку: ви бачите в коді один запит, у логах — теж один, і здається, що читання «дешеве». А потім ви додаєте рядок order.getItems().size() (або просто логуєте об’єкт), і Hibernate раптом виконує ще один SQL. Це виглядає як містика, хоча насправді це чесний наслідок LAZY: «Я прочитаю зв’язок, коли ви попросите».

У Commerce Persistence Lab дуже природний сценарій — детально завантажити замовлення, показати клієнта та позиції замовлення. Наївний код «прочитай замовлення, а далі якось» виглядає так:

import jakarta.persistence.EntityManager;
import java.util.List;

public class OrderReadExample {

    public static void showOrder(EntityManager em, long orderId) {
        // Читаємо лише саме замовлення (без колекцій), бо зв’язок зазвичай LAZY
        var order = em.find(PurchaseOrder.class, orderId);

        // На цьому місці Hibernate ще може НЕ ходити в БД за items (залежить від налаштувань/стану)
        List<OrderItem> items = order.getItems(); // може тригернути SQL

        // А ось тут майже напевно відбудеться ініціалізація колекції (і додатковий SELECT)
        System.out.println("позицій = " + items.size()); // позицій = 5
    }
}

Сам рядок order.getItems() (або items.size()) — це і є момент, коли ви кажете: «А тепер справді сходи в базу по колекцію». Hibernate не шкодить — він просто чесно виконує контракт LAZY.

Тепер уявіть не одне замовлення, а список із 20 замовлень в адмінці. Ви читаєте 20 замовлень одним запитом, а потім у циклі в кожного замовлення торкаєтеся items — і отримуєте вже класичний N+1. І в цей момент у нас з’являється питання: «Чи можна попросити Hibernate завантажити зв’язок одразу, поки ми все одно читаємо замовлення?»

Відповідь: так. Саме для цього і потрібен JOIN FETCH.

2. join vs join fetch

Слова join і join fetch у JPQL настільки схожі, що мозок автоматично думає: «Гаразд, join — отже, дані з’єднаються, і в мене все буде завантажено». Але Hibernate влаштований хитріше (і чесніше): звичайний join — це спосіб пов’язати таблиці для умов і сортування, а join fetch — це явна команда: «І заодно ініціалізуй асоціацію в сутності, яку повертаєш».

Уявіть побутову аналогію. Звичайний join — це як сказати в кафе: «Порівняйте, будь ласка, всі мої замовлення та знайдіть ті, де є десерт». Вам принесли список замовлень, але десерт до пакета не поклали — ви просто використали факт його існування, щоб відфільтрувати. А join fetch — це: «Принесіть замовлення і відразу покладіть десерт у пакет, щоб мені потім не бігати ще раз».

Невелика таблиця, щоб закріпити:

JPQL-фрагмент Для чого зазвичай використовують Чи гарантує ініціалізацію зв’язку в entity?
join o.items i фільтрація/умови за items, сортування, перевірка існування ні, це не fetch-план
join fetch o.items завантажити items одразу разом із o так, це прямий зміст fetch

Важливо: JOIN FETCH — це не зміна анотації fetch = EAGER. Це налаштування конкретного запиту, тобто рішення на рівні use case. Ми лишаємося в правильній філософії курсу: LAZY — базова модель, а «підвантажити наперед» робимо точково.

3. JOIN FETCH для to-one

Починати найкраще зі зв’язків to-one (ManyToOne, OneToOne), бо вони простіші за формою результату. Одне замовлення пов’язане з одним клієнтом — отже, у SQL join зазвичай не роздуває кількість рядків радикально (про «роздування» ми поговоримо на колекціях, тобто to-many, і особливо — у наступній лекції).

У нашому проєкті PurchaseOrder майже напевно має зв’язок на Customer. У термінах коду це щось на кшталт:

import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.ManyToOne;

@Entity
public class PurchaseOrder {

    // Важливо: LAZY — отже Customer не завантажується автоматично під час читання PurchaseOrder
    @ManyToOne(fetch = FetchType.LAZY)
    private Customer customer;

    // ...
}

Якщо ми читаємо замовлення через find, а потім торкаємося order.getCustomer().getEmail(), Hibernate може зробити додатковий запит за Customer (це залежить від того, чи вже був він у persistence context). Щоб явно сказати «прочитай замовлення одразу разом із клієнтом», ми пишемо JPQL із join fetch:

import jakarta.persistence.EntityManager;

public class PurchaseOrderQuery {

    private final EntityManager em;

    public PurchaseOrderQuery(EntityManager em) {
        this.em = em;
    }

    public PurchaseOrder findWithCustomer(long orderId) {
        // JOIN FETCH тут потрібен не для фільтрації, а щоб гарантовано ініціалізувати o.customer
        return em.createQuery(
                """
                select o from PurchaseOrder o
                join fetch o.customer
                where o.id = :id
                """,
                PurchaseOrder.class
        )
        // Параметр запиту: читаємо конкретне замовлення за id
        .setParameter("id", orderId)
        .getSingleResult();
    }
}

Тут важливо не лише те, що в SQL з’явиться JOIN, а й те, що Hibernate ініціалізує асоціацію customer у сутності PurchaseOrder, яку повертає. Тобто ви отримуєте PurchaseOrder, у якого customer уже завантажено, і звернення до полів клієнта в поточному контексті не має запускати додатковий SQL.

Якщо пояснити це максимально «фізично», JOIN FETCH робить дві справи одночасно. Він говорить базі «поверни мені дані з двох таблиць одним запитом» і говорить Hibernate «коли збиратимеш об’єкт PurchaseOrder, одразу поклади в нього Customer, не відкладаючи на потім».

4. inner vs left join fetch

Слово join у JPQL за замовчуванням означає inner join. Це тонкий момент: inner join за змістом каже «поверни лише ті рядки, де зв’язок існує». Для обов’язкових зв’язків (наприклад, order.customer завжди заданий) це зазвичай нормально. Але щойно зв’язок може бути відсутнім, inner join fetch починає поводитися дуже логічно і водночас дуже несподівано: він просто не поверне «батьківську» сутність, у якої немає «дочірньої».

Щоб побачити це простіше, візьмемо приклад із каталогу: Product і ProductDetails. У лабораторній моделі деталі можуть бути не для всіх товарів (наприклад, тестові дані або товар ще не заповнив контент-менеджер). Тоді Product.details — це зв’язок, який може бути null.

І ось тут важливо розрізняти два запити:

// INNER JOIN FETCH: якщо деталей немає — сам Product не повернеться із запиту
var product = em.createQuery(
        "select p from Product p " +
        "join fetch p.details " +
        "where p.id = :id",
        Product.class
)
.setParameter("id", productId)
.getSingleResult();

і

// LEFT JOIN FETCH: Product повернеться завжди, а details буде null, якщо рядка немає
var product = em.createQuery(
        "select p from Product p " +
        "left join fetch p.details " +
        "where p.id = :id",
        Product.class
)
.setParameter("id", productId)
.getSingleResult();

У першому випадку, якщо в товару немає details, запит узагалі не поверне Product (бо inner join виключить рядок). У другому випадку left join fetch скаже базі: «Поверни товар у будь-якому разі, а деталі — якщо вони є». Для optional-зв’язків це зазвичай саме те, що потрібно.

Корисна ментальна підказка: join fetch — це «зв’язок зобов’язаний існувати, інакше ми викидаємо батьківську сутність», а left join fetch — це «зв’язок опціональний, батьківську сутність не викидаємо». У реальному проєкті ви рідко хочете випадково втратити замовлення лише тому, що в нього немає чогось необов’язкового, тому на optional-зв’язках left join fetch — доволі частий вибір.

5. JOIN FETCH для to-many

Колекції (OneToMany, ManyToMany) — це те місце, де JOIN FETCH стає особливо спокусливим, бо саме колекції найчастіше породжують N+1. Ви читаєте список замовлень, а потім у циклі торкаєтеся order.getItems() — і все, ви в пастці.

Для детального читання одного замовлення це зазвичай виглядає дуже красиво: один SQL, усередині — join, і позиції замовлення вже завантажені.

Приклад на PurchaseOrder.items:

import jakarta.persistence.EntityManager;

public class PurchaseOrderQuery {

    private final EntityManager em;

    public PurchaseOrderQuery(EntityManager em) {
        this.em = em;
    }

    public PurchaseOrder findWithItems(long orderId) {
        // LEFT JOIN FETCH: замовлення повернеться навіть коли items порожні (колекція буде просто порожньою)
        return em.createQuery(
                """
                select distinct o from PurchaseOrder o
                left join fetch o.items
                where o.id = :id
                """,
                PurchaseOrder.class
        )
        .setParameter("id", orderId)
        .getSingleResult();
    }
}

І так, тут краще одразу звикати до форми select distinct o. Для читання одного замовлення за id дублікати кореня легко не помітити, але join по колекції вже розмножує рядки результату на кожну позицію. Сам червоний прапорець важливо побачити відразу: collection JOIN FETCH вирішує N+1, але платить за це формою результату.

Чому тут часто використовують left join fetch, а не join fetch? Тому що замовлення теоретично може існувати без позицій (чернетка, порожнє замовлення в тестах, помилка даних). left join fetch дає змогу отримати замовлення навіть тоді, коли items порожні.

З погляду того, що змінилося в SQL, у вас тепер буде join із таблицею order_item, і база поверне дані замовлення та кожної позиції. Hibernate на основі цього результату збере один об’єкт PurchaseOrder і заповнить колекцію items.

На цьому місці важливо зупинити внутрішнього перфекціоніста, який хоче тут же застосувати JOIN FETCH взагалі всюди. На to-many є нюанс: join роздуває число рядків результату, тому що один рядок замовлення повторюється для кожної позиції. Це не помилка, а математика. Тут достатньо помітити сам червоний прапорець: JOIN FETCH колекції — потужний інструмент, але в нього є ціна у вигляді дублікатів кореня, DISTINCT і чутливості до пагінації.

6. JOIN FETCH у коді проєкту

Коли ви зрозуміли, що JOIN FETCH — це частина read use case, виникає практичне питання: де це має жити? У сервісі? У репозиторії? В окремому query-компоненті?

У нашому курсі проєкт організовано package-by-feature, і від початку є ідея: query-орієнтований код варто тримати окремо, щоб не перетворювати Repository на «все про все». Тому два типові варіанти — або окремий клас у orders.query з EntityManager, або Spring Data-метод із @Query (коли це доречно й читабельно). Важливо, що в обох випадках ви явно показуєте, який fetch-план потрібен.

Для такого детального читання зручно тримати один упізнаваний шаблон: select distinct o, join fetch для обов’язкового customer, left join fetch для items, які можуть бути порожніми.

Варіант 1: query-клас (зазвичай найпрозоріший для глибокого розбору, бо ви прямо бачите JPQL):

import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Repository;

@Repository
public class PurchaseOrderQueryRepository {

    private final EntityManager em;

    public PurchaseOrderQueryRepository(EntityManager em) {
        this.em = em;
    }

    public PurchaseOrder getDetailed(long orderId) {
        // Комбінація fetch-планів:
        // - customer зазвичай обов’язковий -> join fetch
        // - items може бути порожньою колекцією -> left join fetch
        return em.createQuery(
                """
                select distinct o from PurchaseOrder o
                join fetch o.customer
                left join fetch o.items
                where o.id = :id
                """,
                PurchaseOrder.class
        )
        .setParameter("id", orderId)
        .getSingleResult();
    }
}

Зверніть увагу на комбінацію: для customer ми використовуємо join fetch (зазвичай обов’язковий зв’язок), а для itemsleft join fetch (колекція може бути порожньою). Це вже кандидат на нормальне детальне завантаження замовлення.

Варіант 2: Spring Data-репозиторій із @Query (теж нормально, якщо запит короткий і ви не ховаєте пів логіки в довгому рядку):

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

import java.util.Optional;

public interface PurchaseOrderRepository extends JpaRepository<PurchaseOrder, Long> {

    // Важливо: fetch-план описано прямо в запиті, а не "магічно" через звернення до lazy-полів
    @Query("""
        select distinct o from PurchaseOrder o
        join fetch o.customer
        left join fetch o.items
        where o.id = :id
        """)
    Optional<PurchaseOrder> findDetailedById(long id);
}

Ідея одна: fetch-план описано там само, де описано запит. Ви не розраховуєте на випадкову поведінку lazy loading і не змушуєте сервіс добирати дані в кілька етапів. При цьому ви не змінюєте анотації LAZY у сутностях і не ламаєте модель під один read-case.

Невеликий момент про транзакцію. В ідеалі детальне читання виконується всередині @Transactional(readOnly = true), щоб persistence context був активним і Hibernate міг спокійно зібрати граф об’єктів. Навіть якщо JOIN FETCH часто дає змогу потім читати вже завантажені зв’язки поза транзакцією, це не привід будувати архітектуру на «давайте витягнемо entity назовні, а далі якось воно житиме». У нашому курсі ми вчимося робити поведінку передбачуваною, а не «якось переживе».

7. Перевіряємо JOIN FETCH за SQL trace

Дуже хочеться повірити запиту на слово, але Hibernate — це система, де віра швидко перетворюється на суперечку з логами. Тому діємо за звичним workflow з попередніх днів: змінили fetch — подивилися SQL — порівняли.

Якщо ви читаєте замовлення без fetch join, часто побачите щось подібне:

select ... from purchase_order o where o.id = ?
-- Пізніше, під час звернення до lazy-колекції:
select ... from order_item i where i.order_id = ?

З JOIN FETCH для items SQL перетворюється на один запит із join:

select ...
from purchase_order o
left join order_item i on i.order_id = o.id
where o.id = ?
-- Колекцію items Hibernate збере з цього самого результату

Не потрібно намагатися запам’ятати точний SQL до останньої коми. Важливо побачити форму: було два окремих SELECT, стало один SELECT із JOIN.

Гарна вправа для себе (прямо як перевірка уважності): після впровадження JOIN FETCH спробуйте в коді спеціально торкнутися зв’язку, який раніше був лінивим, і переконатися, що нового SQL не з’явилося.

// Отримуємо замовлення вже із завантаженими зв’язками
var order = orderQueryRepository.getDetailed(orderId);

// Ці звернення не мають генерувати новий SQL, якщо fetch-план коректний
System.out.println(order.getCustomer().getEmail()); // already loaded
System.out.println(order.getItems().size());        // already loaded

Якщо в SQL trace під час цих звернень немає додаткових запитів — ви досягли мети.

І ще одна маленька психологічна підказка: іноді після JOIN FETCH здається, що все стало швидше, бо запитів менше. Але правильна інженерна звичка — дивитися не лише на кількість запитів, а й на форму результату. На to-one зазвичай усе добре, а на to-many ціна може бути в «роздуванні» результату за рядками. Ми не ігноруємо це — просто розкладаємо тему по лекціях, і наступна якраз про цю ціну.

8. Типові помилки під час JOIN FETCH

Помилка № 1: написати join, очікуючи поведінку join fetch.
Це дуже чесна помилка новачка: «ну я ж зробив join, отже зв’язок має бути в об’єкті». Але JPA так не обіцяє. Звичайний join — це передусім інструмент запиту (умови, сортування), а join fetch — інструмент завантаження графа. Якщо вам важливо гарантувати ініціалізацію зв’язку, не соромтеся слова fetch: це не косметика, а сенс.

Помилка № 2: використовувати join fetch як «новий EAGER за замовчуванням».
Після першої перемоги над N+1 з’являється ейфорія: «Ого, один запит замість десяти! Треба так зробити всюди». І саме в цей момент починається зворотний бік — важкі SQL-запити з великою кількістю рядків, непередбачувані result set’и та проблеми з пагінацією. JOIN FETCH — інструмент під конкретний read-case, особливо корисний для detail-read, але небезпечний як універсальний шаблон. Наступна лекція буде саме про те, чому.

Помилка № 3: переплутати join fetch і left join fetch та випадково втратити батьківську сутність.
Якщо зв’язок опціональний, join fetch (inner) виключить рядки, у яких зв’язку немає. У коді це виглядає як «замовлення ж є, чому не знайшли?», а в SQL усе чесно: inner join нічого не повернув. На optional-зв’язках краще починати з left join fetch і лише потім посилювати умову, якщо ви впевнені, що дані завжди є.

Помилка № 4: думати, що JOIN FETCH лагодить межі транзакції.
JOIN FETCH допомагає завантажити дані в межах конкретного читання, але він не робить архітектуру правильною автоматично. Якщо ви взагалі читаєте дані поза транзакцією або у вас сервісний шар розпадається на дрібні виклики репозиторія без зрозумілого unit of work, JOIN FETCH може тимчасово зменшити біль, але не замінить дисципліну транзакційних меж. Ми лікуємо конкретний fetch-symptom, а не переписуємо фізику часу.

Помилка № 5: не перевірити результат за SQL trace і вирішити, що «все точно стало одним запитом».
Іноді запит переписали, JOIN FETCH додали, а потім виявляється, що вторинний SELECT усе одно є — бо ви не той зв’язок підвантажили, або торкаєтеся іншої колекції, або логування toString() викликає ще щось ліниве. У світі Hibernate перевірка одна: увімкнули sql-trace, зробили сценарій, подивилися на SQL. І лише потім радіємо, інакше радість буде короткою, як безкоштовний пробний період.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ