При создании связи один-ко-многим между сущностями в качестве коллекции, ссылающейся на сущности, которых «много», помимо обычных списков (List) и множеств (Set), также можно использовать отображения (Map). Это позволяет значения любого (обычно уникального) поля вынести в ключ отображения, а в значение положить саму сущность. Что бывает удобно при некоторых обстоятельствах.
Подготовка
Создадим базовое веб-приложения на связке Spring Boot 3 + Hibernate + PostgreSQL
Убедитесь, что файле /src/main/resources/application.properties есть следующая строка, позволяющая Hibernate’у автоматически создавать (и обновлять) схему БД при запуске приложения на основании аннотаций в классах предметной области:
spring.jpa.hibernate.ddl-auto=update
Код
Классы предметной области
Создадим класс предметной области Address:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
@Entity public class Address { @Id @GeneratedValue private Long id; @ManyToOne private Person person; private String city; private String location; //Конструкторы, геттеры и сеттеры, equals(), hashCode() и т.д. } |
Поле типа Person (класс опишем ниже) помечено аннотацией @ManyToOne, что означает, что мы ожидаем, что таблица ADDRESS будет иметь внешний ключ на таблицу PERSON. А с логической точки зрения за одним человеком может быть закреплено несколько адресов.
Сам адрес с нашей точки зрения состоит из названия города (поле city) и остальной части адреса (улица, номер дома и т.д.) в поле location. Мы ожидаем, что поле location будет уникальным, хотя никак это не обозначаем, чтобы не перегружать изложение.
Создадим класс предметной области Person следующего содержания:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
@Entity public class Person { @Id @GeneratedValue private Long id; private String name; @OneToMany(mappedBy = "person", fetch = FetchType.EAGER) @MapKey(name = "location") private Map<String, Address> addresses = new HashMap<>(); //Конструкторы, геттеры и сеттеры, equals(), hashCode() и т.д. } |
Мы помечаем поле addresses аннотацией @OneToMany, в которую параметром mappedBy передаём имя поля, которое ссылается на Person в классе Address. Также мы устанавливаем параметр fetch равным FetchType.EAGER, чтобы список адресов автоматически загружался из БД вместе с данными таблицы PERSON. По умолчанию предполагается ленивая загрузка этих данных (что обычно предпочтительней), но она требует транзакционности, которую мы сейчас не рассматриваем.
В аннотации @MapKey мы заполняем параметр name значением «location», что означает, что при формировании отображения Map<String, Address> addresses поле location объектов класса Address будет добавляется в отображение в качестве ключа, а сам объект в качестве значения. Очевидно, что поле типа location должно быть уникальным, чтобы подобная операция имела смысл.
Репозитории
Создадим репозиторий для класса Person:
1 2 3 |
@Repository public interface PersonRepository extends JpaRepository<Person, Long> { } |
Также создадим репозиторий для класса Address:
1 2 3 |
@Repository public interface AddressRepository extends JpaRepository<Address, Long> { } |
Проверка кода
Напишем тест, который продемонстрирует работу кода:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
@SpringBootTest class SpringHibernatePostgresqlApplicationTests { @Autowired PersonRepository personRepository; @Autowired AddressRepository addressRepository; @Test void oneToManyByMapTest() { Person irina = new Person("Ирина"); personRepository.save(irina); List<Address> addresses = List.of( new Address(irina, "Москва", "ул. Парковая, д.5"), new Address(irina, "Москва", "ул. Садовая, д.12") ); addressRepository.saveAll(addresses); List<String> locations = addresses.stream().map(Address::getLocation).toList(); Person irinaInDb = personRepository.findById(irina.getId()).get(); assertTrue(irinaInDb.getEmails().keySet().containsAll(locations)); } } |
Создадим объект irina типа Person и сохраним его данные в БД. Затем создадим список из двух объектов типа Address, связанных с объектам irina, и также сохраним их.
Затем с помощью стримов превратим список полных адресов в список значений полей location. Он нам понадобится в дальнейшем.
Извлечём из БД пользователя «Ирина» по id записи.
Утверждение assertTrue доказывает, что поле Map<String, Address> addresses в качестве списка ключей содержит все те же строки, что созданный выше список List<String> locations. А значит, действительно, Hibernate не просто извлёк из БД список адресов, связанный с объектом irina, но и создал из этого списка отображение, где в качестве ключа использовал поле значение поля location объекта Address, а в качестве значения — сам объект.
Рассмотрим состояние таблиц БД после выполнения выполнения кода:
Так как оба созданных класса являются сущностями, Hibernate создал для них отдельные таблицы.
Рассмотрим схемы таблиц, созданных Hibernate’ом:
Как мы и ожидали, благодаря аннотации @ManyToOne над полем Person класса Address, таблица ADDRESS содержит колонку PERSON_ID с внешним ключом на идентификатор таблицы PERSON.