Отображение в структуру БД иерархии наследования Java в одну таблицу для всех наследников без использования колонки-дискриминатора

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

Особенностью единой таблицы для хранения данных всех потомков является наличие колонки-дискриминатора, по которой определяется, какая строка в какой именно класс потомок должна быть отображена.

Если по каким-то причинам нет возможности иметь такую колонку, то можно обойтись без неё. Тогда придётся указать Hibernate’у фрагмент SQL, который он будет добавлять в запрос для того, чтобы различать, на какой класс отображать данные той или иной строки таблицы.

Создадим базовое веб-приложения на связке 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 и заводим в нём поле id с аннотацией @Id. Классам- потомкам это поле не понадобится. Аннотация @Inheritance с параметром strategy = InheritanceType.SINGLE_TABLE указывает Hibernate’у сохранять все данные всех потомков этого класса в единую таблицу. Таблица получается ненормализованной, все поля потомков будут отображены в nullable колонки.

Аннотация @org.hibernate.annotations.DiscriminatorFormula предписывает Hibernate’у вместо создания специальной колонки-дискриминатора, по значению которой можно определить к какому из классов-потомков относится содержимое конкретной строки, использовать переданный в параметре value фрагмент SQL, для этих целей. В каждый запрос на выборку Hibernate будет добавлять этот фрагмент, чтобы определить, к какому классу-потомку относятся данные каждой строки, попавшей в выборку.

Создадим классы потомки, каждый в своём файле:

Классы-потомки также помечаются аннотацией @Entity. Кроме того они снабжаются аннотацией @DiscriminatorValue, на значение которой мы смотрим в нашем фрагменте SQL кода, который должен различать потомков в зависимости от содержимого строки.

В нашем примере фрагмент был таким:

case when PHONE is not null then 'CLIENT' else 'EMPLOYEE' end

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

  • Поскольку поле phone специфично для класса-потомка Client, то в таблице соответствующая колонка PHONE не может иметь ограничение NOT NULL. Соответственно мы обязаны тем или иным образом в коде гарантировать, что у объектов класса Client перед сохранением данных это поле будет обязательно заполнено. Иначе при выборке мы получим трудноуловимые ошибки.
  • Сам по себе фрагмент SQL, используемый в качестве дискриминатора, должен быть написан на диалекте конкретной БД и не может быть никак обобщён. Конкретно та строка точно работает в PostreSQL, во многих других СУБД. Но гарантии, что она будет работать должным образом во всех СУБД, поддерживаемых Hibernate’ом — нет.

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

Наследование ClientRepository и EmployeeRepository от PersonRepository необязательно, но в данном случае оно имеет смысл.

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

Метод clientTest() создаёт объект соответствующего класса с некоторыми данными. С помощью репозитория сохраняет данные в базу, а затем убеждается, что если извлечь данные из базы по id, то они совпадают с сохранёнными. Если тест падает, убедитесь, что должным образом определены методы equals() и hashCode() и у класса Client, и у предка, класса Person.

В консоли мы можем посмотреть, как Hibernate делает выборку только строк, отображаемых в объекты Client, с использованием заданного нами фрагмента SQL:

select c1_0.id,c1_0.name,c1_0.phone
from person c1_0
where case when c1_0.PHONE is not null then 'CLIENT' else 'EMPLOYEE' end='CLIENT'
and c1_0.id=?

Поверки, выполняемые тестовым методом employeeTest(), полностью аналогичны таковым у clientTest().

Метод personTest() помимо прочего демонстрирует выборку по всей таблице. Также с использованием нашего SQL фрагмента:

select p1_0.id,case when p1_0.PHONE is not null then 'CLIENT' else 'EMPLOYEE' end,p1_0.name,p1_0.phone,p1_0.personnel_number
from person p1_0
where p1_0.name=?

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

Как видим, создана одна таблица для хранения данных всех классов-потомков, но без колонки-дискриминатора. Решение о том, к какому классу-потомку относятся данные конкретной строки принимается на основе работы заданного в аннотации @DiscriminatorFormula фрагмента SQL, добавляемого во все запросы на выборку из этой таблицы.

Наверное сложно представить ситуацию, когда мы, создавая схему БД, не можем себе позволить создать колонку-дискриминатор. Однако довольно вероятна ситуация, когда схема уже есть, но мы не можем вносить в неё изменения. В этом случае использование @DiscriminatorFormula может оказаться подходящим решением.