Отображение поля типа Map в отдельную таблицу в Hibernate с помощью аннотаций @ElementCollection и @CollectionTable

Если отображаемый класс содержит поле типа Map, параметризованное базовыми типами (<String, String>, <Integer, Boolean> и т.п.), то данные этого поля не могут быть отображены просто в колонку соответствующей таблицы. Их необходимо отобразить в отдельную таблицу с внешним ключом на id основной.

Например, если класс Person содержит поле Map<String, Boolean> emails, то список адресов электронной почты конкретного человека не может храниться с ним в одной таблице PERSON, нужно завести для них отдельную таблицу EMAILS и хранить там. Hibernate позволяет решить эту задачу, не создавая отдельный класс-сущность Email, а просто разметив поле Map<String, Boolean> emails аннотациями @ElementCollection, @CollectionTable, @MapKeyColumn и @Column.

Подготовка

Создадим базовое веб-приложения на связке Spring Boot 3 + Hibernate + PostgreSQL

Убедитесь, что файле /src/main/resources/application.properties есть следующая строка, позволяющая Hibernate’у автоматически создавать (и обновлять) схему БД при запуске приложения на основании аннотаций в классах предметной области:

spring.jpa.hibernate.ddl-auto=update

Код

Создадим класс предметной области, содержащий поле типа Map<String, Boolean>:

Мы помечаем поле Map<String, Boolean> emails аннотациями @ElementCollection и @CollectionTable, что указывает Hibernate’у создать для данных этой коллекции отдельную таблицу.

В аннотации @CollectionTable параметр name задаёт имя создаваемой таблицы и является обязательным. Необязательный параметр joinColumns принимает массив аннотаций @JoinColumn, в котором задаются имена колонок таблицы EMAILS, которые будут внешними ключами, ссылающимися на PERSON. Если этот параметр не передать, то в таблице EMAILS будет создана одна колонка-внешний ключ с именем PERSON_ID. Таким образом в нашем примере использование параметра joinColumns избыточно и сделано для демонстрации возможности.

У отображений (Map) есть ключ и значение, каждый из которых отображается в свою колонку в таблице EMAILS. Данные ключа будут отображаться в колонку, имя которой задано параметром name в аннотации @MapKeyColumn, данные значения будут отображены в колонку, заданную аннотацией @Column. В нашем примере в таблице EMAILS появятся две колонки: EMAIL и IS_SUBSCRIBED.

Создадим репозиторий с кастомным запросом, который будет делать выборку из двух таблиц: PERSON и из связанной с ней таблицы EMAILS:

Напишем тест, который продемонстрирует работу нашего кода:

Тест показывает, что сохранение данных в таблицы и их извлечение происходит как и ожидается.

Посмотрим на схему БД, которую создал Hibernate на основе наших аннотаций:

В схеме БД мы видим, что для поля emails класса Person, как мы и ожидали, была создана отдельная таблица и данные поля emails хранятся именно в ней.

Таблица EMAILS содержит колонку PERSON_ID с внешним ключом, ссылающимся на ID таблицы PERSON, как мы и указывали в параметре joinColumns аннотации @CollectionTable.

Кроме того, данные ключа отображения (Map) — собственно адреса электронной почты — хранятся в колонке email, как мы и указали в аннотации @MapKeyColumn. А данные значения — в колонке IS_SUBSCRIBED, как и было указано в аннотации @Column.

Данные ключа отображения (колонка EMAIL), а также внешний ключ на таблицу PERSON (колонка PERSON_ID) объеденены Hibernate’ом в составной первичный ключ.