JavaRush /Курси /Hibernate deep-dive /Розбір зламаного зв’язку в Hibernate

Розбір зламаного зв’язку в Hibernate

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

1. Вступ

Коли базові правила вже, здається, зрозумілі, особливо легко повірити відлагоджувачу: «Ну от же, у order.getItems() лежить item — отже, усе добре». Але Hibernate — не психолог і не телепат. Він не лікує самооцінку, а просто генерує SQL під час flush(), орієнтуючись на owning side і правила lifecycle. Тому «зламаний зв’язок» найчастіше проявляється так: у пам’яті все красиво, а в базі — ні (або навпаки).

Зламаний двонапрямний зв’язок зазвичай має один із трьох побутових симптомів. Перший — ви створили PurchaseOrder, додали до нього OrderItem, зберегли… і раптом бачите в SQL спробу вставити order_item з order_id = null (або ловите обмеження NOT NULL, якщо схема дисциплінована). Другий — ви очікуєте, що видалення позиції із замовлення призведе до DELETE, але бачите тишу в журналі або несподіваний UPDATE, а в базі позиція й далі живе своїм окремим життям, як кіт, який вважає, що ви у нього живете. Третій — ви впевнені, що каскади «все зроблять», а потім з’ясовується, що дочірня сутність узагалі не збереглася, бо каскад було налаштовано не там або не на тій колекції.

Ключова думка: «поломка» майже ніколи не в репозиторії і не в кількості save(). Вона в тому, що об’єктна модель у пам’яті та модель зв’язку в базі даних розходяться, а Hibernate за замовчуванням не намагається вгадати, яке з ваших посилань «правильне». Він бере owning side і робить саме те, що ви йому сказали, навіть якщо це сталося випадково.

2. Алгоритм діагностики: мапінг → flush()

Коли зв’язок поводиться дивно, хочеться почати крутити ручки: додати cascade = ALL, потім ще один save(), потім «про всяк випадок» зробити fetch = EAGER, а тоді вже остаточно втратити контроль і ввімкнути OSIV (але в нас він вимкнений, і це добре). Ми діятимемо інакше: спочатку читаємо мапінг, потім відтворюємо мінімальний сценарій, а вже потім дивимося SQL. Це нудно, зате ефективно — приблизно як чистити зуби.

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

Крок діагностики Питання, яке ви ставите Де дивитися Що вважається нормальним
1 Де фізично живе зовнішній ключ? У таблицях і в @JoinColumn FK розташований там, де ви очікуєте (наприклад, order_item.order_id)
2 Яка сторона є owning side? Зазвичай @ManyToOne у дочірній сутності Змінюємо owning side → бачимо очікуваний SQL на flush()
3 mappedBy вказує на правильне поле? У @OneToMany(mappedBy="...") Значення mappedBy збігається з ім’ям Java‑поля у дочірній сутності
4 Обидві сторони зв’язку синхронізуються в пам’яті? У helper-методах і сервісі add/remove змінюють і колекцію, і owning-посилання
5 Cascade відповідає життєвому циклу? На стороні parent (зазвичай) Каскад є там, де дочірня сутність справді живе разом із parent
6 orphanRemoval відповідає правилу «child без parent не живе»? На @OneToMany Розрив зв’язку приводить до DELETE, і це очікувано
7 SQL підтверджує вашу модель? SQL-лог після flush() У журналі саме ті INSERT/UPDATE/DELETE, яких ви очікуєте

Щоб це запам’яталося не як «таблиця з підручника», а як робоча схема, давайте зобразимо той самий алгоритм у вигляді маленької блок-схеми: так простіше тримати в голові порядок дій, коли ви нервуєте й хочете просто «щоб працювало».

flowchart TD
    A["Дивна поведінка зв’язку"] --> B["Читаємо мапінг: де FK і хто owning side?"]
    B --> C["Перевіряємо mappedBy (імʼя Java-поля, а не колонки)"]
    C --> D["Перевіряємо helper-методи: обидві сторони синхронізуються?"]
    D --> E["Перевіряємо lifecycle: cascade і orphanRemoval за змістом"]
    E --> F["Відтворюємо мінімальний сценарій у транзакції"]
    F --> G["Дотискаємо до flush"]
    G --> H["Дивимося SQL: збіглося з очікуванням?"]
    H -->|ні| B
    H -->|так| I["Зв’язок здоровий: код і SQL розповідають одну історію"]

І так, це «петля». Якщо SQL не збігся з очікуваннями, ви повертаєтеся до мапінгу, бо Hibernate, як правило, не «псує дані зі злості». Він просто чесно виконує ту модель, яку ви описали анотаціями та станом об’єктів.

3. Мінімальний сценарій: замовлення і позиція

Діагностика найшвидше працює на мінімальному сценарії: один PurchaseOrder, один OrderItem. Нам не потрібна нова теорія про owner; нам потрібна маленька транзакція, де відразу видно, чи збігаються мапінг, стан об’єктів і SQL. Тому ми спеціально робимо сценарій маленьким і додаємо примусовий flush(), щоб побачити правду тут і зараз.

Уявімо, що в нас є навчальний сервіс у проєкті Commerce Persistence Lab, який ми використовуємо як лабораторний інструмент. Він не мусить бути красивим; його завдання — відтворюваність і спостережуваність.

import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderRelationLabService {
    // EntityManager потрібен нам саме як "пульт керування" flush() у лабораторії
    private final EntityManager em;

    public OrderRelationLabService(EntityManager em) {
        // Dependency Injection: у тестовому сервісі це просто "дайте доступ до Persistence Context"
        this.em = em;
    }

    @Transactional
    public void flushNow() {
        // Примусово надсилаємо накопичені зміни до БД, щоб побачити SQL прямо зараз
        em.flush();
    }
}

Сам по собі цей клас безглуздий, але він показує важливу річ: «бачити SQL» — це не ворожіння на кавовій гущі, а керована дія. У реальній лабораторії ви додасте метод, який створює сутності, пов’язує їх правильно або неправильно, а потім викликає flush().

Тепер — «зламаний» сценарій. Тут неважливо, через що дочірня сутність потрапить у persistence context — через cascade чи через окремий persist(). Важливий сам симптом: inverse side говорить «зв’язок є», а owning side — «зв’язку немає».

import java.util.ArrayList;
import java.util.List;

public class PurchaseOrder {
    // Важливо: це inverse side. Сам факт наявності item у цій колекції НЕ гарантує FK у БД.
    private List<OrderItem> items = new ArrayList<>();

    public List<OrderItem> getItems() {
        // Повертаємо колекцію "як є": без helper-методів тут легко зламати синхронізацію
        return items;
    }
}
PurchaseOrder order = new PurchaseOrder();
OrderItem item = new OrderItem();

// Змінюємо лише inverse side: у пам’яті "здається, що все добре"
order.getItems().add(item);

em.persist(order);             // persist сам по собі не перетворює inverse side на джерело істини для FK
em.flush();                    // у цей момент стає видно, чи логічний цей зв’язок для БД

У відлагоджувачі ви побачите: так, items.size() став 1. Але SQL-лог може показати зовсім іншу картину. Якщо OrderItem при цьому зберігається як частина графа, Hibernate впирається в порожній owner: order_id брати нізвідки. Далі вже говорить схема — або спроба записати null, або constraint violation, якщо FK оголошено як NOT NULL.

Тепер «правильний» мінімальний сценарій виглядає трохи менш «красиво», але для бази значно чесніше:

PurchaseOrder order = new PurchaseOrder();
OrderItem item = new OrderItem();

// 1) owning side: саме це поле керує FK (order_id)
item.setOrder(order);

// 2) inverse side: щоб граф у пам’яті також був консистентним
order.getItems().add(item);

em.persist(order);
em.flush(); // якщо мапінг і каскади коректні, у SQL буде INSERT із заповненим order_id

Далі вступають у дію правила lifecycle. Якщо OrderItem справді живе всередині PurchaseOrder і окремо не потрібен, для нашого варіанта достатньо PERSIST і REMOVE: створення та видалення йдуть разом із замовленням, оновлення child усе одно підхоплює dirty checking, а orphanRemoval = true відповідає за delete-on-unlink.

З погляду програміста-ледаря — прикро, що потрібно два рухи замість одного. З погляду передбачуваності — чудово: ви явно синхронізували обидві сторони, і тепер Hibernate не має нічого вгадувати.

А далі ми зробимо важливий крок: перетворимо «два рухи» на один, через helper-метод. Але спочатку потрібно чітко зрозуміти, де саме ламається зв’язок — і чому це видно лише після flush().

4. Патерни поломки: запахи в коді

Зв’язки ламаються зазвичай доволі одноманітно: код компілюється, граф у пам’яті виглядає переконливо, а SQL розповідає іншу історію. Нижче — не нова теорія, а набір запахів, за якими швидко видно, де relation розійшовся між об’єктами та БД.

Найчастіший варіант — зміна лише inverse side. Тобто ви змінюєте колекцію order.items, але не чіпаєте item.order. Для людини це виглядає як «я ж додав елемент у список, отже він належить замовленню». Для Hibernate це означає: «ви змінили лише зручну навігацію, але не сторону, яка керує FK». У підсумку flush() або нічого не робить зі зв’язком, або виконує insert/update у несподіваному вигляді.

Щоб було зрозуміло, де тут owning side, покажемо мінімальний мапінг (без полів домену — нам зараз важлива механіка):

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;

@Entity
public class OrderItem {
    @Id
    @GeneratedValue
    private Long id;

    // Owning side: саме це поле буде джерелом істини для зовнішнього ключа в БД
    @ManyToOne
    private PurchaseOrder order;

    public void setOrder(PurchaseOrder order) {
        // Якщо це поле не виставлене, Hibernate не зможе заповнити FK (order_id)
        this.order = order;
    }
}

У OrderItem є поле order. Саме воно стає джерелом істини для зовнішнього ключа (order_id). Якщо ви його не змінюєте, Hibernate нічого не запише у FK.

Другий патерн поломки виглядає навпаки: ви змінюєте лише owning side (item.setOrder(order)), але забуваєте додати item до колекції order.items. Це часто трапляється, коли логіка створення item знаходиться «десь далеко» (наприклад, в іншому методі), а замовлення створюється окремо. У базі все може зберегтися коректно, якщо ви окремо persist(item), але в пам’яті замовлення залишиться без елементів. І це неприємно саме тому, що ви можете потім у тій самій транзакції, наприклад, порахувати суму замовлення через order.getItems() і отримати нуль, хоча в БД уже лежать позиції. Hibernate не зобов’язаний автоматично підтягувати inverse-колекцію, якщо ви не сказали йому про це через helper-метод або явну синхронізацію.

Третій патерн — спроба лікувати проблему «магічними» викликами репозиторію. Наприклад, додати save(order) ще раз, або save(item) «про всяк випадок», або взагалі перейти на saveAndFlush, бо «так надійніше». Це майже завжди симптом того, що ви намагаєтеся замінити розуміння моделі на ритуал. Якщо owning side не оновлений, ще один save() нічого не змінить. Він лише ще раз проганяє Hibernate через стан сутностей — і знову приводить до того самого висновку.

Тут дуже корисно тримати в голові коротку формулу: репозиторій зберігає сутності, але не вгадує зміст зв’язку. Зміст зв’язку задається тим, які посилання ви реально виставили в об’єктній моделі. Hibernate не читає ваші наміри, він читає поля.

5. Виправлення: helper-методи і lifecycle

Коли такі поломки повторюються, обіцянка «буду уважнішим у сервісі» швидко перестає працювати. Зв’язок має збиратися правильно в одному місці, а не залежати від пам’яті розробника. Тому лікується це не ще одним save(), а helper-методом і зрозумілим lifecycle для дочірньої сутності.

Ось мінімальний helper-метод для PurchaseOrder — короткий, щоб його справді читали:

import java.util.ArrayList;
import java.util.List;

public class PurchaseOrder {
    private final List<OrderItem> items = new ArrayList<>();

    public void addItem(OrderItem item) {
        // Inverse side: оновлюємо навігацію в пам’яті
        items.add(item);

        // Owning side: оновлюємо поле, яке керує FK у БД
        item.setOrder(this);
    }
}

І симетричний removeItem, який не менш важливий, ніж addItem. Тут особливо критично не залишити «висячим» owning-посилання в дочірній сутності.

public void removeItem(OrderItem item) {
    // Inverse side: прибираємо з колекції
    items.remove(item);

    // Owning side: розриваємо зв’язок, інакше orphanRemoval може не спрацювати
    item.setOrder(null);
}

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

PurchaseOrder order = new PurchaseOrder();
OrderItem item = new OrderItem();

order.addItem(item);   // одна дія замість двох (і обидві сторони синхронізовані)
em.persist(order);
em.flush();            // перевіряємо, що SQL відповідає очікуванням

Далі вмикається lifecycle. Якщо OrderItem справді живе всередині PurchaseOrder і окремо не потрібен, для нашого робочого варіанта достатньо PERSIST і REMOVE: створення та видалення йдуть разом із замовленням, оновлення child усе одно підхоплює dirty checking, а orphanRemoval = true відповідає за delete-on-unlink.

Приклад мапінгу, який у нашому проєкті зазвичай доречний для замовлення та його позицій:

import jakarta.persistence.CascadeType;
import jakarta.persistence.OneToMany;
import java.util.ArrayList;
import java.util.List;

public class PurchaseOrder {
    // CascadeType.PERSIST + REMOVE: замовлення створює позиції разом із собою і видаляє їх разом із собою
    // orphanRemoval = true: позиція без замовлення "не живе", при розриві зв’язку очікуємо DELETE
    @OneToMany(
            mappedBy = "order",
            cascade = {CascadeType.PERSIST, CascadeType.REMOVE},
            orphanRemoval = true
    )
    private List<OrderItem> items = new ArrayList<>();
}

Із orphanRemoval = true зв’язок стає ще більш «живим»: якщо позицію прибрали із замовлення через removeItem, вона видалиться з бази. Це зручно, але лише якщо це справді ваше правило домену. І знову ж таки: без коректного removeItem orphanRemoval може не спрацювати, бо ви формально не розірвали зв’язок на owning side.

6. SQL-лог як протокол дій

SQL-лог у цьому курсі — не «щось для DBA». Це ваш головний спосіб переконатися, що Hibernate робить саме те, що ви думаєте. Але читати SQL-лог теж треба правильно: не як нескінченну стіну тексту, а як протокол дій. У контексті зв’язку PurchaseOrderOrderItem нас цікавлять лише кілька типів операцій: вставка замовлення, вставка позиції, оновлення FK, видалення позиції.

Якщо зв’язок налаштований і використовується правильно, у мінімальному сценарії створення замовлення з однією позицією ви очікуєте таку послідовність. Спочатку INSERT у таблицю замовлення, бо замовлення — root. Потім INSERT у таблицю позиції, де order_id уже заповнений. На PostgreSQL із SEQUENCE це зазвичай відбувається без додаткових UPDATE, тому що id замовлення відомий до вставки позиції — Hibernate може отримати його із sequence.

Умовно це виглядає так (формат SQL у журналі може відрізнятися, але зміст один):

insert into purchase_order (order_number, ...) values ('PO-1', ...);   -- вставили замовлення (root)
insert into order_item (order_id, quantity, ...) values (101, 1, ...); -- вставили позицію з FK (order_id != null)

Якщо ви бачите замість цього вставку order_item із order_id = null, це майже завжди означає: owning side не виставлений. Тобто item.order залишився null, а ви змінювали лише колекцію order.items. У цей момент SQL-лог — ваш чесний союзник. Він каже: «Ти можеш скільки завгодно дивитися на список у пам’яті, але в базу ти намагаєшся вставити рядок без FK».

Під час видалення позиції із замовлення з orphanRemoval = true ви очікуєте DELETE по order_item. І важливо: це має бути видно саме після flush(). Видалили з колекції, зробили flush() — побачили DELETE. Якщо DELETE немає, зв’язок не розірвано з точки зору Hibernate. Найчастіше причина знову в тому, що ви прибрали item із колекції, але не занулили owning side (item.setOrder(null)), і Hibernate формально бачить, що зв’язок усе ще існує, бо він існує на owning side.

Подібна ситуація буває і з каскадами. Якщо ви очікуєте, що persist(order) збереже позиції, а в SQL бачите лише вставку замовлення, це означає, що каскад не спрацював. І найчастіше він не спрацьовує не тому, що «Hibernate забув про каскад», а тому, що item узагалі не був доданий до колекції order.items, а cascade налаштовано саме на ній. Тобто ви виставили item.order, але не включили item до cascade-графа.

Ця частина особливо важлива психологічно: SQL допомагає перестати сперечатися із самим собою. Або запит пішов, або ні. Або FK заповнений, або ні. І щойно ви починаєте читати SQL як протокол, налагодження зв’язків стає нудним і швидким (а це комплімент).

7. Типові помилки під час роботи зі зв’язками

Помилка №1: зміна лише inverse side (order.getItems().add(item)).
Це класика, бо людська логіка каже: «я додав елемент у список, отже він належить замовленню». Але Hibernate під час flush() ухвалює рішення за owning side, і якщо item.order не виставлений, ви отримаєте або order_id = null, або відсутність зміни зв’язку. Лікується не репозиторіями, а helper-методом, який гарантовано ставить owning-посилання.

Помилка №2: очікування каскаду без включення дочірньої сутності до графа.
Каскад спрацьовує лише на тих зв’язках, де його налаштовано. Якщо cascade = PERSIST стоїть на PurchaseOrder.items, а item ви туди не додали, то persist(order) не зобов’язаний зберігати item. Виходить кумедний ефект: owning side правильний, але item не потрапив у persistence context як частина графа. Лікується тим самим helper-методом: він робить обидва рухи, і каскад починає працювати передбачувано.

Помилка №3: спроба «полагодити» зв’язок додатковим save().
Зайвий save() часто маскує проблему, а не розв’язує її. Якщо owning side не виставлений, save() не перетворюється на магію. Він просто повторно запускає звичайний механізм persist/merge і приводить до тих самих рішень під час flush(). У результаті ви отримуєте код, який виглядає «надійним», але насправді просто важче читається.

Помилка №4: неправильне значення mappedBy.
Дуже хочеться написати в mappedBy ім’я колонки (order_id) або щось подібне. Але mappedBy — це ім’я Java‑поля в дочірній сутності, наприклад "order". Помилка тут зазвичай виявляється рано (застосунок падає на старті з mapping exception), але іноді студент починає «лікувати» її іншими анотаціями і тільки погіршує ситуацію. Лікується просто: mappedBy має точно збігатися з ім’ям поля owning side.

Помилка №5: orphanRemoval = true, але remove-логіка змінює лише колекцію.
Якщо ви прибрали елемент із order.items, але залишили item.order вказувати на замовлення, ви не розірвали зв’язок там, де ним керують. Hibernate не зобов’язаний видаляти child, бо формально він і далі пов’язаний із parent. Тому коректний removeItem завжди робить два рухи: items.remove(...) і item.setOrder(null).

1
Опитування
Зв’язки Hibernate, рівень 11, лекція 4
Недоступний
Зв’язки Hibernate
Зв’язки, каскади та orphanRemoval
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ