JavaRush /Курси /Spring Data JPA /@EntityGraph у метода...

@EntityGraph у методах репозиторію

Spring Data JPA
Рівень 22 , Лекція 2
Відкрита

1. Коли потрібен @EntityGraph замість join fetch

Коли ви вперше долаєте N+1 через join fetch, виникає відчуття: «Ну все, життя вдалося, можна ставити join fetch усюди й жити щасливо». А потім ви ловите себе на тому, що навіть найбанальніший findById раптом перетворюється на громіздкий JPQL-запит, а репозиторій починає скидатися на збірник заклинань. @EntityGraph потрібен саме тоді, коли запит простий, а план завантаження — ні.

Уявіть собі типовий сценарій нашого shop-data-jpa. Ми хочемо показати «картку товару»: поля товару (sku, name, price, status) і назву категорії. Мапінг у нас правильний і обережний: Product -> Category найімовірніше LAZY (бо категорія потрібна не завжди, і точно не хочеться вантажити її без потреби).

Якщо ми зробимо:

productRepository.findById(id)

то зв’язок category може бути не завантажений, і при першому зверненні до product.getCategory() Hibernate надішле окремий SELECT. Для одного товару це ще терпимо, але для списків — це типова дорога в N+1.

Ми можемо розвʼязати проблему через join fetch і написати JPQL-запит. Але тут з’являється дуже практична незручність: критерій «за id» і так уже ідеально виражається стандартним методом репозиторію або похідним запитом. Не хочеться перетворювати простий метод на кастомний запит лише заради того, щоб підтягнути категорію.

І тут корисно розділити дві думки.

- Перша думка: логіка вибірки — це «кого» ми шукаємо (за id, за sku, за статусом).
- Друга думка: план завантаження — це «що має бути готове» в знайденого об’єкта до завершення сервісного методу (категорія, позиції замовлення, товар у позиції замовлення тощо).

@EntityGraph — це спосіб зробити другу думку видимою й керованою, не переписуючи першу.

2. Що робить @EntityGraph

Коли ви бачите слово “graph”, мозок новачка іноді малює щось страшне: “граф сутностей”, “граф залежностей”, “граф проблем у моєму житті”. На практиці все простіше: entity graph — це список зв’язків, які потрібно завантажити відразу, у межах одного читання. @EntityGraph у Spring Data JPA — це анотація, якою ми кажемо: «Для цього методу репозиторію завантаж ось ці зв’язки заздалегідь».

Найважливіше: @EntityGraph не змінює зміст запиту. Він не додає фільтри, не «підправляє» WHERE, не перетворює findByStatus на “findByStatusAndSomethingElse”. Він впливає лише на те, які зв’язки будуть ініціалізовані в межах одного читання.

Щоб краще це відчути, зручно тримати в голові такий конвеєр:

flowchart TD
    S["Сервісний read-use-case"] --> R["Метод репозиторію"]
    R --> Q["Логіка запиту: WHERE / ORDER BY"]
    R --> F["Fetch-план: які зв'язки завантажити"]
    Q --> JPA["Провайдер JPA: Hibernate"]
    F --> JPA
    JPA --> SQL["Згенерований SQL"]
    SQL --> PC["Persistence Context"]

Логіка запиту відповідає на питання «які рядки або сутності мені потрібні». Fetch-план відповідає на питання «які пов’язані дані мають бути завантажені разом із ними».

attributePaths: як ми описуємо, що вантажити

В анотації @EntityGraph є ключовий параметр attributePaths. Це масив рядків — шляхів до зв’язків. Шлях пишеться так, як ви зверталися б до полів у Java, але рядком: "category", "items", "items.product".

Звучить просто, але в цьому і сила, і небезпека. Сила — тому що ви прямо на методі вказуєте потрібне завантаження. Небезпека — тому що дуже легко почати писати «про всяк випадок» "items.product.category.manufacturer.someOtherThing" і отримати зайве завантаження, яке раптово стане вашим новим «чому все так повільно».

Декларативність без магії

join fetch — це частина JPQL, ви явно пишете «як» хочете завантажити.
@EntityGraph — це декларація «що» ви хочете отримати ініціалізованим. Hibernate (як провайдер JPA) під капотом зазвичай реалізує це через join fetch (особливо для to-one зв’язків), але важливе в навчальному сенсі ось що: ви відділяєте план завантаження від тексту запиту.

3. Базовий приклад: картка товару Product разом із Category без JPQL

Коли ми говоримо «картка товару», ми зазвичай маємо на увазі use-case: отримати один товар і відразу мати доступ до його категорії в межах того самого сценарію читання. Це ідеальний сценарій для @EntityGraph, тому що критерій вибірки простий (за id), а потрібний зв’язок зрозумілий (category).

Спочатку нагадаю, як виглядає мапінг (спрощено). Зверніть увагу на LAZY: це наш default, а не «потім розберемося».

import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;

public class Product {

    // Важливо: LAZY — це дефолтна «гігієна», щоб не тягнути категорію під час кожного читання товару.
    @ManyToOne(fetch = FetchType.LAZY)
    // Явно фіксуємо FK-колонку, щоб було зрозуміло, звідки береться зв'язок на рівні БД.
    @JoinColumn(name = "category_id", nullable = false)
    private Category category;
}

Тепер — репозиторій. Ми не хочемо писати JPQL, бо критерій «за id» уже чудово виражається похідним запитом. Зробимо окремий «говорящий» метод саме під картку й анотуємо його графом.

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

import java.util.Optional;

public interface ProductRepository extends JpaRepository<Product, Long> {

    // Під час читання картки продукту наперед ініціалізуй зв'язок category.
    @EntityGraph(attributePaths = "category")
    Optional<Product> findCardById(Long id);
}

Тут важливий момент, який часто дивує новачків: частина імені до By може бути довільною, а Spring Data все одно розбере критерій за частиною після By. Метод findCardById за змістом так само шукає за id, просто назва чесно підказує, що це читання під картку.

Тепер — сервісний метод читання. Нам важливо, щоб use-case жив у транзакції, хай і read-only, і щоб назовні не «витікало» незаплановане lazy-читання.

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CatalogQueryService {

    private final ProductRepository productRepository;

    public CatalogQueryService(ProductRepository productRepository) {
        // Впроваджуємо репозиторій як залежність: сценарій читання керує даними, а не контролер.
        this.productRepository = productRepository;
    }

    @Transactional(readOnly = true) // Важливо: усе читання відбувається всередині persistence context.
    public String loadProductCategoryName(Long id) {
        // Використовуємо метод з entity graph: очікуємо, що category уже буде ініціалізована.
        Product product = productRepository.findCardById(id).orElseThrow();
        // Без графа тут міг би «вистрілити» зайвий SELECT через лінивий зв'язок.
        return product.getCategory().getName();
    }
}

Що ми виграємо? В ідеальному світі — і в нашому навчальному світі з Hibernate 7 це якраз типово — ви побачите в SQL-логах один запит із join до таблиці категорій, а не «спочатку товар, потім окремий select за категорією».

Приблизний силует SQL буде схожий на:

select p.*, c.*
from product p
join category c on c.id = p.category_id
where p.id = ?

Будь ласка, не сприймайте це як «обов’язковий текст SQL» — конкретні псевдоніми й список колонок будуть іншими. Важливо, що категорія завантажується одразу і product.getCategory().getName() не стріляє додатковим запитом.

4. Вкладені attributePaths: картка замовлення CustomerOrder

Якщо приклад із товаром здається занадто «м’яким», то приклад із замовленням зазвичай одразу показує, навіщо взагалі потрібен свідомий fetch-plan. Замовлення (CustomerOrder) саме по собі майже ніколи не цікаве без позицій (OrderItem). А позиція замовлення часто потрібна разом із товаром (product). Тобто картка замовлення — це не один об’єкт, а маленький, але конкретний граф.

Почнемо з очевидного: зв’язки за замовчуванням залишаються LAZY. Замовлення може мати багато позицій, і ми точно не хочемо, щоб кожне читання замовлення автоматично тягнуло їх, а потім ще товари, а потім категорії, а потім виробників, а потім… ви зрозуміли.

import jakarta.persistence.FetchType;
import jakarta.persistence.OneToMany;

public class CustomerOrder {

    // Колекція — потенційно «важкий» зв'язок, тому за замовчуванням тримаємо LAZY.
    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    private java.util.List<OrderItem> items;
}

Тепер у репозиторії ми заводимо метод «картки замовлення» й указуємо граф. Тут ключовий момент: вкладені шляхи.

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

import java.util.Optional;

public interface CustomerOrderRepository extends JpaRepository<CustomerOrder, Long> {

    // Ініціалізуємо не лише items, а й вкладений зв'язок product всередині кожної позиції.
    // Це і є «картка замовлення»: дані готові в межах одного читання.
    @EntityGraph(attributePaths = {"items", "items.product"})
    Optional<CustomerOrder> findCardByOrderNumber(String orderNumber);
}

Сенс attributePaths тут такий: спочатку ініціалізуй колекцію items, а для кожного OrderItem ініціалізуй product.

І так, це вже to-many завантаження. А отже, у нього є ціна. У більшості випадків Hibernate реалізує це через join-и, і ви отримуєте «широкий» результат, де один рядок замовлення множиться на кількість позицій на рівні рядків SQL. У persistence context Hibernate потім акуратно збирає це назад в один об’єкт CustomerOrder із колекцією items.

Чому це нормально саме для картки замовлення? Тому що картка — це одне замовлення, тобто розмір результату обмежений. У замовлення може бути 3 позиції або 20 — але це все ще «людський» обсяг, який ми можемо дозволити собі одним читанням.

Сервісний метод для такого read-use-case виглядає максимально нудно, і це добре: нудний код часто є найправильнішим.

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderQueryService {

    private final CustomerOrderRepository orderRepository;

    public OrderQueryService(CustomerOrderRepository orderRepository) {
        // Репозиторій інкапсулює доступ до даних, сервіс — use-case і межі транзакції.
        this.orderRepository = orderRepository;
    }

    @Transactional(readOnly = true) // Важливо: колекції мають ініціалізуватися всередині транзакції.
    public int loadItemCount(String orderNumber) {
        // Читаємо «картку»: очікуємо, що items уже завантажені графом.
        CustomerOrder order = orderRepository.findCardByOrderNumber(orderNumber).orElseThrow();
        // Якби items не були завантажені, тут міг би бути лінивий SELECT (або навіть N+1 у циклах).
        return order.getItems().size();
    }
}

Якщо ви ввімкнете SQL-логи, ви маєте побачити, що виклик order.getItems().size() не запускає «пачку додаткових SELECT». Він працює на вже завантажених даних.

І ось це відчуття — «дані готові всередині use-case» — і є метою @EntityGraph. Ми не робимо зовнішній шар щасливим «випадково», ми робимо сервісний read-use-case чесним і передбачуваним.

5. Похідні запити, @Query, пагінація

Коли @EntityGraph починає подобатися, виникає природна спокуса: «А давайте тепер просто навісимо @EntityGraph на все». Це приблизно як відкрити для себе гарячі клавіші в IDE і намагатися гарячою клавішею робити каву. Можна, але буде дивно. Давайте обережно розберемо кілька типових поєднань.

@EntityGraph + похідний запит

Це «золота зона» @EntityGraph: критерій вибірки виражається іменем методу, а план завантаження — анотацією. Наприклад, список активних товарів, де для кожного товару потрібна категорія.

import org.springframework.data.jpa.repository.EntityGraph;

import java.util.List;

public interface ProductRepository extends org.springframework.data.jpa.repository.JpaRepository<Product, Long> {

    // Для списку товарів підтягнемо to-one зв'язок: це зазвичай безпечніше, ніж тягнути колекції.
    @EntityGraph(attributePaths = "category")
    List<Product> findByStatus(ProductStatus status);
}

Для ManyToOne це зазвичай цілком безпечно. Категорія — to-one зв’язок, join не роздуває результат по рядках так, як колекції. Це хороший компроміс між простотою коду й контролем завантаження.

@EntityGraph + @Query (коли JPQL потрібен, але fetch хочеться окремо)

Іноді JPQL потрібен не заради fetch, а заради умов або порядку, який важко виразити похідним методом. У такому випадку можна зробити так: JPQL відповідає за «кого вибираємо», @EntityGraph — за «що підвантажуємо».

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

import java.util.List;

public interface CustomerOrderRepository extends org.springframework.data.jpa.repository.JpaRepository<CustomerOrder, Long> {

    // Граф відповідає за «що завантажити», а JPQL — за «кого вибрати».
    @EntityGraph(attributePaths = {"items", "items.product"})
    @Query("""
           select o
           from CustomerOrder o
           where o.status = :status
           """)
    List<CustomerOrder> findCardsByStatus(OrderStatus status);
}

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

Пагінація та @EntityGraph: обережно з колекціями

Тут діє та сама фізика, що й у collection join fetch: Page по кореневій сутності погано поєднується з підвантаженням to-many колекції. @EntityGraph не обходить це обмеження, він просто задає той самий fetch-plan іншим способом.

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

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.EntityGraph;

public interface CustomerOrderRepository extends org.springframework.data.jpa.repository.JpaRepository<CustomerOrder, Long> {

    // Увага: колекція + Page часто призводить до «множення рядків» на SQL-рівні.
    @EntityGraph(attributePaths = "items")
    Page<CustomerOrder> findByStatus(OrderStatus status, Pageable pageable);
}

Для to-one зв’язків пагінація зазвичай живе спокійніше, а для колекцій — ні. Якщо вам потрібна сторінка замовлень, items майже ніколи не варто тягнути «за замовчуванням»: спочатку запитайте себе, чи потрібен тут узагалі entity graph, чи чесніше одразу читати summary/read-модель.

EntityGraphType: FETCH і LOAD

Коли ви відкриваєте документацію, ви побачите, що в @EntityGraph є параметр type. Зазвичай там два значення: FETCH і LOAD. Ця тема легко перетворюється на болото «а давайте вивчимо специфікацію JPA на 300 сторінок», але нам потрібен короткий, практичний сенс.

Головна ідея така: graph може або жорстко задати, що вантажити, а решту вважати lazy, або додати перелічене до стандартного плану завантаження з мапінгу.

Тип графа Інтуїтивний сенс Як мислити в проєкті
FETCH «Вантаж лише те, що я перелічив, решта — ніби lazy» Хороший вибір для усвідомлених сценаріїв читання
LOAD «Вантаж те, що я перелічив, і збережи дефолти мапінгу» Може стати у пригоді, якщо в моделі є свідомі EAGER-дефолти

У нашому курсі ми намагаємося тримати зв’язки LAZY за замовчуванням, тому найчастіше вам вистачить дефолта, який зазвичай FETCH, або явного вказання FETCH для читабельності.

Приклад, радше для читабельності, ніж для необхідності:

import org.springframework.data.jpa.repository.EntityGraph;

import java.util.Optional;

public interface ProductRepository extends org.springframework.data.jpa.repository.JpaRepository<Product, Long> {

    // Явно фіксуємо тип графа, щоб читач бачив намір: завантажити лише перелічене.
    @EntityGraph(attributePaths = "category", type = EntityGraph.EntityGraphType.FETCH)
    Optional<Product> findDetailedById(Long id);
}

6. Практика @EntityGraph у shop-data-jpa

Коли команда вперше отримує в руки інструмент керування завантаженням, зазвичай є дві крайнощі. Перша — не використовувати його зовсім і потім лікувати N+1 панікою. Друга — навісити граф на все підряд і отримати надмірне завантаження, тільки тепер легалізоване. Нам потрібна доросла середина: @EntityGraph як частина контракту конкретного read-use-case.

Практично це означає, що в репозиторії корисно заводити методи з промовистими іменами під ключові сценарії. Наприклад, для товару можна мати «звичайне читання» (без графа, де зв’язки залишаються лінивими) і «картку» (із графом). Для замовлення — так само: «знайти замовлення» і «знайти картку замовлення».

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

Сценарій використання Що потрібно отримати Частий вибір
Картка товару Product + category @EntityGraph(attributePaths="category")
Картка замовлення CustomerOrder + items + items.product @EntityGraph(attributePaths={"items","items.product"})
Список товарів багато рядків, мало даних частіше — окрема read-модель (наступна лекція)
Список замовлень багато замовлень, у кожного багато items обережно: не тягнути items «за замовчуванням»

І ще один важливий організаційний момент: @EntityGraph — це не «лагодити лінивість», а проєктувати читання. Якщо ви відчуваєте, що граф починає розростатися, це сигнал: або use-case занадто важкий, або ви повертаєте занадто багато даних «про всяк випадок».

7. Типові помилки під час використання @EntityGraph

Помилка № 1: думати, що @EntityGraph змінює умови запиту.
Іноді розробник бачить, що метод називається findByStatus, додає @EntityGraph, і очікує, що «граф розумно підвантажить лише активні позиції» або «тепер буде інший join». Ні: умови задаються запитом — derived або @Query, а граф лише каже, що з знайденої сутності потрібно завантажити одразу. Якщо дані «не ті» — проблема у WHERE, а не в графі.

Помилка № 2: робити один величезний «універсальний граф на все».
Дуже спокусливо: «А давайте зробимо граф {"items", "items.product", "items.product.category", ...} і будемо викликати один метод». Це зазвичай закінчується тим, що ви постійно завантажуєте зайве, SQL стає ширшим, пам’ять тече швидше, а сенс use-case розмивається. Граф має бути вузьким, як хороший контракт: рівно те, що потрібно зараз.

Помилка № 3: намагатися лагодити посторінкові списки замовлень через завантаження колекцій.
@EntityGraph не скасовує фізику SQL. Якщо ви просите сторінку замовлень і одночасно просите завантажити items, ви майже напевно отримаєте важке читання. І так, це буде схоже на проблему “collection join fetch + pagination”, тільки тепер сховану в анотації. Граф найзручніше працює для карток і невеликих, обмежених читань.

Помилка № 4: використовувати @EntityGraph там, де вам узагалі не потрібна entity.
Якщо use-case — «показати таблицю з трьох колонок», то підвантажувати entity + зв’язки, а потім вручну «вирізати потрібне» — це як купити піаніно, щоб зіграти один звук «ля». Це технічно можливо, але архітектурно дивно. У таких сценаріях зазвичай логічніше одразу читати в окрему read-модель, але це тема наступної лекції.

Помилка № 5: вважати, що @EntityGraph замінює транзакційну дисципліну.
Граф допомагає завантажити потрібні зв’язки в момент читання, але він не перетворює об’єкт на «вічноживучий портал у базу». Якщо ви вийшли із сервісного методу й починаєте «дочитувати» щось, чого не було в графі, ви все одно впираєтеся в ту саму межу persistence context. Тому правильний порядок думок залишається тим самим: use-case → транзакційна межа → план завантаження → інструмент.

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