сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Spring и Hibernate

ORM-маппинг в Hibernate

Маппинг: где ORM кусает за руку

Положил новый объект в HashSet и проверил, что множество его находит. Находит. Потом сохранил объект в базу и проверил снова тем же самым объектом - НЕ находит. Ни одной строчки кода между этими проверками я не написал: id из null стал шестёркой, hashCode изменился, и объект потерялся в собственном множестве.

Так выглядит главная особенность работы с ORM (object-relational mapping, отображение объектов на таблицы): библиотека меняет твои объекты за спиной. Спрашивают на собесе именно это - разметку сущностей, кто владеет связью и почему equals с hashCode пишутся не как обычно.

// Формулировки: «как размечаешь связь один ко многим?», «кто владелец связи?», «как писать equals и hashCode для сущности?», «что такое каскад?».

Сущность, идентификатор и связи

Сущность (entity) - класс, отображённый на таблицу: аннотация @Entity, поле-идентификатор с @Id. Идентификатор обычно генерирует база: @GeneratedValue со стратегией IDENTITY означает автоинкремент в самой таблице.

Связи размечаются четырьмя аннотациями: @OneToMany, @ManyToOne, @OneToOne, @ManyToMany. У каждой есть режим загрузки. Ленивый (LAZY) - связанные объекты подтягиваются при первом обращении к ним. Жадный (EAGER) - сразу вместе с основным объектом. Для коллекций ленивый режим стоит по умолчанию, а для @ManyToOne и @OneToOne - ЖАДНЫЙ, и это регулярно оказывается неприятным сюрпризом: загрузил одну книгу, а вместе с ней приехал автор, издательство и всё, что было размечено связями.

// Практическое правило: ставь LAZY везде руками, включая @ManyToOne, а нужные связи подтягивай явно в запросе. Тогда ты решаешь, что грузится, а не разметка пятилетней давности.

сущность
класс, отображённый на таблицу базы
LAZY / EAGER
связь подгружается при обращении / сразу вместе с объектом

Владелец связи и каскады

В двусторонней связи одна сторона главная. Владелец - та, где лежит внешний ключ, обычно сторона @ManyToOne. Противоположная помечается mappedBy и говорит: «колонку в базе пишет не я». Отсюда классическая ошибка - добавить объект только в коллекцию на стороне mappedBy: в памяти всё выглядит правильно, в базе не меняется ничего, потому что владелец про изменение не узнал. Лечится методом-помощником, который проставляет обе стороны разом.

Каскад говорит, какие операции переходят с родителя на детей: CascadeType.ALL передаёт сохранение, обновление и удаление. Рядом живёт отдельная настройка orphanRemoval - «удаляй ребёнка, если его выкинули из коллекции».

Я это проверил. Загрузил автора, удалил одну книгу из его списка и НЕ звал никакого сохранения. В базе книга исчезла: в следующей транзакции у автора осталось две книги вместо трёх. Мощно и опасно - orphanRemoval на связи, которую вы «просто почистили в памяти», удаляет строки безвозвратно.

владелец связи
сторона с внешним ключом, только её изменения попадают в базу
orphanRemoval
объект убрали из коллекции - строку удаляют из базы

Почему equals и hashCode пишутся иначе

Вернёмся к замеру из начала. Я написал самый очевидный equals - сравнение по идентификатору - и такой же hashCode. Пока объект не сохранён, идентификатор равен null. После сохранения он стал шестёркой, hashCode пересчитался, и HashSet перестал находить объект: множество разложило его по корзине для старого значения хеша.

Отсюда два правила. Первое: hashCode сущности обязан быть ПОСТОЯННЫМ на всём жизненном пути объекта - проще всего вернуть константу от класса, тогда никакая генерация идентификатора его не сдвинет. Второе: equals стоит строить на бизнес-ключе - на том, что делает объект уникальным по смыслу (номер паспорта, артикул, электронная почта), а не на техническом идентификаторе.

// Есть и третья тонкость, из-за которой сравнение классов через getClass() ломается: ORM подсовывает вместо объекта прокси - подставной подкласс, который подгружает данные при первом обращении. У прокси другой класс, поэтому getClass() их не сравнит. Спасает проверка через instanceof, а в современной Java - через o instanceof Author a.

@Override public int hashCode() { return Objects.hash(id); }  // ловушка

var fresh = new Author("Новый");
Set<Author> set = new HashSet<>(); set.add(fresh);
// до сохранения:    id=null, set.contains(fresh) = true
// после сохранения: id=6,    set.contains(fresh) = false
бизнес-ключ
то, что делает объект уникальным по смыслу, а не по идентификатору
прокси сущности
подставной подкласс, подгружающий данные при первом обращении

Как отвечать: «Как писать equals и hashCode для сущности?»

Не так, как для обычного объекта. Главное требование - hashCode должен быть постоянным всю жизнь объекта, потому что идентификатор появляется только после сохранения. Я это проверял: положил несохранённый объект в HashSet, множество его находило, а после сохранения тот же самый объект перестало находить - идентификатор из null стал числом, и хеш уехал. Поэтому hashCode я делаю константным для класса, а equals строю на бизнес-ключе: на том, что уникально по смыслу - артикул, электронная почта, номер документа. Если бизнес-ключа нет, можно сравнивать по идентификатору, но с проверкой на null и с тем же константным hashCode. И сравнение типов пишу через instanceof, а не через getClass, потому что ORM подставляет прокси - подкласс, у которого getClass вернёт другое значение.

Почему это сильный ответ: названо требование постоянства хеша с объяснением, откуда оно берётся, дана рабочая схема и упомянут прокси - деталь, на которой ломается привычный шаблон equals.

На чём валят

  • hashCode по генерируемому идентификатору. После сохранения объект теряется в своём же множестве - я это замерил.
  • Сравнение типов через getClass() в equals. ORM подставляет прокси-подкласс, и сравнение проваливается; нужен instanceof.
  • Добавить объект только на сторону mappedBy. В памяти связь есть, в базе нет: колонку пишет владелец.
  • Оставить EAGER на @ManyToOne по умолчанию. Один запрос тянет за собой половину связанного графа.
  • orphanRemoval на связи, которую чистят «временно, в памяти». Строки удалятся из базы без всякого save.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 21, остальные разбираются в тренажёре.

  1. #orm_mapping1 / 5
    Что помечает аннотация @Entity на классе?
    A)Класс, экземпляры которого автоматически сериализуются в JSON при возврате из контроллера REST
    B)Класс — сущность, отображаемая на таблицу БД и управляемая ORM
    C)Класс, который Hibernate делает неизменяемым: его поля не получится менять после создания объекта
    D)Класс-конфигурацию Hibernate, где перечисляют настройки подключения к базе данных приложения
    показать ответ и разбор
    +B)Класс — сущность, отображаемая на таблицу БД и управляемая ORM

    // разбор: @Entity помечает POJO как ПЕРСИСТЕНТНУЮ СУЩНОСТЬ: Hibernate отображает её на таблицу (имя = класс или @Table), поля — на колонки (@Column), а один @Id обязателен как первичный ключ. Экземпляры такой сущности могут управляться контекстом персистентности (сохраняться, отслеживаться, загружаться). Требования: непустой конструктор, не final. Часто путают роль @Entity (модель хранения) с DTO (модель передачи) — их обычно разделяют, чтобы не тащить особенности БД/ленивые связи в API.

  2. #orm_mapping2 / 5
    Чем стратегии @GeneratedValue IDENTITY и SEQUENCE отличаются на практике?
    A)IDENTITY генерирует UUID на стороне приложения, а SEQUENCE берёт целочисленный ключ из таблицы-счётчика
    B)Они эквивалентны: обе позволяют Hibernate группировать вставки в один пакетный запрос
    C)IDENTITY — авто-инкремент БД (мешает батч-вставке); SEQUENCE — последовательность (можно батчить)
    D)SEQUENCE работает только в MySQL, а IDENTITY — только в PostgreSQL, поэтому выбор зависит от СУБД
    показать ответ и разбор
    +C)IDENTITY — авто-инкремент БД (мешает батч-вставке); SEQUENCE — последовательность (можно батчить)

    // разбор: IDENTITY использует авто-инкрементный столбец БД: id становится известен только ПОСЛЕ выполнения INSERT, поэтому Hibernate вынужден вставлять строки по одной — БАТЧИНГ вставок отключается (проблема на массовых сохранениях). SEQUENCE берёт значения из последовательности БД ЗАРАНЕЕ (и может выбирать пачками через allocationSize), поэтому позволяет группировать INSERT'ы в батчи. Для PostgreSQL/Oracle SEQUENCE обычно предпочтительнее по производительности. AUTO отдаёт выбор провайдеру, TABLE — медленный переносимый вариант.

  3. #orm_mapping3 / 5
    Что означает mappedBy в двунаправленной связи @OneToMany(mappedBy = "...")?
    A)Эта сторона владеет связью и хранит внешний ключ, а mappedBy задаёт имя колонки этого ключа в таблице
    B)MappedBy включает каскадное удаление: при удалении родителя все связанные дочерние строки удаляются
    C)MappedBy создаёт отдельную соединительную таблицу для хранения связи между двумя сущностями
    D)Эта сторона — обратная (inverse); владелец связи и внешний ключ — на другой стороне
    показать ответ и разбор
    +D)Эта сторона — обратная (inverse); владелец связи и внешний ключ — на другой стороне

    // разбор: В двунаправленной связи ВЛАДЕЛЕЦ (owning side) — та сторона, что содержит внешний ключ; обычно это @ManyToOne с @JoinColumn. Противоположная @OneToMany-сторона помечается mappedBy="поле" и является INVERSE (обратной): она лишь отражает связь, но НЕ управляет её сохранением в БД. Частая ошибка: изменить только inverse-коллекцию (parent.getChildren().add(child)) и удивляться, что FK не записался — синхронизировать нужно OWNING-сторону (child.setParent(parent)). Забытый mappedBy приводит к лишней join-таблице и дублирующим апдейтам.

  4. #orm_mapping4 / 5
    Какой fetch-тип по умолчанию у @ManyToOne и @OneToMany?
    A)@ManyToOne — EAGER, @OneToMany — LAZY
    B)Обе связи по умолчанию EAGER: Hibernate сразу подгружает связанные сущности вместе с корнем
    C)Обе связи по умолчанию LAZY, поэтому связанные данные не грузятся, пока к ним явно не обратятся
    D)Тип загрузки по умолчанию не задан спецификацией и зависит только от настроек конкретной СУБД
    показать ответ и разбор
    +A)@ManyToOne — EAGER, @OneToMany — LAZY

    // разбор: По спецификации JPA умолчания: связи «к одному» (@ManyToOne, @OneToOne) — EAGER (подгружаются сразу вместе с сущностью), связи «ко многим» (@OneToMany, @ManyToMany) — LAZY (грузятся при первом обращении). На практике EAGER у @ManyToOne часто вреден (тянет лишние джойны, порождает N+1 и неожиданные запросы), поэтому многие ЯВНО ставят fetch=LAZY даже на to-one и подгружают что нужно через JOIN FETCH/@EntityGraph. Понимание дефолтов — ключ к контролю над генерируемыми запросами.

  5. #orm_mapping5 / 5
    Как правильно смоделировать связь «многие-ко-многим» в Hibernate?
    A)Хранить в одной из сущностей список идентификаторов другой в виде строки через запятую в одной колонке
    B)@ManyToMany с соединительной таблицей (@JoinTable) или отдельной entity-связкой
    C)Продублировать все поля одной сущности внутри другой, чтобы избежать создания дополнительных таблиц
    D)Связь многие-ко-многим в реляционной БД не выходит — её приходится разбивать на уровне приложения вручную
    показать ответ и разбор
    +B)@ManyToMany с соединительной таблицей (@JoinTable) или отдельной entity-связкой

    // разбор: Многие-ко-многим в реляционной модели реализуется ПРОМЕЖУТОЧНОЙ (join) таблицей с двумя внешними ключами. В Hibernate это @ManyToMany с @JoinTable (указывают имя таблицы и колонки-ключи), одна сторона — владелец, другая — mappedBy. Важный нюанс: если у самой связи есть АТРИБУТЫ (дата, количество, роль), чистый @ManyToMany не подходит — join-таблицу превращают в отдельную @Entity с двумя @ManyToOne (и своим ключом). Также @ManyToMany требует аккуратности с каскадами и производительностью (легко получить лишние удаления/вставки всей коллекции).

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.