JavaRush /Курси /Hibernate deep-dive /Owning side і зовнішній ключ

Owning side і зовнішній ключ

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

1. Дві навігації в Java й один зовнішній ключ у таблиці

Коли ви вперше малюєте доменну модель у Java, дуже легко подумати так: «Оскільки в мене є посилання order -> items і посилання item -> order, отже зв’язок двобічний і обидві сторони однаково важливі». Це природна інтуїція для об’єктного світу. Проблема в тому, що база даних живе у світі таблиць, і їй байдуже на об’єктну романтику: у зв’язку майже завжди є одна фізична точка зберігання — конкретна колонка зовнішнього ключа.

Уявімо наш навчальний домен Commerce Persistence Lab: замовлення (PurchaseOrder) і позиції (OrderItem). У пам’яті ми хочемо ходити в обидва боки: із замовлення до позицій, щоб порахувати суму, і з позиції до замовлення, щоб зрозуміти, до чого вона належить. Але в реляційній моделі зв’язок «замовлення → позиції» зберігається так: у таблиці order_item є колонка order_id, яка посилається на рядок у таблиці purchase_order.

Можна уявити це як «два входи в один офіс»: з одного боку у вас двері «items», з іншого — двері «order», але замок, тобто те, що реально фіксує належність, знаходиться в одному місці — у колонці order_id.

Невелика схематична картинка (не UML, а «щоб мозок не сперечався»):

flowchart TD
    PO["PurchaseOrder
purchase_order.id"] -->|у Java: order.items| OI["OrderItem
order_item.order_id"] OI -->|у Java: item.order| PO OI -. у БД зберігається FK .-> FK["order_item.order_id"] FK -. посилається на .-> PK["purchase_order.id"]

Зверніть увагу на незручний факт: у Java в нас два посилання, а в БД — одна колонка. І саме навколо цієї колонки будується вся розмова про owning side.

2. Зовнішній ключ у схемі

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

Для зв’язку PurchaseOrderOrderItem мінімальна форма схеми виглядає приблизно так (спрощено):

create table purchase_order (
  id bigint primary key
);

create table order_item (
  id bigint primary key,
  order_id bigint references purchase_order(id)
);

Сенс у тому, що рядок order_item містить значення, яке визначає належність: order_id. Це і є «джерело істини» для бази даних.

І ось ключовий поворот: Hibernate має зрозуміти, з якої частини вашої Java-моделі він повинен взяти нове значення order_id, коли настане flush(). Він не може одночасно дивитися на все й вгадувати. Йому потрібне одне головне джерело. Так з’являється ідея: у двобічному зв’язку є керівна сторона.

Щоб це запам’ятати простіше, можна тримати в голові коротку формулу: «FK живе в таблиці — owner живе в полі, яке цей FK мапить.»

3. Owning side та inverse side

Тепер введемо два терміни, але по-людськи. Owning side — це сторона зв’язку, зміни якої Hibernate сприймає як команду: «Окей, треба оновити зовнішній ключ». Inverse side — це сторона для навігації, для зручності й для краси моделі, але вона не є джерелом істини для запису FK.

У класичній парі @OneToMany / @ManyToOne owning side майже завжди знаходиться на стороні @ManyToOne. Причина не релігійна, а суто фізична: зовнішній ключ знаходиться в таблиці «many», а отже мапити його найлогічніше полем сутності «many».

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

PurchaseOrder (зворотна сторона, тобто inverse side):

import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
class PurchaseOrder {
    @Id @GeneratedValue
    private Long id;

    // Inverse side: mappedBy вказує, що FK мапується полем OrderItem.order
    @OneToMany(mappedBy = "order")
    private List<OrderItem> items = new ArrayList<>();

    // Гетери й сетери опущено для стислості
}

OrderItem (керівна сторона, owning side):

import jakarta.persistence.*;

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

    // Owning side: це поле керує колонкою FK (order_item.order_id)
    @ManyToOne
    @JoinColumn(name = "order_id")
    private PurchaseOrder order;

    // Гетери/сетери опущено для стислості
}

Тут можна буквально побачити FK: @JoinColumn("order_id") — це прямий натяк, що Hibernate писатиме колонку order_id на підставі стану поля order у OrderItem.

А PurchaseOrder.items — це зручна колекція, по якій легко ходити, але вона сама по собі не містить того, що Hibernate повинен записати в таблицю order_item. Вона як список контактів у телефоні: зручно для вас, але юридично договір підписує той, у кого ручка й печатка.

Щоб закріпити, ось коротка таблиця-нагадування з людськими формулюваннями:

Що ви бачите в коді Де зберігається зв’язок у БД Owning side (керує FK) Inverse side (для навігації)
PurchaseOrder.items і OrderItem.order order_item.order_id OrderItem.order (@ManyToOne + @JoinColumn) PurchaseOrder.items (mappedBy=...)

Важливо: слово “owner” тут не про “власник за бізнесом”, не про “агрегації DDD” і не про “хто головний у кімнаті”. Це суто технічне: хто керує записом зовнішнього ключа.

4. Flush як момент істини

Якщо ви пам’ятаєте розмову про dirty checking і flush(), ви вже знаєте: Hibernate може досить довго терпіти ваші зміни, доки не настане момент синхронізації. До flush() ви можете змінювати поля керованих сутностей, додавати елементи в колекції, і все це житиме в persistence context як «план змін».

Але коли настає flush() (явний виклик, commit транзакції або flush-before-query), Hibernate зобов’язаний матеріалізувати ваші наміри в SQL. І тут він перестає бути психологом і стає бухгалтером: йому потрібні конкретні числа, конкретні колонки, конкретні значення.

Для зв’язку OrderItem.order це виглядає так: Hibernate має зрозуміти, чи змінилося значення зовнішнього ключа order_id. Він дивиться на owning side, тому що саме вона оголошує @JoinColumn. Якщо в owning side нове значення — буде SQL UPDATE або INSERT з новим order_id. Якщо owning side не змінювалася — Hibernate не бачить причин чіпати order_id, навіть якщо ви «красиво» переставили об’єкти в колекціях на inverse side.

Можна сформулювати без зайвої філософії: Hibernate не оновлює зовнішній ключ тому, що ви «хочете», а тому, що в нього є факт зміни owning side.

Щоб пов’язати це з тим, що ми вже проходили, ось мініфрагмент сервісного методу, де ми спеціально робимо flush(), щоб побачити правду в SQL-логах:

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

@Service
public class OrderDebugService {
    private final EntityManager em;

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

    @Transactional
    public void forceFlush() {
        // Примусова синхронізація persistence context -> БД
        // Зручно, щоб довести сценарій до SQL і перевірити, що відбувається насправді
        em.flush();
    }
}

Так, метод смішний. Але він підкреслює важливу думку: усі розмови про owning side завжди закінчуються одним запитанням: «а що було в SQL після flush()?».

5. Анти-приклад: змінюємо лише inverse side — і база «не в курсі»

Зараз буде ситуація, яку в реальному проєкті легко створити випадково. Вона виглядає як цілком нормальний код: ми знайшли замовлення, знайшли позицію, додали позицію до замовлення. У налагоджувачі все красиво: у замовлення стало більше items. І тут починається найпідступніша частина: якщо ви не ввімкнули SQL-лог і не зробили flush(), можна місяць жити в упевненості, що все працює.

Поганий (але дуже життєвий) код:

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

@Service
public class OrderItemAttachService {
    private final EntityManager em;

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

    @Transactional
    public void attachWrong(long orderId, long itemId) {
        PurchaseOrder order = em.find(PurchaseOrder.class, orderId);
        OrderItem item = em.find(OrderItem.class, itemId);

        // Змінюємо лише inverse side (колекцію)
        // Важливо: це не команда для Hibernate оновити FK у таблиці order_item
        order.getItems().add(item);

        // У налагоджувачі й у пам’яті все виглядає "правильно"
        System.out.println(order.getItems().size()); // наприклад: 1

        // Тут ви чекаєте UPDATE... але його може не бути, тому що owning side не чіпали
        em.flush();
    }
}

Чому UPDATE може не з’явитися? Тому що owning side — це OrderItem.order, а ми її не чіпали. Для Hibernate це виглядає так: «у позиції як був order_id раніше (або null), так і залишився». Колекція в PurchaseOrder для Hibernate не є джерелом істини для FK.

Найнеприємніше, що в межах однієї транзакції ви реально можете бачити «правильну» картинку в пам’яті. Але база даних залишиться зі старим order_id. І коли пізніше ви прочитаєте це ж замовлення в новій транзакції, колекція items буде зібрана вже за даними БД — і «приклеєна» позиція раптово зникне. Це той самий баг, який зазвичай описують словами: «воно іноді працює, іноді ні, Hibernate дивний». Ні, не дивний. Просто ви змінювали не те.

Якщо вам потрібен короткий маркер реальності, ось він: order.getItems().add(item) змінює Java-колекцію, але не змінює колонку order_id.

6. SQL-коректний мінімальний приклад

Тепер зробимо майже те саме, тільки змінимо саме те поле, яке мапить зовнішній ключ. Це виглядає менш «колекційно», але значно чесніше для БД: ми говоримо Hibernate конкретно, яке значення має опинитися в order_item.order_id.

Правильний, керований варіант:

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

@Service
public class OrderItemAttachService2 {
    private final EntityManager em;

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

    @Transactional
    public void attachCorrect(long orderId, long itemId) {
        PurchaseOrder order = em.find(PurchaseOrder.class, orderId);
        OrderItem item = em.find(OrderItem.class, itemId);

        // Змінюємо owning side: саме це поле пише FK (order_item.order_id)
        item.setOrder(order);

        // Тепер після flush Hibernate бачить факт зміни: FK має оновитися
        em.flush(); // очікуємо SQL на кшталт: update order_item set order_id=? where id=?
    }
}

Для запису FK і для SQL цього вже достатньо: Hibernate бачить нове значення й розуміє, яким має стати order_id. Але це ще не вся робота зі зв’язком. Колекція order.items сама від цього не синхронізується, тому в межах тієї ж транзакції об’єктний граф може залишатися неповним, навіть якщо SQL уже буде коректним.

Що приблизно буде в SQL (спрощено, щоб було видно на око):

update order_item
set order_id = 1
where id = 10;

І ось тут відбувається важливе психологічне дорослішання: ви починаєте мислити як БД. Не «я додав у колекцію», а «я оновив зовнішній ключ». В об’єктній моделі ви можете й далі використовувати колекції для зручності, але точка керування залишається там, де зберігається FK.

А якщо ми створюємо нові сутності — нове замовлення і нову позицію, — owning side так само залишається ключовою. Мінісценарій — дуже коротко:

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

@Transactional
public void createOrderWithItem(EntityManager em) {
    PurchaseOrder order = new PurchaseOrder();
    OrderItem item = new OrderItem();

    // Зв’язок задаємо через owning side, щоб FK точно було проставлено
    item.setOrder(order);

    // Порядок persist тут не ключовий для ідеї owning side
    em.persist(order);
    em.persist(item);

    // Доводимо до SQL, щоб побачити INSERT/UPDATE з потрібним FK
    em.flush();
}

Навіть якщо пізніше у вас з’являться каскади (цю тему сьогодні не розглядаємо), логіка owning side нікуди не зникає. Зовнішній ключ усе одно буде заповнений, тому що ви змінили owning side.

7. Owning side в інших зв’язках

Є спокуса думати, що owning side — це тільки про @OneToMany/@ManyToOne. Але насправді принцип ширший: owning side — це та сторона, яка визначає фізичний спосіб зберігання зв’язку в БД. У більшості практичних випадків це сторона з @JoinColumn.

Наприклад, у нас є Customer і CustomerAddress. У таблиці адрес буде колонка customer_id, отже owning side — у CustomerAddress:

import jakarta.persistence.*;

@Entity
class CustomerAddress {
    @Id @GeneratedValue
    private Long id;

    // Owning side: колонка customer_id буде записуватися з цього поля
    @ManyToOne
    @JoinColumn(name = "customer_id")
    private Customer customer;

    // Гетери/сетери опущено для стислості
}

А у Customer буде колекція addresses як inverse side (у проєкті вона є), але зовнішній ключ вона не пише.

І навіть для @OneToOne принцип той самий: хто зберігає @JoinColumn, той і керує фізичним зв’язком. У нашому домені це особливо помітно на прикладі ProductProductDetails. Щойно ви бачите @JoinColumn(name="product_id") — ви розумієте, де та «ручка», якою підписують зв’язок.

Ключовий практичний висновок звучить майже смішно: якщо ви не знаєте, хто owning side, ви не знаєте, що буде в SQL. А якщо ви не знаєте, що буде в SQL, то Hibernate навчить вас через баги. Він узагалі чудовий викладач, але з поганим гумором: жартує UPDATE-ами в п’ятницю ввечері.

8. Діагностика проблем зі зв’язками

У бойовому середовищі зазвичай усе починається із симптому: «чому зв’язок не зберігся?», «чому у замовлення немає позицій?», «чому воно зникло після перезапуску?». І перший імпульс новачка — додати ще одну анотацію. Другий імпульс — додати save() «про всяк випадок». Третій — увімкнути cascade = ALL, щоб «воно точно збереглося». Це дуже людський, але дуже дорогий шлях.

Замість цього тримайте в голові спокійний діагностичний ритуал із трьох питань. Він простий, але майже завжди економить години.

Перше питання: у якій таблиці фізично живе зовнішній ключ? Якщо це order_item.order_id, то треба шукати поле, яке цей order_id мапить.

Друге питання: яке поле в Java є owning side? Зазвичай це видно по @JoinColumn і тому, де немає mappedBy.

Третє питання: чи є в SQL після flush() саме та зміна, яку ви очікуєте? Якщо ні — значить ви змінювали не owning side (або змінювали, але не в managed-стані, або немає транзакції/flush, але це ми вже проходили).

Якщо хочете візуальну шпаргалку, ось проста блок-схема:

flowchart TD
    A["Зв’язок \"не зберігся\""] --> B["Де в БД зовнішній ключ?"]
    B --> C["Яке поле мапить цей FK?"]
    C --> D["Це owning side?"]
    D --> E["Змінювали саме його в межах транзакції?"]
    E --> F["Зробили flush і подивилися SQL?"]
    F --> G["Є UPDATE/INSERT з FK?"]

Це не «методологія заради методології». Це спосіб не перетворювати Hibernate на ворожіння на кавовій гущі.

9. Типові помилки під час розуміння owning side

Помилка №1: вважати ownerʼом сторону з колекцією, бо вона «виглядає головною».
Колекція на стороні PurchaseOrder візуально виглядає як «головний зв’язок»: замовлення ж «має» позиції. Але для бази даних головне не «хто має», а «де лежить зовнішній ключ». У нашому випадку order_id живе в order_item, отже owning side — поле OrderItem.order, навіть якщо вам емоційно хочеться зворотного.

Помилка №2: змінювати лише inverse side і вірити налагоджувачу.
Налагоджувач показує те, що в пам’яті. Hibernate пише те, що в БД. Ці два світи збігаються лише тоді, коли ви коректно змінюєте owning side. Якщо ви додали item у order.items, то в пам’яті все виглядає прекрасно, а в SQL після flush() може бути нуль дій. Це особливо підступно, тому що баг проявиться пізніше, в іншій транзакції.

Помилка №3: не доводити сценарій до flush() і робити висновки «на око».
Зв’язки в Hibernate — це частина unit of work. Поки не стався flush, ви дивитеся на «план змін», а не на факт. У результаті з’являються легенди рівня «Hibernate іноді зберігає, іноді ні». Він не «іноді». Він робить рівно те, що йому наказали: owning side + flush-цикл.

Помилка №4: намагатися лікувати проблему додатковими save() замість виправлення моделі змін.
Якщо зв’язок не записався, це рідко проблема «не викликали save()». Набагато частіше це проблема «не змінили owning side». Додаткові save() іноді просто маскують корінь, додаючи зайві flush-виклики, зайві SELECT-запити й відчуття, що все тримається на скотчі.

Помилка №5: плутати «зручно ходити графом» і «хто пише FK».
Inverse side потрібна, тому що її зручно використовувати для навігації: рахувати totals, будувати DTO, робити бізнес-логіку. Але зручність навігації не дорівнює праву керувати зовнішнім ключем. В ORM це дві різні ролі, і їх змішування завжди закінчується неочікуваним SQL.

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