Односторонние и двусторонние отношения в Hibernate 7
Впервые опубликовано на Хабре 14 февраля 2021; здесь переработанная и обновлённая версия.
Четыре вида отношений в JPA знают все: @OneToOne, @OneToMany, @ManyToOne, @ManyToMany. Реже помнят, что каждое из них бывает односторонним или двусторонним, и от этого зависит, какие таблицы создаст Hibernate и какой SQL выполнит. Я писал об этом на Хабре в 2021 году и оставил один вопрос открытым. Здесь на него есть ответ, а каждый листинг ниже напечатал настоящий запуск Hibernate 7.4 на H2 — из одной программы: Main.java и её pom.xml.
В примерах две сущности: пользователь и его контакты (почта, телефон). Каждый контакт принадлежит одному пользователю, у пользователя много контактов.
Сторона-владелец
В отношении двух таблиц внешний ключ лежит в одном месте. Сущность, чьё отображение управляет этим ключом, — сторона-владелец (owning side). Hibernate пишет ключ только по стороне-владельцу. Другая сторона, если она вообще отображает отношение, — обратная (inverse side): её читают, но никогда не записывают.
Запомните эту фразу: всё дальнейшее из неё следует.
Одностороннее @ManyToOne: простой случай
Пусть отношением владеет контакт:
@Entity
@Table(name = "contacts")
class Contact {
@Id @GeneratedValue Long id;
String type;
String data;
@ManyToOne User user;
}
@Entity
@Table(name = "users")
class User {
@Id @GeneratedValue Long id;
String username;
}
Схема та, что нарисовали бы руками: колонка user_id в contacts:
create table contacts (
id bigint not null,
user_id bigint,
data varchar(255),
type varchar(255),
primary key (id)
)
alter table if exists contacts
add constraint FK... foreign key (user_id) references users
Но обычно мы спрашиваем «какие контакты у пользователя?», а не «чей это контакт?». Перенесём отношение к пользователю.
Одностороннее @OneToMany: неожиданная таблица связи
@Entity
@Table(name = "users")
class User {
@Id @GeneratedValue Long id;
String username;
@OneToMany List<Contact> contacts = new ArrayList<>();
}
Hibernate создаёт третью таблицу:
create table users_contacts (
User_id bigint not null,
contacts_id bigint not null unique
)
В 2021 году я написал, что не понимаю почему. Ответ — в спецификации JPA: одностороннее @OneToMany по умолчанию и есть таблица связи. В классе Contact нет поля для ключа, поэтому по умолчанию JPA не кладёт ключ в contacts и заводит отдельную таблицу. unique на contacts_id и делает связь «один ко многим»: контакт встречается в таблице связи один раз.
Если явно попросить колонку, таблица связи исчезнет:
@OneToMany
@JoinColumn(name = "user_id")
List<Contact> contacts = new ArrayList<>();
create table contacts (
id bigint not null,
user_id bigint,
data varchar(255),
type varchar(255),
primary key (id)
)
Схема теперь правильная, но посмотрите на SQL при сохранении пользователя с двумя контактами:
insert into contacts (data, type, id) values (?, ?, ?)
insert into contacts (data, type, id) values (?, ?, ?)
insert into users (username, id) values (?, ?)
update contacts set user_id=? where id=?
update contacts set user_id=? where id=?
Ключ лежит в contacts, а управляют им из User. Hibernate вставляет контакты без него, а потом исправляет каждую строку через UPDATE. Лишний запрос на каждого потомка — цена одностороннего @OneToMany, даже с @JoinColumn.
@ManyToMany с двух сторон: два отношения вместо одного
Пользователи и роли: у пользователя несколько ролей, у роли несколько пользователей. Поставим @ManyToMany в оба класса:
class User { @ManyToMany List<Role> roles; }
class Role { @ManyToMany List<User> users; }
create table roles_users (
Role_id bigint not null,
users_id bigint not null
)
create table users_roles (
User_id bigint not null,
roles_id bigint not null
)
Две таблицы связи. Мы создали не одно двустороннее отношение, а два односторонних, которые случайно соединяют одни и те же сущности. Каждая сторона считает своей отдельную таблицу. Если сохранить «Алекс — администратор» со стороны пользователя, запишется только users_roles, и сторона роли этого никогда не увидит.
mappedBy: кто здесь владелец
mappedBy ставится на обратной стороне и называет поле на стороне-владельце:
class User { @ManyToMany List<Role> roles; } // владелец
class Role { @ManyToMany(mappedBy = "roles") List<User> users; } // обратная сторона
create table users_roles (
roles_id bigint not null,
users_id bigint not null
)
Одна таблица. Для @ManyToMany владельцем может быть любая из сторон.
Для «один ко многим» владельцем должна быть сторона «многих»: ключ лежит именно там. Поэтому mappedBy есть у @OneToMany и нет у @ManyToOne. Двусторонняя связь пользователя и контактов выглядит так:
@Entity
@Table(name = "contacts")
class Contact {
@Id @GeneratedValue Long id;
String type;
String data;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
User user; // владелец: пишет user_id
}
@Entity
@Table(name = "users")
class User {
@Id @GeneratedValue Long id;
String username;
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
List<Contact> contacts = new ArrayList<>(); // обратная сторона: только чтение
void addContact(Contact c) { contacts.add(c); c.user = this; }
void removeContact(Contact c) { contacts.remove(c); c.user = null; }
}
Схема — та же простая, что в первом примере, а сохранение пользователя с двумя контактами — три вставки, у каждого контакта сразу свой ключ:
insert into users (username, id) values (?, ?)
insert into contacts (data, type, user_id, id) values (?, ?, ?, ?)
insert into contacts (data, type, user_id, id) values (?, ?, ?, ?)
Ошибка, которую провоцирует mappedBy
У фразы «обратная сторона только читается» есть острое следствие. Добавим контакт в список пользователя и забудем выставить contact.user:
User u = session.createQuery("from User", User.class).getSingleResult();
Contact c = new Contact();
c.type = "telegram";
c.data = "@alex";
u.contacts.add(c); // выставлена только обратная сторона
session.persist(c);
Ни исключения, ни предупреждения. Что оказалось в таблице:
email -> user_id 1
phone -> user_id 1
telegram -> user_id null
Новый контакт сохранился без пользователя. В той же сессии u.contacts его по-прежнему показывает, так что ошибка видна только после перезагрузки. Именно для этого и нужны методы addContact и removeContact: они держат обе стороны согласованными, и код снаружи сущности должен работать через них, а не трогать список напрямую.
Как выбирать на практике
- Для «один ко многим» отображайте
@ManyToOneу потомка.@OneToMany(mappedBy = ...)у родителя добавляйте, только если действительно ходите от родителя, и тогда — с методами-помощниками. - Избегайте одностороннего
@OneToManyдля больших коллекций: это таблица связи или лишнийUPDATEна каждого потомка. @ManyToManyс двух сторон всегда требуетmappedByна одной из них. Если у самой связи есть данные (с какого числа, кто выдал), сделайте её отдельной сущностью с двумя@ManyToOne.- Делайте
@ManyToOneленивым (fetch = FetchType.LAZY): по умолчанию JPA загружает его сразу. - Смотрите сгенерированный DDL и SQL (
hibernate.show_sql,format_sql) при каждом изменении отображения. Пять минут чтения экономят миграцию потом.
Что изменилось с 2021 года
Правила те же, а платформа ушла вперёд. Аннотации живут в jakarta.persistence вместо javax.persistence (с Hibernate 6). Листинги 2021 года были для MySQL с GenerationType.IDENTITY, а эти — для H2 с обычным @GeneratedValue: на базе с последовательностями это последовательность с шагом выделения 50, поэтому выше нет auto_increment. На отношения это не влияет. И Hibernate теперь седьмой: листинги получены на версии 7.4.