Отображение в структуру БД иерархии наследования Java по принципу «отдельная таблица на каждый уровень иерархии наследования» с учётом полиморфизма с помощью Hibernate

Классы сущностей Java могут иметь иерархическую структуру и существует ряд стратегий отображения этой структуры в БД. Одной из стратегий является отображение каждого класса, представляющего собой очередной уровень иерархии наследования в отдельную таблицу.

Каждая таблица, хранящая данные очередного класса-наследника, содержит колонку с внешним ключом, ссылающимся на таблицу, хранящую данные класса предка. Выборка данных, таким образом, происходит через JOIN всех таблиц, соответствующей ветви иерархии наследования.

То есть, если классы SpecialClient наследует класс Client, а от в свою очередь наследует класс Person, то для отображения данных будут созданы три таблицы (SPECIAL_CLIENT, CLIENT и PERSON), при этом в таблице SPECIAL_CLIENT будет внешний ключ, ссылающийся на CLIENT, а у той — внешний ключ, ссылающийся на PERSON.

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

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

spring.jpa.hibernate.ddl-auto=update

Также добавим в application.properties настройку, позволяющую видеть создаваемый Hibernate’ом SQL в консоли:

spring.jpa.show-sql=true

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

Класс маркируется аннотацией @Entity, а также аннотацией @Inheritance с параметром strategy = InheritanceType.JOINED. Собственно InheritanceType.JOINED предписывает Hibernate’у создавать отдельную таблицу для каждого класса в иерархии наследования. Также именно в главном предке мы заводим поле с аннотацией @Id. В наследниках его не будет. Все наследники будут по сути использовать первичный ключ предка.

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

Этот класс также помечается аннотацией @Entity. Кроме того мы маркируем его аннотацией @PrimaryKeyJoinColumn, в которую параметром name передаём название служебной колонки, которая будет с одной стороны являться первичный ключом для этой таблицы, с другой, внешним ключом, ссылающимся на первичный ключ таблицы PERSON.

Если этой аннотацией не воспользоваться, то такая колонка всё равно будет создана и будет называться также как и колонка первичного ключа внешней таблицы, на которую она ссылается (в нашем случае был бы id).

Отметим, что мы создали класс Client абстрактным. Но это было необязательно. Любые классы в иерархии могут быть конкретными.

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

Мы помечаем последнего наследника аннотацией @Entity, как и всех его предков. Мы не задаём имя «первичного ключа — одновременно внешнего ключа» с помощью @PrimaryKeyJoinColumn для этого класса, поэтому соответствующая колонка у него будет называться CLIENT_ID, как имя первичного ключа предка (там мы этой аннотацией воспользовались).

Таким образом мы ожидаем, что будет создана таблица PERSON для хранения данных полей класса Person. В ней будет создана колонка первичного ключа id.

Затем будет создана таблица CLIENT для хранения данных полей класса Client. В ней будет создана колонка CLIENT_ID, которая будет одновременно являться первичным ключом для этой таблицы, а также внешним ключом, ссылающимся на PERSON.ID.

Затем будет создана таблица SPECIAL_CLIENT для хранения данных полей класса SpecialClient. В ней будет создана колонка CLIENT_ID, которая будет одновременно являться первичным ключом для этой таблицы, а также внешним ключом, ссылающимся на CLIENT.CLIENT_ID (который, как мы знаем, сам ссылается дальше).

Создадим иерархию репозиториев (каждый в своём классе):

Мы пометили репозитории предков аннотацией @NoRepositoryBean, так как не планируем использовать их в качестве самостоятельных бинов. Но это не обязательно. Если бы мы не объявляли класс Client абстрактным, вполне был бы смысл пометить ClientRepository аннотацией @Repository и использовать как самостоятельный бин.

Кроме того отметим, что совершенно необязательно вообще создавать репозитории предков PersonRepository и ClientRepository. Можно просто унаследовать SpecialClientRepository от JpaRepository<T, ID> и добавить методы findByName() и findByPhone() в него. Но при разветвлённой иерархии наследования это будет нерационально (а она может разветвиться со временем), поэтому проще всегда сразу делать иерархию репозиториев.

Напишем тест, который проиллюстрирует вышеизложенное:

Сперва мы создаём объект класса SpecialClient и сохраняем его в БД. Затем мы извлекаем данные из БД по id и убеждаемся, что сохранённые данные соответствуют извлечённым.

Затем мы в репозитории specialClientRepository используем методы предков для выборки по отдельным полям. Убеждаемся, что наследование методов предков репозиториев работает как и ожидается.

Посмотрим на схему БД:

Как мы и ожидали, для каждого класса в иерархии наследования создана отдельная таблица. Реальный первичный ключ только один и он в таблице предка верхнего уровня PERSON, собственно для него и создан сиквенс.

Посмотрим на таблицу PERSON:

Она содержит колонку ID с первичный ключом и не содержит внешних ключей на другие таблицы.

Посмотрим на таблицу CLIENT:

Она содержит колонку CLIENT_ID (имя которой мы задали в аннотации @PrimaryKeyJoinColumn), которая является одновременно первичным ключом для самой таблицы CLIENT, а также внешним ключом, ссылающимся на PERSON.ID.

Аналогичная история в таблице SPECIAL_CLIENT:

Она также содержит колонку CLIENT_ID, которая является её первичным ключом и ссылкой на CLIENT.CLIENT_ID. Поскольку имя этой колонки мы не задавали, она называется также как и та, на которую ссылается.

Если мы посмотрим в консоль, то увидим, что для выборки данных Hibernate использует JOIN (что естественно, а как ещё):

select s1_0.client_id,s1_1.name,s1_2.phone,s1_0.preference
from special_client s1_0 join person s1_1 on s1_0.client_id=s1_1.id join client s1_2 on s1_0.client_id=s1_2.client_id
where s1_0.client_id=?

Основным преимуществом такого подхода является тот факт, что схема данных получается нормализованной. Плюс можно свободно накладывать на любые поля ограничение NOT NULL.

Обратная сторона — при глубокой и разветвлённой иерархии наследования в выборке будет большое количество JOIN’ов (это не всегда INNER JOIN, могут быть и LEFT OUTER JOIN, в зависимости от устройства иерархии наследования). Это может в какой-то момент существенным образом сказываться на скорости работы с данными.

Плюс некоторые вендоры могут ограничивать количество JOIN’ов в одном запросе и по мере развития иерархии классов предметной области можно в какой-то момент этого ограничения достичь.