1. @ManyToMany: огляд і обмеження
Коли розробник уперше бачить @ManyToMany, усередині зазвичай прокидається маленький оптимізатор: «О! Можна просто пов’язати Product і Tag — і все само запрацює, без зайвих сутностей і таблиць!» Це справді звучить як мрія, особливо після mappedBy, каскадів та інших радощів. Але в цієї мрії є одна важлива умова: зв’язок має залишатися легким і без власного змісту.
У нашому міні-магазині теги — хороший приклад такого легкого зв’язку. Товар може мати багато тегів: sale, new, eco, gift. І кожен тег, очевидно, може належати багатьом товарам. Це і є класична кардинальність «багато-до-багатьох»: багато товарів ↔ багато тегів.
Але тут важливо не переплутати «зараз зручно» і «це справді відповідає моделі». @ManyToMany добре працює, коли теги — це саме класифікація, а не самостійний бізнес-об’єкт зі складною логікою. Щойно бізнес каже: «А давайте ще зберігати, хто призначив тег, коли призначив і звідки він узагалі з’явився — вручну, автоматично чи через імпорт» — зв’язок перестає бути легким, і @ManyToMany починає тріщати по швах.
Саме тому в нашому курсі Tag — контрольований бонус. Ми вміємо його зробити, розуміємо ціну та обмеження, але не перетворюємо теги на центральну вісь проєкту. Центральна вісь у нас — каталог, залишки та замовлення, а не «велика система тегування всього сущого».
2. Join table в SQL
Якщо в об’єктній моделі Product може просто зберігати Set<Tag>, то в реляційній моделі так не вийде: у таблиці немає колекцій. Таблиця любить прості речі: колонки, значення, ключі. Тому «багато-до-багатьох» у SQL завжди зберігається через третю таблицю, яку зазвичай називають join table (таблиця-зв’язка, таблиця-склейка).
У нашому випадку це буде щось на кшталт product_tag, де кожен рядок означає один факт зв’язку: «ось цей товар має ось цей тег». Жодної магії: просто пари ідентифікаторів. Це зручно уявити як «таблицю дружби» в соцмережі: у ній не зберігається одна людина й не зберігається друга людина — зберігається лише факт зв’язку між ними.
Схематично це виглядає так:
%% Таблиця-зв’язка: окрема таблиця, яка зберігає лише пари ідентифікаторів
erDiagram
product ||--o{ product_tag : "зв’язує"
tag ||--o{ product_tag : "зв’язує"
product {
bigint id
varchar sku
varchar name
}
tag {
bigint id
varchar code
varchar name
}
product_tag {
bigint product_id
bigint tag_id
}
А у вигляді DDL (спрощено, по-людськи) join table зазвичай виглядає так:
create table product_tag (
product_id bigint not null references product(id), -- посилання на товар
tag_id bigint not null references tag(id), -- посилання на тег
primary key (product_id, tag_id) -- захист від дублів зв’язку
);
Зверніть увагу на primary key (product_id, tag_id). Це не обов’язково саме так, але це дуже здорова звичка: вона захищає нас від дублів. Інакше можна випадково зберегти зв’язок «product 10 ↔ tag 3» двічі, а потім дивуватися, чому в каталозі «eco» показується двічі (спойлер: тому що база чесно зберігає два рядки).
Якщо спробувати подумки прочитати product_tag як таблицю, вийде дуже проста картинка. Наприклад:
| product_id | tag_id |
|---|---|
| 10 | 3 |
| 10 | 7 |
| 11 | 3 |
Це означає: товар 10 має теги 3 і 7, товар 11 має тег 3. І все. Жодних прихованих об’єктів «productTag» у Java не існує — якщо ми використовуємо @ManyToMany, Hibernate працюватиме з join table «за лаштунками».
Роль @JoinTable у JPA якраз у тому, щоб явно описати цю третю таблицю: як вона називається, які в неї колонки та на яку сутність указує кожна з них.
3. Однонаправлений @ManyToMany: Product → Tag
Найспокійніший варіант для навчального проєкту, а доволі часто й для комерційного, — однонаправлений @ManyToMany. Це коли Product знає свої теги, а Tag не зберігає колекцію товарів. Такий підхід дає дві приємні речі: він простіший для мислення і різко знижує ризик «гігантського об’єктного графа», де все пов’язано з усім.
Почнемо із сутності Tag. Ми тримаємо її в catalog, тому що це частина каталогу: саме так класифікують товари. У тега буде унікальний code (машинний ідентифікатор) і зрозуміле людині name.
package com.example.shopdatajpa.catalog.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "tag") // таблиця-довідник тегів
public class Tag {
@Id
@GeneratedValue
private Long id; // технічний ідентифікатор (PK)
@Column(nullable = false, unique = true, length = 64)
private String code; // унікальний "код" тега: sale/new/eco
@Column(nullable = false, length = 255)
private String name; // зрозуміле людині імʼя для UI/адмінки
}
Тепер додамо в Product набір тегів. Тут ключовий елемент — @JoinTable: ми явно кажемо Hibernate, що зв’язок зберігається в таблиці product_tag, і задаємо імена колонок product_id та tag_id. Додатково ми додаємо uniqueConstraints, щоб захиститися від дублів на рівні схеми.
package com.example.shopdatajpa.catalog.entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.JoinTable;
import jakarta.persistence.ManyToMany;
import jakarta.persistence.UniqueConstraint;
import java.util.HashSet;
import java.util.Set;
public class Product {
@ManyToMany // зв’язок "багато-до-багатьох": у товару є багато тегів
@JoinTable(
name = "product_tag", // join table (таблиця зв’язків)
joinColumns = @JoinColumn(name = "product_id", nullable = false), // FK на власника зв’язку (Product)
inverseJoinColumns = @JoinColumn(name = "tag_id", nullable = false), // FK на іншу сторону (Tag)
uniqueConstraints = @UniqueConstraint(columnNames = {"product_id", "tag_id"}) // захист від дублів пар
)
private Set<Tag> tags = new HashSet<>(); // Set = за змістом "набір", а не список із дублями
}
Тут дуже важливо не плутати joinColumns та inverseJoinColumns. Володіюча сторона — Product, і joinColumns описує колонку в join table, яка вказує на власника, тобто на product. А inverseJoinColumns указує на іншу сторону, тобто на tag. Назви звучать трохи ніби «інверсія за настроєм», але на практиці це просто «власник ↔ протилежна сторона».
Чому ми використовуємо Set, а не List? Тому що в нашій предметній моделі «у товару є набір тегів», і дублікати нам не потрібні. У List дублікати — нормальний стан, а в Set — ні. Це не релігія колекцій, а дуже практичний захист від ситуації «чому в товару тричі тег sale».
На цьому місці новачок часто запитує: «А де mappedBy?» Відповідь проста: в однонаправленому зв’язку mappedBy не потрібен. Ми не стверджуємо, що Tag теж знає про товари. У нас лише одна навігація: від товару до його тегів.
І це не означає, що ми назавжди заборонили шукати товари за тегом. Просто навігації tag.getProducts() у Java немає. А запити й читання — це окрема тема, і ми до неї дійдемо пізніше через derived queries, JPQL та projections. Зараз ми робимо правильну річ: тримаємо модель простою, доки не стане ясно, що її справді потрібно ускладнити.
4. Двонапрямлений @ManyToMany: mappedBy та helper-методи
Двонапрямлений @ManyToMany — це як двостулкові двері: зручно, доки ви не почали бігти через них із двох боків одночасно. Іноді він справді потрібен: наприклад, якщо в коді є сценарії «показати всі товари за тегом» і вам зручно починати навігацію саме від Tag. Але дуже часто двосторонність додають «про всяк випадок», а потім отримують цикли, непередбачувані завантаження й нескінченні toString()-страждання.
Якщо двосторонність усе ж потрібна, то owning side все одно має бути лише одна. Зазвичай володіючу сторону залишають Product (він і так центр каталогу), а в Tag додають зворотну колекцію з mappedBy = "tags".
package com.example.shopdatajpa.catalog.entity;
import jakarta.persistence.ManyToMany;
import java.util.HashSet;
import java.util.Set;
public class Tag {
// mappedBy говорить, що власник зв’язку — поле Product.tags
@ManyToMany(mappedBy = "tags")
private Set<Product> products = new HashSet<>();
// геттер потрібен, щоб helper-методи на боці Product могли синхронізувати обидві сторони
public Set<Product> getProducts() {
return products;
}
}
У цей момент з’являється новий обов’язок: синхронізація обох сторін у пам’яті. Якщо ви зробили product.getTags().add(tag), то колекція tag.getProducts() не оновиться сама. Hibernate не телепат. Він ORM, а не психолог.
Тому, якщо зв’язок двонапрямлений, ми майже завжди додаємо helper-методи. Зазвичай їх тримають на боці, який ближчий до сценарію використання. У нашому випадку логічно тримати їх у Product, тому що ми призначаємо теги товару.
package com.example.shopdatajpa.catalog.entity;
public class Product {
// Важливо: оновлюємо обидві сторони, щоб об’єктна модель у пам’яті була узгодженою
public void addTag(Tag tag) {
tags.add(tag);
tag.getProducts().add(this);
}
// Аналогічно при видаленні: прибираємо зв’язок і тут, і на зворотному боці
public void removeTag(Tag tag) {
tags.remove(tag);
tag.getProducts().remove(this);
}
}
Ці методи виглядають тривіальними, але вони економлять вам години налагодження. Без них ви отримаєте дуже дивні ефекти. Наприклад, ви додали тег товару й одразу в тому самому методі перевіряєте tag.getProducts().contains(product) — і отримуєте false. Не тому, що Hibernate «зламався», а тому, що ви оновили лише половину моделі.
І ще одна тонкість: helper-методи — це не «переробка Java під ORM». Це нормальна дисципліна для будь-якої двосторонньої структури даних. Якщо у вас є два вказівники, які мають бути узгоджені, у вас має бути одна точка, де їх оновлюють як одну операцію. Інакше код перетворюється на «я десь там додав в одну колекцію, а другу — ну, якось потім».
5. Видалення і каскади: ризик CascadeType.REMOVE
Коли ми говоримо «видалити зв’язок», часто маємо на увазі «прибрати тег у товару». У термінах SQL це означає видалити рядок із join table product_tag. Але коли ми говоримо «видалити тег», це означає видалити рядок із таблиці tag. Це дві різні операції, і плутати їх — майже гарантовано отримати сюрпризи.
У @ManyToMany особливо небезпечно вмикати CascadeType.REMOVE. Причина проста: один і той самий Tag може належати кільком товарам. І якщо ви випадково налаштуєте каскадне видалення та видалите один Product, ORM може спробувати видалити й пов’язані теги. А далі станеться або порушення посилальної цілісності (у базі ще є інші товари, які посилаються на цей тег через join table), або тиха втрата даних (якщо ви потім вручну почистите join table). І обидва варіанти погані: один ламає застосунок, другий — зміст даних.
Ось приклад налаштування, яке дуже хочеться написати «щоб усе само прибиралося», і яке майже завжди хочеться видалити з проєкту через п’ять хвилин:
package com.example.shopdatajpa.catalog.entity;
import jakarta.persistence.CascadeType;
import jakarta.persistence.ManyToMany;
import java.util.HashSet;
import java.util.Set;
public class Product {
// Небезпечно: видаляючи Product, можна випадково зачепити спільні Tag-и
@ManyToMany(cascade = CascadeType.REMOVE)
private Set<Tag> tags = new HashSet<>();
}
Якщо уявити собі реальність магазину, стає очевидно: видалення товару не повинно видаляти саму сутність тега. Тег sale продовжує існувати і може бути корисним для інших товарів. Отже, життєвий цикл Tag не належить товару. А якщо життєвий цикл не спільний, каскадні видалення тут недоречні.
У навчальному проєкті найспокійніший варіант за замовчуванням — взагалі не ставити каскади на @ManyToMany, доки не з’явиться залізна доменна причина. Теги створюються окремо, живуть окремо, а зв’язок між ними та товарами — просто зв’язок, який ми додаємо й прибираємо.
І ще один момент, який корисно проговорити заздалегідь: видалення зв’язку — це не видалення сутності. Код рівня «прибрати тег із товару» логічно виглядає так, ніби ми редагуємо список:
product.removeTag(tag); // за змістом: прибрати зв’язок
А код рівня «видалити тег як сутність» — це вже окрема операція, і вона має бути усвідомленою. Навіть якщо в майбутньому у нас з’явиться адміністративна операція «видалити тег», вона повинна явно перевіряти, що тег ніде не використовується (або спочатку чистити зв’язки). Але це вже не питання анотації, а питання бізнес-правил.
6. Сутність-зв’язка замість @ManyToMany
@ManyToMany — це чудовий інструмент, поки зв’язок між Product і Tag залишається простим: «є/немає». Але бізнес майже ніколи не зупиняється на «є/немає». У бізнесу є суперздібність: він може додати нове поле до будь-якого зв’язку, причому зазвичай у п’ятницю ввечері. І ось у цей момент @ManyToMany починає опиратися.
Уявіть, що ми захотіли зберігати не просто «які теги в товару», а, наприклад, джерело призначення тега: MANUAL, AUTO_RULE, IMPORT. Або дату призначення. Або пріоритет тега. Або того, хто його призначив. Це все атрибути зв’язку, а не атрибути товару чи тега. І в join table з’являється нова колонка, наприклад source.
Ось тут прямий @ManyToMany стає поганою моделлю, тому що join table перестає бути «технічною таблицею зв’язків» і стає повноцінною частиною домену. І правильний крок — зробити цю таблицю окремою сутністю-зв’язкою.
Наприклад, можна завести ProductTagAssignment (назва може бути будь-якою адекватною), де будуть дві ManyToOne-посилання та поля зв’язку:
package com.example.shopdatajpa.catalog.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
@Entity
@Table(
name = "product_tag_assignment",
uniqueConstraints = @UniqueConstraint(columnNames = {"product_id", "tag_id"}) // один зв’язок на пару
)
public class ProductTagAssignment {
@Id
@GeneratedValue
private Long id; // технічний ідентифікатор рядка зв’язку
@ManyToOne
private Product product; // на який товар призначили тег
@ManyToOne
private Tag tag; // який саме тег призначили
@Column(nullable = false, length = 32)
private String source; // атрибут зв’язку: звідки надійшло призначення (MANUAL/AUTO_RULE/IMPORT)
}
Зверніть увагу, що ми знову ставимо унікальність за парою product_id + tag_id. Просто тепер це не «прихована» join table, а таблиця зі змістом, і ми керуємо нею як звичайною entity.
Корисно побачити різницю між підходами в одній таблиці — не як «теорію заради теорії», а як інженерне рішення:
| Питання | Прямий @ManyToMany | Сутність-зв’язка |
|---|---|---|
| Зв’язок має лише факт «є/немає» | Чудово підходить | Буде зайвою складністю |
| У зв’язку з’являються поля (source, assignedAt, priority) | Погано або взагалі не виражається нормально | Це її природна зона |
| Потрібно тонко керувати життєвим циклом зв’язку | Обмежено | Повний контроль |
| Потрібно швидко стартувати і не ускладнювати проєкт | Дуже зручно | Трохи складніший старт |
Саме тому ми називаємо @ManyToMany обмеженим інструментом. Він не поганий. Він просто працює у доволі вузькому коридорі. У навчальному проєкті ми показуємо його на тегах, тому що це типовий сценарій саме з цього коридору. Але ми відразу фіксуємо: щойно зв’язок стає «сутністю в масці», час знімати маску й робити окремий клас.
7. Типові помилки при @ManyToMany
Помилка №1: робити @ManyToMany для «центральних» бізнес-зв’язків.
Новачку здається, що якщо зв’язок складний, то @ManyToMany саме врятує: менше коду, менше таблиць, менше сутностей. У реальності все навпаки: складний зв’язок майже завжди потребує власних полів і правил, а отже проситься в сутність-зв’язку. @ManyToMany добре працює саме там, де зв’язок легкий і майже технічний за змістом.
Помилка №2: вмикати CascadeType.REMOVE, тому що «нехай воно саме».
У ManyToMany сутності з обох боків зазвичай перевикористовуються. Тег — спільний для багатьох товарів. Тому каскадне видалення перетворюється на потенційну катастрофу: видаляючи один товар, ви починаєте ризикувати цілим словником тегів. Тут краще тримати життєві цикли окремо й керувати видаленням явно.
Помилка №3: робити двонапрямленість «про всяк випадок», а потім забувати синхронізувати обидві сторони.
Двонапрямлений зв’язок вимагає дисципліни: оновили одну сторону — оновіть і другу, інакше модель у пам’яті сама собі суперечить. Якщо зворотна навігація не потрібна просто зараз, однонапрямлений Product -> tags зазвичай простіший, чистіший і дешевший для розуміння.
Помилка №4: ставити @JoinTable на обидві сторони або забувати mappedBy.
У @ManyToMany усе ще є owning side. Якщо намагатися описати join table з двох сторін «симетрично», можна отримати або дві таблиці зв’язків, або конфліктні налаштування. Правило просте: @JoinTable живе у власника зв’язку, а зворотна сторона (якщо вона є) використовує mappedBy.
Помилка №5: не думати про дублікати в join table.
Навіть якщо в Java ви використовуєте Set, це ще не гарантує, що в таблиці не з’являться повторювані пари. Правильний захист — унікальність на рівні БД (наприклад, primary key (product_id, tag_id) або unique(product_id, tag_id)) і акуратне керування зв’язком через один зрозумілий метод, а не через «десь у коді додали, десь видалили».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ