usharik.dev

← All articles

Unidirectional and bidirectional relations in Hibernate 7

Published · Alex Usharovski

First published on Habr (in Russian) on 14 February 2021; this is a rewritten and updated version.

Everyone knows the four JPA relations: @OneToOne, @OneToMany, @ManyToOne, @ManyToMany. Fewer people keep in mind that each of them can be unidirectional or bidirectional, and that this choice decides which tables Hibernate creates and which SQL it runs. I wrote about it on Habr in 2021 and left one question open. This version answers it, and every listing below was printed by a real run of Hibernate 7.4 on H2, from one program: Main.java with its pom.xml.

The examples use two entities, a user and the user's contacts (an email, a phone). Each contact belongs to one user; a user has many contacts.

The owning side

In a relation between two tables, the foreign key lives in one place. The entity whose mapping controls that key is the owning side. Hibernate writes the key based on the owning side only. The other side, if it maps the relation at all, is the inverse side: it is read, never written.

Keep that sentence in mind: everything below follows from it.

Unidirectional @ManyToOne: the plain case

Let the contact own the relation:

@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;
}

The schema is what you would draw by hand, with a user_id column in 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

But we usually ask "what are this user's contacts?", not "whose contact is this?". So let us move the relation to the user.

Unidirectional @OneToMany: the surprise join table

@Entity
@Table(name = "users")
class User {
    @Id @GeneratedValue Long id;
    String username;
    @OneToMany List<Contact> contacts = new ArrayList<>();
}

Hibernate creates a third table:

create table users_contacts (
    User_id bigint not null,
    contacts_id bigint not null unique
)

In 2021 I wrote that I did not understand why. The answer is in the JPA specification: the default mapping of a unidirectional @OneToMany is a join table. The Contact class has no field for the key, so by default JPA does not put the key into contacts, and uses a separate table instead. The unique on contacts_id is what keeps it one-to-many: a contact appears in the join table once.

Ask for the column explicitly and the join table disappears:

@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)
)

The schema is right now, but look at the SQL for saving a user with two contacts:

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=?

The key belongs to contacts, but it is managed from User. Hibernate inserts the contacts without it, then fixes each row with an UPDATE. One extra statement per child. That is the price of a unidirectional @OneToMany, even with @JoinColumn.

@ManyToMany on both sides: two relations, not one

Users and roles: a user has several roles, a role belongs to several users. Put @ManyToMany on both classes:

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
)

Two join tables. We did not create one bidirectional relation, we created two unidirectional ones that happen to connect the same entities. Each side believes it owns its own table. Saving "Alex is an admin" from the user side writes only users_roles; the role side will never see it.

mappedBy: telling Hibernate which side owns

mappedBy goes on the inverse side and names the field on the owning side:

class User { @ManyToMany List<Role> roles; }                        // owning side
class Role { @ManyToMany(mappedBy = "roles") List<User> users; }    // inverse side
create table users_roles (
    roles_id bigint not null,
    users_id bigint not null
)

One table. For @ManyToMany either side may own the relation.

For one-to-many, the owner must be the "many" side, because that is where the key is. So mappedBy exists on @OneToMany and not on @ManyToOne. The bidirectional user and contacts look like this:

@Entity
@Table(name = "contacts")
class Contact {
    @Id @GeneratedValue Long id;
    String type;
    String data;
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    User user;                                   // owning side: writes 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<>();  // inverse side: read only

    void addContact(Contact c) { contacts.add(c); c.user = this; }
    void removeContact(Contact c) { contacts.remove(c); c.user = null; }
}

The schema is the plain one from the first example, and saving a user with two contacts is now three inserts, each contact with its key:

insert into users (username, id) values (?, ?)
insert into contacts (data, type, user_id, id) values (?, ?, ?, ?)
insert into contacts (data, type, user_id, id) values (?, ?, ?, ?)

The bug that mappedBy invites

"Inverse side is read only" has a sharp consequence. Add a contact to the user's list and forget to set 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);     // only the inverse side is set
session.persist(c);

No exception, no warning. What ends up in the table:

email -> user_id 1
phone -> user_id 1
telegram -> user_id null

The new contact is saved without a user. In the same session u.contacts still shows it, so the bug only appears after a reload. That is why the helper methods addContact and removeContact exist: they keep both sides in step, and code outside the entity should go through them rather than touch the list directly.

Choosing, in practice

What changed since 2021

The rules are the same; the platform moved on. Annotations live in jakarta.persistence instead of javax.persistence (since Hibernate 6). The 2021 listings came from MySQL with GenerationType.IDENTITY; these come from H2 with a plain @GeneratedValue, which on a database with sequences means a sequence with an allocation size of 50, hence no auto_increment above. The relations behave the same either way. And Hibernate is version 7: the run that produced these listings used 7.4.

Source files