JavaRush /Курси /Hibernate deep-dive /SQL і контроль зв’язку з l...

SQL і контроль зв’язку з link entity

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

1. Головний критерій — SQL

Тепер нам уже не потрібно доводити, що @ManyToMany ховає реальний рядок зв’язку. Це й так видно. Тут спір вирішує не кількість анотацій, а те, який SQL піде в PostgreSQL після flush().

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

Невелика схема мислення, яку варто тримати в голові:

flowchart TD
    A[Java-код: змінюємо колекцію] --> B[Hibernate: dirty checking колекцій]
    B --> C["flush()"]
    C --> D[SQL: INSERT/DELETE/UPDATE]
    D --> E[PostgreSQL: реальний запис у таблицю]

Мініприклад, який будемо повторювати сьогодні. Він не про ідеальну реалізацію, а про те, як побачити SQL просто зараз:

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

@Transactional
public void demoFlush(EntityManager entityManager) {
    // ... змінюємо зв’язки/поля в керованих сутностях (усередині транзакції)
    // Важливо: до flush() Hibernate може накопичувати зміни в persistence context і не надсилати SQL одразу.
    entityManager.flush(); // примусово надсилаємо SQL, щоб побачити його в логах
}

Ключова думка: якщо ви не змусили Hibernate зробити flush(), ви інколи порівнюєте мрії замість реальності. А в ORM мрії — погане джерело даних.

2. Наївний @ManyToMany: видалення та join‑таблиця

Давайте спочатку подивимося на сценарій «як було б у наївній моделі». У нас є Product, у нього є список категорій, і ми робимо remove().

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

import jakarta.persistence.Entity;
import jakarta.persistence.JoinTable;
import jakarta.persistence.ManyToMany;
import java.util.ArrayList;
import java.util.List;

@Entity
public class Product {

    // Наївний many-to-many: join-таблиця існує в БД,
    // але в Java-моделі у рядка зв’язку немає окремої сутності й ідентичності.
    @ManyToMany
    @JoinTable(name = "product_category_assignment")
    private List<Category> categories = new ArrayList<>();
}

Нижче я навмисно використовую короткі лабораторні фрагменти. Припускаємо, що Product і Category уже керовані в поточній транзакції: так простіше ізолювати саме SQL-профіль зв’язку, не змішуючи його з кодом завантаження сутностей.

І сервісний код, який зовні теж виглядає «правильно»:

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

@Transactional
public void removeCategoryNaive(Product product, Category category, EntityManager em) {
    // На рівні Java: «просто прибрали елемент із колекції».
    product.getCategories().remove(category);

    // Примусово просимо Hibernate матеріалізувати зміни в SQL,
    // щоб побачити реальну поведінку, а не гадати.
    em.flush();
}

На рівні Java це виглядає як видалення одного зв’язку. Але для @ManyToMany Hibernate керує join‑таблицею як службовою колекцією і тому нерідко обирає грубу синхронізацію: видалити всі рядки для товару і вставити назад ті, що залишилися.

Типовий SQL‑слід (спрощено, щоб було читабельно):

-- Hibernate може зробити так:
delete from product_category_assignment
where product_id = ?;

insert into product_category_assignment(product_id, category_id)
values (?, ?);

insert into product_category_assignment(product_id, category_id)
values (?, ?);

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

3. Link entity: точкове видалення DELETE

Тепер повторімо той самий сценарій, але вже в «дорослій» моделі: Product містить не categories, а categoryAssignments, і видалення зв’язку — це видалення конкретного об’єкта assignment.

Мінімальне відображення з боку товару:

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

@Entity
public class Product {

    // Product керує життєвим циклом assignment-ів:
    // - cascade = ALL: зміни assignment-об’єктів проходять разом із продуктом
    // - orphanRemoval = true: видалили assignment із колекції -> видалили рядок у БД
    @OneToMany(mappedBy = "product", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<ProductCategoryAssignment> categoryAssignments = new ArrayList<>();
}

А ось ключова різниця: у нас є сутність зв’язку, тож Hibernate бачить рядок таблиці як об’єкт зі своїм id.

Нижче — скорочений вигляд того самого ProductCategoryAssignment. Поля sortOrder, assignedAt, обмеження та helper-методи нікуди не зникли; для самого DELETE нам зараз важливі лише власницькі посилання та ідентичність запису.

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

@Entity
public class ProductCategoryAssignment {

    // Окремий ідентифікатор зв’язку — це і є «контроль»:
    // рядок у таблиці стає повноцінним об’єктом.
    @Id
    @GeneratedValue
    private Long id;

    // Нижче важливі саме власницькі посилання;
    // інші поля link entity тут просто не беруть участі в самому DELETE.
    @ManyToOne(fetch = FetchType.LAZY)
    private Product product;

    @ManyToOne(fetch = FetchType.LAZY)
    private Category category;
}

Тепер сервісний код не «колупає колекцію категорій», а викликає метод моделі, який видаляє assignment:

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

@Transactional
public void removeCategoryExplicit(Product product, Long categoryId, EntityManager em) {
    // У доменній моделі видаляємо саме зв’язок (assignment), а не Category як довідник.
    product.unassignCategory(categoryId);

    // Одразу матеріалізуємо зміни, щоб перевірити SQL-слід у логах.
    em.flush();
}

Що бачить Hibernate на flush()? Він бачить: у колекції був managed-об’єкт ProductCategoryAssignment, тепер його немає, а orphanRemoval = true означає: «видалити orphan із БД». І SQL стає точковим та передбачуваним.

Спрощений SQL‑слід виглядає так:

delete from product_category_assignment
where id = ?;

Іноді перед цим буде SELECT (наприклад, щоб ініціалізувати колекцію assignments, якщо вона lazy і ще не завантажена). Але ключова відмінність усе одно зберігається: операція видалення не призводить до перескладання всього зв’язку, і ви бачите рівно ту механіку, яку очікуєте як інженер: «видаляємо один запис зв’язку».

І ось тут з’являється відчуття контролю. Не «Hibernate начебто якось видалив», а «я розумію, яка сутність видаляється і чому саме такий SQL».

4. Додавання категорії: зрозумілий INSERT

Видалення — найпомітніша частина, але додавання теж дуже показове. У @ManyToMany додавання категорії — це просто «вставити пару FK у join‑таблицю». І все. Жодних даних зв’язку у вас немає, і додати їх не можна (хіба що почати складні трюки, які в більшості проєктів закінчуються сумом і технічним боргом).

У link entity додавання категорії — це створення assignment-сутності. І це здається «більше коду», але насправді — більше змісту.

Мініприклад helper-методу на товарі (коротко, без усієї обвʼязки):

import java.time.Instant;

public void assignCategory(Category category, int sortOrder) {
    // Створюємо об’єкт зв’язку (assignment), який може зберігати атрибути зв’язку:
    // порядок, дату призначення тощо.
    ProductCategoryAssignment a =
            new ProductCategoryAssignment(this, category, sortOrder, Instant.now());

    // Тримаємо консистентність графа в пам’яті:
    // додаємо assignment і в колекцію продукту, і (за потреби) в колекцію категорії.
    categoryAssignments.add(a);
    category.addAssignment(a);
}

Що це дає в SQL? Тепер INSERT у таблицю зв’язку містить не тільки product_id і category_id, а й поля зв’язку:

insert into product_category_assignment
(id, product_id, category_id, sort_order, assigned_at)
values (?, ?, ?, ?, ?);

Так, SQL став ширшим. Але в цьому немає нічого поганого. Навпаки: тепер SQL чесно відображає модель, а модель чесно відображає предметну область. І у вас з’являється можливість змінювати sortOrder без танців із бубном і зберігати assignedAt там, де йому логічно місце.

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

5. Оновлення даних зв’язку: звичайний UPDATE

Ось де link entity починає виглядати особливо приємно. У @ManyToMany ви не можете зробити «поміняти порядок категорії у товару», тому що порядок — це не властивість Category і не властивість Product. Це властивість пари Product + Category. А пари в моделі немає — отже, і змінювати нічого.

У link entity усе прямолінійно: sortOrder — поле сутності assignment. Ми змінюємо його як звичайне поле керованої сутності, і dirty checking робить решту.

Мініприклад:

public void changeSortOrder(int newSortOrder) {
    // Проста перевірка інваріантів зв’язку: від’ємний порядок забороняємо.
    // Це зручно саме тому, що зв’язок став об’єктом із поведінкою.
    if (newSortOrder < 0) {
        throw new IllegalArgumentException("Порядок має бути >= 0");
    }

    // Звичайне оновлення поля керованої сутності:
    // на flush() Hibernate зробить UPDATE одного рядка.
    this.sortOrder = newSortOrder;
}

Якщо ProductCategoryAssignment перебуває в persistence context (а він там опиниться, коли ви завантажили товар разом із assignments у межах транзакції), то на flush() ви побачите рівно один оновлювальний запит:

update product_category_assignment
set sort_order = ?
where id = ?;

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

Щоб зафіксувати різницю «на папері», ось компактна таблиця порівняння (вона не про performance-замірювання, а про передбачуваність):

Операція в коді Наївний @ManyToMany Link entity ProductCategoryAssignment
Додати категорію INSERT у join‑таблицю лише з FK INSERT в assignment зі своїми полями (sortOrder, assignedAt)
Видалити одну категорію іноді точковий DELETE, іноді «delete all + insert решти» DELETE одного assignment-рядка (зазвичай за id)
Змінити sortOrder фактично ніде зберігати один UPDATE одного запису

І так, це той випадок, коли «більше об’єктів у Java» означає менше сюрпризів у SQL.

6. «Контроль зв’язку» на практиці

Коли ми говоримо «контроль зв’язку», легко скотитися в абстракції. Тому давайте конкретно: що саме у нас з’являється після переходу на link entity, якщо дивитися не на красу архітектури, а на щоденну розробку та налагодження.

По-перше, у рядка зв’язку з’являється ідентичність сутності. У нього є id, його можна логувати, на нього можна поставити breakpoint, його можна видалити як об’єкт. Це різко знижує відчуття, що join‑таблиця — це «чорна магія ORM». Вона вже не магія: це звичайна таблиця звичайної сутності.

По-друге, cascade і orphanRemoval стають семантично чесними. Ми можемо сказати: «Product керує життєвим циклом assignment-об’єктів», і ввімкнути cascade = ALL та orphanRemoval = true лише для assignments, а не для Category. Це важлива деталь: категорія — спільна довідникова сутність, і продукт не має її «знищувати», коли прибирає зв’язок. З link entity це майже неможливо переплутати, бо видаляється саме assignment.

По-третє, інваріанти на кшталт «унікальна пара product + category» стають простішими для підтримки і на рівні моделі, і на рівні схеми. У Java ви можете зробити перевірку — не створювати дублікати, а в PostgreSQL — додати унікальне обмеження, щоб база не дозволила випадково записати сміття. І це вже не теорія, а практичне страхування від багів у сервісному коді.

Наприклад, анотація на таблиці (показую дуже коротко — ідею, а не повний клас):

import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;

@Table(
    name = "product_category_assignment",
    // Унікальність пари FK захищає від дублікатів на рівні БД:
    // один і той самий category не має призначатися одному й тому самому product двічі.
    uniqueConstraints = @UniqueConstraint(
        name = "uk_product_category",
        columnNames = {"product_id", "category_id"}
    )
)
public class ProductCategoryAssignment { }

І нарешті, link entity робить SQL‑слід читабельнішим для людини. Коли ви бачите delete from product_category_assignment where id = ?, ви розумієте, що видаляється саме зв’язок. Коли ви бачите update ... set sort_order, ви розумієте, що оновлюється саме порядок. Це звучить банально, але в реальних проєктах «банально зрозуміло» — це конкурентна перевага.

7. SQL‑профілі в Commerce Persistence Lab

Щоб не залишати все на рівні «десь там у логах буде красиво», уявімо, як ми зазвичай дивимося на поведінку в нашому лабораторному проєкті. Ми запускаємо застосунок із увімкненим SQL trace (ви це вже робили на ранніх етапах курсу), виконуємо один сервісний метод і всередині транзакції робимо flush(), щоб не гадати, коли саме Hibernate вирішить надіслати SQL.

Щоб зосередитися на SQL, цей сервісний фрагмент теж навмисно короткий: вважаємо, що Product уже керований, а код завантаження за id тут не впливає на сам профіль видалення зв’язку.

Приклад сервісу каталогу в стилі нашого проєкту — мінімальний:

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

@Service
public class ProductCategoryService {

    private final EntityManager entityManager;

    public ProductCategoryService(EntityManager entityManager) {
        // EntityManager потрібен нам тут, щоб примусово викликати flush()
        // і одразу побачити SQL у логах.
        this.entityManager = entityManager;
    }

    @Transactional
    public void unassignCategory(Product product, long categoryId) {
        // Лабораторний сценарій: Product уже керований, нас цікавить лише SQL-слід видалення зв’язку.
        product.unassignCategory(categoryId);

        // Примусовий flush — ключ до контролю над SQL.
        entityManager.flush();
    }
}

Якщо модель ще на @ManyToMany, ви в логах можете побачити «грубе» перескладання. Якщо модель уже на link entity, ви майже напевно побачите точковий DELETE за assignment-рядком (іноді з попереднім SELECT, якщо колекція ще не завантажена).

І ось тут виникає практичний критерій успішності рефакторингу дня 12: ваш код має приводити до SQL, який говорить те саме, що й ваш бізнес-зміст. «Прибрати категорію з товару» має виглядати як «видалити запис призначення», а не як «переписати всі призначення товару заново».

Так, Hibernate не зобов’язаний бути «максимально економним» у кожному граничному випадку. Але після link entity у вас з’являється можливість зробити поведінку передбачуваною. А передбачуваність — це фундамент для будь-якого наступного кроку: чи то обмеження, чи то сортування, чи то історія змін, чи просто спокійний сон розробника.

8. Типові помилки під час роботи з link entity

Помилка №1: порівняння рішень за кількістю анотацій, а не за SQL.
Це найчастіша інженерна ілюзія: здається, що короткий mapping автоматично означає «просто працює». На практиці @ManyToMany часто означає, що Hibernate робитиме SQL так, як йому зручно для керування колекцією, а не так, як зручно вам для контролю зв’язку. Правильна звичка — після будь-якої операції зі зв’язком робити flush() у лабораторії та дивитися на згенерований SQL.

Помилка №2: після переходу на link entity залишити стару колекцію categories поруч із categoryAssignments.
Це створює два джерела правди. У пам’яті застосунку ви можете випадково оновлювати categories, а в БД ви очікуватимете, що оновлюються assignments (або навпаки). У результаті ви отримуєте несинхронізований граф і дуже дивний SQL. У проєкті такого типу зв’язок має мати один шлях керування: або наївний @ManyToMany (до рефакторингу), або link entity (після).

Помилка №3: видалення Category, коли ви хотіли видалити assignment.
Коли в домені з’являються Product, Category і ProductCategoryAssignment, мозок іноді плутає: «ну я ж видаляю категорію з товару… отже видаляю категорію». Ні. Категорія — довідник, спільна сутність. Видаляється запис зв’язку. Якщо ви викликаєте categoryRepository.delete(...) замість видалення assignment із колекції — ви змінюєте зміст операції і ризикуєте втратити дані довідника.

Помилка №4: відсутність orphanRemoval, через що зв’язок «в пам’яті зник», а в БД залишився.
Якщо ви видалили assignment із колекції Product.categoryAssignments, але не налаштували orphanRemoval = true, Hibernate не зобов’язаний видаляти рядок із таблиці зв’язку. Він побачить, що об’єкт більше не пов’язаний із батьком, але не отримає команди «це orphan, його потрібно видалити». У результаті ви отримаєте розбіжність між моделлю в пам’яті та даними в таблиці product_category_assignment.

Помилка №5: оновлення лише однієї сторони двонапрямного зв’язку.
Коли ви створюєте або видаляєте assignment, потрібно підтримувати консистентність і на боці Product, і на боці Category (якщо в категорії є колекція assignments для навігації). Якщо ви додали assignment лише в product.categoryAssignments, але не додали його в category.productAssignments, у пам’яті у вас уже «напівсинхронізований» граф. Hibernate, звісно, зрештою орієнтується на owning side в БД, але ви самі собі ускладните життя: налагодження, логування та прості перевірки в коді почнуть поводитися суперечливо.

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