Отношение OneToOne в Hibernate и Spring

В этой статье рассмотрим  отношение @OneToOne.
Допустим, одному пользователю User соответствует одна сущность UserDetails (информация о User).

Типичный нелучший способ

Обычно отношение @OneToOne делают так:

@Entity
public class UserDetails {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private long id;

    private String phone;

    @OneToOne(fetch = FetchType.LAZY)
    private User user;

}

Что создает в таблице USER_DETAILS внешний ключ USER_ID, указывающий на ID в таблице USER:

При этом класс User тоже может ссылаться на UserDetails (или не ссылаться, на схему это не влияет).

Тут User ссылается:

@Entity
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private long id;

    private String name;

    @OneToOne(mappedBy = "user", cascade = CascadeType.ALL, optional = false, fetch = FetchType.LAZY)
    private UserDetails userDetails;

}

Добавим данные:

insert into users (id, name) values (1,'Ivan');
insert into users (id, name) values (2, 'John');
insert into users (id, name) values (3, 'Petr');

insert into user_details (id, phone, user_id) values (4, '154623',  1);
insert into user_details (id,  phone, user_id) values (5, '435',  2);
insert into user_details (id,  phone, user_id) values (6, '3454',  3);

Выглядит результат так:

Схема ,без @MapsId
Схема без @MapsId

Недостатки

  • лишний столбец
  • если User тоже в свою очередь ссылается на UserDetails, то его настройка fetch = FetchType.LAZY не работает. То есть при поиске пользователя генерируется не один, а два SQL оператора:

а именно, такой поиск

@DataJpaTest
class UserRepositoryTest {
    @Autowired
    private UserRepository userRepository;

    @Test
    @DisplayName("ищет user EAGER")
    public void whenFindUser_ThenEager() {
        Optional<User> optionalUser = userRepository.findById(1l);
        Assertions.assertTrue(optionalUser.isPresent());
    }

}

генерирует два SQL оператора:

select user0_.id as id1_1_0_, user0_.name as name2_1_0_ 
from users user0_ where user0_.id=?

select userdetail0_.id as id1_0_0_, userdetail0_.phone as phone2_0_0_, userdetail0_.user_id as user_id3_0_0_ 
from user_details userdetail0_ 
where userdetail0_.user_id=?

Это неоптимально.

Лучший способ

Во-первых, можно убрать из схемы лишний столбец. Если у каждого UserDetails свой ровно один User, то зачем в таблице USER_DETAILS нужен автогенерируемый первичный ключ? Достаточно одного USER_ID — пусть он будет и первичный, и внешний:

Схема с mapsid
Схема с mapsid

 

Схема с @MapsId
Схема с @MapsId

Чтобы создать такую схему, надо в UserDetails аннотировать поле user аннотацией @MapsId:

@Entity
public class UserDetails {
    @Id
    private long id;

    private String phone;

    @OneToOne(fetch = FetchType.LAZY)
    @MapsId
    private User user;

}

Также у поля id выше в убрана аннотация @GeneratedValue. Теперь id не генерируется автоматически, а заполняется идентификатором User.

Помимо более чистой структуры БД, для поля userDetails сущности User начинает работать FetchType.LAZY, то есть при поиске User по id уже выполняется один select, а не два.

Но все же из User лучше обратную ссылку убрать:

@Entity
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private long id;

    private String name;

}

Заполним новую схему данными:

insert into users (id, name) values (1,'Ivan');
insert into users (id, name) values (2, 'John');
insert into users (id, name) values (3, 'Petr');

insert into user_details (phone, user_id) values ('154623',  1);
insert into user_details (phone, user_id) values ('435',  2);
insert into user_details (phone, user_id) values ('3454',  3);

Выполним тот же тест, и получим один оператор select:

select user0_.id as id1_1_0_, user0_.name as name2_1_0_ 
   from users user0_ where user0_.id=?

Зная идентификатор User, всегда можно извлечь UserDetails по такому же идентификатору. И уж тогда получить второй select.

Переименование внешнего/первичного ключа

Чтобы сменить название внешнего ключа (который по сути является первичным) в таблице user_details с user_id на id, нужно использовать аннотацию @JoinColumn:

@MapsId
@JoinColumn(name = "id")
private User user;

Получим такую схему:

Переименование столбца с помощью @JoinColumn
Переименование столбца с помощью @JoinColumn

Итоги

Таким образом, второй вариант с @MapsId предпочтительней: лучше совместить внешний и первичный ключ, а также делать одностороннее отношение (без обратного поля с mappedBy). Это оптимально для производительности.

Исходный код на GitHub.

Есть также вариант сопоставить 1:1 с помощью @SecondaryTable.

Отношение OneToOne в Hibernate и Spring: 9 комментариев

  1. Так этот проект использует Hibernate или это чистое JPA?
    Просто я в файле pom.xml не вижу ни одной подключенной библиотеки Hibernate. Типа зависимости hibernate-core.
    Так же нет hibernate.cfg.xml файла, где прописаны настройки Hibernate.
    Или они не обязательны?
    Можете немного просвятить в этом вопросе.

    1. Это Spring Boot, тут есть зависимость spring-boot-starter-data-jpa, и в список ее зависимостей как раз и входит hibernate. Файл hibernate.cfg.xml тут не нужен, настройки переходят в application.yml (специфика Spring Boot).

  2. При сохранении нового объекта UserDetails через JpaRepository методом save() выполняется два insert: insert into users, затем insert into user_details. Как избавиться от первого insert?
    Т.к. объект User уже до этого был сохранен в БД возникает ошибка уникальности «duplicate key value violates unique constraint»

    1. Все правильно, что выполняется два insert, отношение же OneToOne, так и должно быть.
      Проблема в другом — в data.sql уже добавлены три пользователя с id=1,2,3 (их мы выбираем в примере). А в классе User стоит @GeneratedValue(strategy = GenerationType.SEQUENCE) без уточнений, это означает, что создается последовательность hibernate_sequence с начальным значением 1, и с помощью нее генерируются новые id. Поэтому при первом же добавлении генерируется id=1 и возникает ошибка. Исправить это просто. Либо уточнить параметры генератора, сделать начальное значение как минимум с 4:
      @Id
      @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = «users_id_seq-generator»)
      @SequenceGenerator(name = «users_id_seq-generator», sequenceName= «users_id_seq»,
      initialValue = 4, allocationSize = 10)
      private long id;
      Либо делать @GeneratedValue(strategy = GenerationType.IDENTITY) — так id будет генерироваться без использования sequence. Это будет просто автоинкрементный столбец, где новое значение генерируется в зависимости от предыдущего. Такой вариант и отправлен сейчас в репозиторий вместе с примером сохранения user, спасибо за замечание.

      1. Если по логике объект User может существовать и без UserDetails, то каким образом нужно сохранить новый объект UserDetails чтобы не возникало ошибки(из-за первого insert)?

        1. Так User без UserDetails вроде можно сохранить:
          User user=new User();
          user.setName(«Jane»);
          userRepository.save(user);

          А вот UserDetails без User сохранить нельзя, используя @MapsId, потому что с ним такая структура в базе генерируется: первичный ключ в UserDetails (он же является внешним) берется из первичного ключа User. Нет User — нет UserDetails.

          Если все же надо сохранять UserDetails без User, то @MapsId не надо использовать.

          1. Это все понятно, я все пытаюсь объяснить проблему когда User уже сохранен в БД и его больше не нужно сохранять.
            User user = userRepo.getById(4L);
            UserDetails ud = new UserDetails();
            ud.setUser(user);
            userDetailsRepo.save(ud) // тут ошибка
            выполняя первый insert он пытается сохранить нового User, но он уже есть в БД с id 4. Цель — сохраняя UserDetails сохранить только его и не трогать таблицу с User. Есть идеи?

          2. У меня такой тест срабатывает (при условии что userdetails c id=3 правда нет, то есть если из data.sql убрать строку insert into user_details (phone, id) values (‘3454’, 3);):
            @Test
            @Commit
            public void shouldSaveUserDetailsWhenUserExists() {
            User user = userRepository.getOne(3l);
            UserDetails ud = new UserDetails();
            ud.setPhone(«123»);
            ud.setUser(user);
            userDetailsRepository.save(ud);
            }

  3. Если мы убираем аннотацию @OneToOne на сущности User, то пропадает возможность воспользоваться cascade, нужного, например, для удаления удаления User. Значит, нужно в методе удаления юзера сначала проверять, существуют ли для него userdetails, затем удалять их и только после этого удалять юзера. В итоге мы получаем, достаточно длинный sql..
    Или же есть другие варианты?

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *