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 в БД, але ви самі собі ускладните життя: налагодження, логування та прості перевірки в коді почнуть поводитися суперечливо.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ