Связь вида многие-ко-многим между сущностями проще всего организовать через промежуточную сущность. Такой подход соответствует тому, как создаются связи многие-ко-многим между таблицами в БД. Связующая таблица в таком случае помимо колонок, ссылающихся, на связываемые таблицы, также может иметь любые другие дополнительные колонки.
Подготовка
Создадим базовое веб-приложения на связке Spring Boot 3 + Hibernate + PostgreSQL
Убедитесь, что файле /src/main/resources/application.properties есть следующая строка, позволяющая Hibernate’у автоматически создавать (и обновлять) схему БД при запуске приложения на основании аннотаций в классах предметной области:
spring.jpa.hibernate.ddl-auto=update
Код
Классы предметной области
Создадим класс предметной области Person следующего содержания:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
@Entity public class Person { @Id @GeneratedValue private Long id; private String name; @OneToMany(mappedBy = "person", fetch = FetchType.EAGER) private List<PersonAddress> personAddress; //Конструкторы, геттеры и сеттеры, equals(), hashCode() и т.д. } |
Поле List<PersonAddress> personAddress ссылается на объект, который свяжет две сущности, в БД этому объекту будет соответствовать связующая таблица. В аннотацию @OneToMany передаётся mappedBy значение «person», так как в классе PersonAddress на класс Person будет ссылаться поле person (что естественно).
Также мы устанавливаем параметр fetch равным FetchType.EAGER, чтобы связанные данные автоматически загружались из БД вместе с данными таблицы PERSON. По умолчанию предполагается ленивая загрузка этих данных (что обычно предпочтительней), но она требует транзакционности, которую мы сейчас не рассматриваем.
Создадим класс предметной области 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; @OneToMany(mappedBy = "address", fetch = FetchType.EAGER) private List<PersonAddress> peopleAddress; private String city; private String location; //Конструкторы, геттеры и сеттеры, equals(), hashCode() и т.д. } |
Настройки поля List<PersonAddress> peopleAddress, ссылающегося на связующую сущность, полностью аналогичны таковым у класса Person.
Создадим класс-сущность PersonAddress, который обеспечит связь многие-ко-многим между Person и Address. При этом о каждой связи между людьми и адресами (имеются в виду отношения собственности), в поле Boolean isHome будут храниться сведения, является ли адрес домашним для человека; а в поле Integer purchasingYear — дата приобретения объекта недвижимости.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
@Entity public class PersonAddress { @Id @GeneratedValue private Long id; @ManyToOne @JoinColumn(name = "PERSON_ID") private Person person; @ManyToOne @JoinColumn(name = "ADDRESS_ID") private Address address; private Boolean isHome; private Integer purchasingYear; //Конструкторы, геттеры и сеттеры, equals(), hashCode() и т.д. } |
Поля Person person и Address address помечены аннотациями @ManyToOne, что указывает Hibernate’у хранить в них внешние ключи на соответствующие таблицы. Кроме того в аннотациях @JoinColumn мы задаём параметры колонок-внешних ключей. В данном случае мы задаём их имена. Впрочем, те имена, что мы выбрали полностью аналогичны тем, что были бы созданы автоматически, если бы мы ничего не настраивали. Там не менее, мы продемонстрировали возможность дополнительных настроек колонок-внешних ключей.
Репозитории
Создадим репозиторий для класса Person:
1 2 3 |
@Repository public interface PersonRepository extends JpaRepository<Person, Long> { } |
Далее создадим репозиторий для класса Address:
1 2 3 |
@Repository public interface AddressRepository extends JpaRepository<Address, Long> { } |
А также создадим репозиторий для связующей сущности PersonAddress:
1 2 3 |
@Repository public interface PersonAddressRepository extends JpaRepository<PersonAddress, 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 28 29 30 31 32 33 34 35 36 |
@SpringBootTest class SpringHibernatePostgresqlApplicationTests { @Autowired PersonRepository personRepository; @Autowired AddressRepository addressRepository; @Autowired PersonAddressRepository personAddressRepository; @Test void manyToManyViaEntryTest() { Person irina = new Person("Ирина"); Person vladimir = new Person("Владимир"); personRepository.saveAll(List.of(irina, vladimir)); Address parkovaya = new Address("Москва", "ул. Парковая, д.5"); Address sadovaya = new Address("Москва", "ул. Садовая, д.12"); addressRepository.saveAll(List.of(parkovaya, sadovaya)); personAddressRepository.save(new PersonAddress(irina, parkovaya, true, 2002)); personAddressRepository.save(new PersonAddress(irina, sadovaya, false, 2013)); personAddressRepository.save(new PersonAddress(vladimir, parkovaya, true, 2002)); Person irinaInDb = personRepository.findById(irina.getId()).get(); List<Address> irinasRealEstate = irinaInDb.getPersonAddress().stream().map(PersonAddress::getAddress).toList(); assertTrue(irinasRealEstate.containsAll(List.of(parkovaya, sadovaya))); Address parkovayaInDb = addressRepository.findById(parkovaya.getId()).get(); List<Person> owners = parkovayaInDb.getPeopleAddress().stream().map(PersonAddress::getPerson).toList(); assertTrue(owners.containsAll(List.of(irina, vladimir))); } } |
Создадим два объекта типа Person и сохраним их данные в БД. Далее создадим два объекта Address и также сохраним в БД.
Затем сохраним три связи между объектами Person и Address.
Извлечём из БД данные объекта irina и докажем, что этот объект через промежуточную сущность PersonAddress связан в БД с двумя адресами, как мы это определили выше.
Проведём аналогичную проверку с адресами. Извлечём из БД данные адреса perkovaya. Составим список владельцев этого адреса, доступный через промежуточную сущность PersonAddress. Докажем, что этот список владельцев соответствует списку из объектов irina и vladimir, как мы и определили выше.
Рассмотрим состояние таблиц БД после выполнения выполнения кода:
Поскольку все три класса объявлены сущностями, то Hibernate создал таблицы (а благодаря аннотации @GeneratedValue ещё и сиквенсы) для всех трёх классов.
Рассмотрим схему соединительной таблицы, созданной Hibernate’ом:
Как мы и ожидаем, соединительная таблица PERSON_ADDRESS содержит две колонки-внешние ключи на идентификаторы таблиц PERSON и ADDRESS. А также дополнительные колонки с данными.