Классы сущностей 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
Создадим абстрактный класс-предок, который составит верхний уровень иерархии наследования классов предметной области:
1 2 3 4 5 6 7 8 9 10 11 |
@Entity @Inheritance(strategy = InheritanceType.JOINED) public abstract class Person { @Id @GeneratedValue protected Long id; protected String name; //Конструкторы, геттеры и сеттеры, equals(), hashCode() и т.д. } |
Класс маркируется аннотацией @Entity, а также аннотацией @Inheritance с параметром strategy = InheritanceType.JOINED. Собственно InheritanceType.JOINED предписывает Hibernate’у создавать отдельную таблицу для каждого класса в иерархии наследования. Также именно в главном предке мы заводим поле с аннотацией @Id. В наследниках его не будет. Все наследники будут по сути использовать первичный ключ предка.
Создадим ещё один класс, который будет наследовать Person и представлять собой промежуточный уровень иерархии наследования:
1 2 3 4 5 6 7 |
@Entity @PrimaryKeyJoinColumn(name = "CLIENT_ID") public abstract class Client extends Person { private String phone; //Конструкторы, геттеры и сеттеры, equals(), hashCode() и т.д. } |
Этот класс также помечается аннотацией @Entity. Кроме того мы маркируем его аннотацией @PrimaryKeyJoinColumn, в которую параметром name передаём название служебной колонки, которая будет с одной стороны являться первичный ключом для этой таблицы, с другой, внешним ключом, ссылающимся на первичный ключ таблицы PERSON.
Если этой аннотацией не воспользоваться, то такая колонка всё равно будет создана и будет называться также как и колонка первичного ключа внешней таблицы, на которую она ссылается (в нашем случае был бы id).
Отметим, что мы создали класс Client абстрактным. Но это было необязательно. Любые классы в иерархии могут быть конкретными.
Создадим класс-наследник Client’а, который будет представлять собой последний уровень иерархии наследования:
1 2 3 4 5 6 |
@Entity public class SpecialClient extends Client { private String preference; //Конструкторы, геттеры и сеттеры, equals(), hashCode() и т.д. } |
Мы помечаем последнего наследника аннотацией @Entity, как и всех его предков. Мы не задаём имя «первичного ключа — одновременно внешнего ключа» с помощью @PrimaryKeyJoinColumn для этого класса, поэтому соответствующая колонка у него будет называться CLIENT_ID, как имя первичного ключа предка (там мы этой аннотацией воспользовались).
Таким образом мы ожидаем, что будет создана таблица PERSON для хранения данных полей класса Person. В ней будет создана колонка первичного ключа id.
Затем будет создана таблица CLIENT для хранения данных полей класса Client. В ней будет создана колонка CLIENT_ID, которая будет одновременно являться первичным ключом для этой таблицы, а также внешним ключом, ссылающимся на PERSON.ID.
Затем будет создана таблица SPECIAL_CLIENT для хранения данных полей класса SpecialClient. В ней будет создана колонка CLIENT_ID, которая будет одновременно являться первичным ключом для этой таблицы, а также внешним ключом, ссылающимся на CLIENT.CLIENT_ID (который, как мы знаем, сам ссылается дальше).
Создадим иерархию репозиториев (каждый в своём классе):
1 2 3 4 5 6 7 8 9 10 11 12 13 |
@NoRepositoryBean public interface PersonRepository<T extends Person> extends JpaRepository<T, Long> { List<T> findByName(String name); } @NoRepositoryBean public interface ClientRepository<T extends Client> extends PersonRepository<T> { List<T> findByPhone(String phone); } @Repository public interface SpecialClientRepository extends ClientRepository<SpecialClient> { } |
Мы пометили репозитории предков аннотацией @NoRepositoryBean, так как не планируем использовать их в качестве самостоятельных бинов. Но это не обязательно. Если бы мы не объявляли класс Client абстрактным, вполне был бы смысл пометить ClientRepository аннотацией @Repository и использовать как самостоятельный бин.
Кроме того отметим, что совершенно необязательно вообще создавать репозитории предков PersonRepository и ClientRepository. Можно просто унаследовать SpecialClientRepository от JpaRepository<T, ID> и добавить методы findByName() и findByPhone() в него. Но при разветвлённой иерархии наследования это будет нерационально (а она может разветвиться со временем), поэтому проще всегда сразу делать иерархию репозиториев.
Напишем тест, который проиллюстрирует вышеизложенное:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
@SpringBootTest class SpringHibernatePostgresqlApplicationTests { @Autowired SpecialClientRepository specialClientRepository; @Test void specialClientTest() { SpecialClient specialClient = new SpecialClient("Irina", "+7 123 456 78 90", "Предпочитает пироженое."); specialClientRepository.save(specialClient); SpecialClient specialClientInDb = specialClientRepository.findById(specialClient.getId()).get(); assertEquals(specialClient, specialClientInDb); List<SpecialClient> searchByPhoneResult = specialClientRepository.findByPhone("+7 123 456 78 90"); assertFalse(searchByPhoneResult.isEmpty()); List<SpecialClient> searchByNameResult = specialClientRepository.findByName("Irina"); assertFalse(searchByNameResult.isEmpty()); } } |
Сперва мы создаём объект класса 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’ов в одном запросе и по мере развития иерархии классов предметной области можно в какой-то момент этого ограничения достичь.