Автор оригинала: Vlad Mihalcea.
Вступление
Java, как и любой другой объектно-ориентированный язык программирования, широко использует наследование и полиморфизм. Наследование позволяет определять иерархии классов, которые предлагают различные реализации общего интерфейса.
Концептуально модель предметной области определяет как данные (например, сохраняемые сущности), так и поведение (бизнес-логику). Тем не менее, наследование более полезно для изменения поведения, а не для повторного использования данных (композиция гораздо более подходит для совместного использования структур).
Даже если данные (сохраняемые сущности) и бизнес-логика (транзакционные службы) не связаны, наследование все равно может помочь в изменении бизнес-логики (например, Шаблон посетителя ).
В этой статье мы рассмотрим, как лучше всего сопоставить наследование SINGLE_TABLE, которое не только является стратегией наследования по умолчанию, но и обычно является наиболее эффективным способом моделирования наследования сущностей.
Модель предметной области
Чтобы проиллюстрировать, как работает наследование сущностей, рассмотрим следующую схему модели:
Корневой сущностью этой модели домена является сущность Board
, поскольку, прямо или косвенно, все остальные сущности связаны с Board
@Entity @Table(name = "board") public class Board { @Id @GeneratedValue private Long id; private String name; //Getters and setters omitted for brevity }
Конечный пользователь может отправить либо Сообщение
, либо Объявление
на определенной Доске
. Поскольку Сообщение
и Объявление
имеют одинаковую функциональность (отличающуюся только данными), они оба наследуются от базового класса Тема
.
Класс Тема
определяет отношение к Доске
сущности, следовательно, Сообщение
и Объявление
сущности также могут быть связаны с экземпляром Доски|/.
@Entity @Table(name = "topic") public class Topic { @Id @GeneratedValue private Long id; private String title; private String owner; @Temporal(TemporalType.TIMESTAMP) private Date createdOn = new Date(); @ManyToOne(fetch = FetchType.LAZY) private Board board; //Getters and setters omitted for brevity }
Как Сообщение
, так и Объявление
сущности расширяют класс Тема
и определяют свои собственные специфические атрибуты.
@Entity public class Post extends Topic { private String content; //Getters and setters omitted for brevity } @Entity public class Announcement extends Topic { @Temporal(TemporalType.TIMESTAMP) private Date validUntil; //Getters and setters omitted for brevity }
Статистика темы
находится в нижней части этой модели предметной области, поскольку она необходима только для целей мониторинга, не будучи напрямую связанной с основной бизнес-логикой. Поскольку статистика необходима как для Публикации
, так и для Объявления
сущностей, статистика Темы
определяет ассоциацию Тема
сущность.
@Entity @Table(name = "topic_statistics") public class TopicStatistics { @Id @GeneratedValue private Long id; @OneToOne @MapsId private Topic topic; private long views; //Getters and setters omitted for brevity }
Сопоставление наследования с одной таблицей
Наследование одной таблицы-это стратегия JPA по умолчанию, направляющая всю иерархию модели домена наследования в единую таблицу базы данных.
Чтобы использовать эту стратегию, класс Тема
сущность должен быть сопоставлен с одной из следующих аннотаций:
@Наследование
(будучи моделью наследования по умолчанию, не обязательно указывать стратегию при использовании наследования одной таблицы).@Наследование(стратегия.SINGLE_TABLE)
.
Сущности Post
и Объявление
не нуждаются в дополнительном сопоставлении (семантики наследования Java достаточно). Сохраняя ту же компоновку, что и на диаграмме классов модели домена, отношения таблиц, связанные с этой стратегией наследования, выглядят следующим образом:
Таблица тема
содержит столбцы, связанные с базовым классом Тема
, а также столбцы, связанные с атрибутами из Сообщения
и Объявления
сущностей.
В следующем примере одна Запись
и одна Объявление
сущности будут сохранены вместе с их связанными @OneToOne
| Тематическими статиками отношениями.
Post post = new Post(); post.setOwner("John Doe"); post.setTitle("Inheritance"); post.setContent("Best practices"); post.setBoard(board); entityManager.persist(post); Announcement announcement = new Announcement(); announcement.setOwner("John Doe"); announcement.setTitle("Release x.y.z.Final"); announcement.setValidUntil( Timestamp.valueOf(LocalDateTime.now().plusMonths(1)) ); announcement.setBoard(board); entityManager.persist(announcement); TopicStatistics postStatistics = new TopicStatistics(post); postStatistics.incrementViews(); entityManager.persist(postStatistics); TopicStatistics announcementStatistics = new TopicStatistics(announcement); announcementStatistics.incrementViews(); entityManager.persist(announcementStatistics);
Как Сообщение
, так и Объявление
сущности сохраняются в таблице тема
, первичный ключ которой используется совместно с таблицей topic_statistics
.
INSERT INTO topic ( board_id, createdOn, owner, title, content, DTYPE, id ) VALUES ( 1, '2016-01-17 09:22:22.11', 'John Doe', 'Inheritance', 'Best practices', 'Post', 1 ) INSERT INTO topic ( board_id, createdOn, owner, title, validUntil, DTYPE, id ) VALUES ( 1, '2016-01-17 09:22:22.11', 'John Doe', 'Release x.y.z.Final', '2016-02-17 09:22:22.114', 'Announcement', 2 ) INSERT INTO topic_statistics (views, id) VALUES (1, 2) INSERT INTO topic_statistics (views, id) VALUES (1, 3)
Одним из преимуществ использования наследования в модели домена является поддержка полиморфных запросов. Когда разработчик приложения выдает запрос выбора для Темы
сущности:
Listtopics = entityManager.createQuery( "select t from Topic t where t.board.id = :boardId", Topic.class) .setParameter("boardId", 1L) .getResultList();
Hibernate переходит к таблице тема
и после извлечения результирующего набора сопоставляет каждую строку с соответствующим экземпляром подкласса (например, Сообщение
или Объявление
), анализируя значение столбца дискриминатора (например, DTYPE
).
SELECT t.id AS id2_1_, t.board_id AS board_id8_1_, t.createdOn AS createdO3_1_, t.owner AS owner4_1_, t.title AS title5_1_, t.content AS content6_1_, t.validUntil AS validUnt7_1_, t.DTYPE AS DTYPE1_1_ FROM topic t WHERE t.board_id = 1
Наследование модели домена позволяет автоматически разрешать ассоциации сущностей базового класса при извлечении. При загрузке Статистики темы
вместе с ее Темой
отношением:
TopicStatistics statistics = entityManager.createQuery( "select s from TopicStatistics s join fetch s.topic t where t.id = :topicId", TopicStatistics.class) .setParameter("topicId", topicId) .getSingleResult();
Hibernate объединяет таблицы topic_statistics
и тема
, чтобы создать Статистику темы
сущность с фактической Публикацией
или Объявлением
ссылкой на объект атрибута.
SELECT ts.id AS id1_2_0_, t.id AS id2_1_1_, ts.views AS views2_2_0_, t.board_id AS board_id8_1_1_, t.createdOn AS createdO3_1_1_, t.owner AS owner4_1_1_, t.title AS title5_1_1_, t.content AS content6_1_1_, t.validUntil AS validUnt7_1_1_, t.DTYPE AS DTYPE1_1_1_ FROM topic_statistics ts INNER JOIN topic t ON ts.id = t.id WHERE t.id = 2
Даже если это непрактично в данном конкретном примере, @OneToMany
ассоциации также возможны.
Сущность Board
может отображать двунаправленные @OneToMany
отношения следующим образом:
@OneToMany(mappedBy = "board") private Listtopics = new ArrayList<>();
Извлечение коллекции лениво генерирует отдельную инструкцию select, идентичную вышеупомянутому запросу Тема
сущность. При быстрой загрузке коллекции для Hibernate требуется одно соединение с таблицей.
Board board = entityManager.createQuery( "select b from Board b join fetch b.topics where b.id = :id", Board.class) .setParameter("id", id) .getSingleResult();
SELECT b.id AS id1_0_0_, t.id AS id2_1_1_, b.name AS name2_0_0_, t.board_id AS board_id8_1_1_, t.createdOn AS createdO3_1_1_, t.owner AS owner4_1_1_, t.title AS title5_1_1_, t.content AS content6_1_1_, t.validUntil AS validUnt7_1_1_, t.DTYPE AS DTYPE1_1_1_, t.board_id AS board_id8_1_0__, t.id AS id2_1_0__ FROM board b INNER JOIN topic t ON b.id = t.board_id WHERE b.id = 1
Ограничения целостности данных
Поскольку все атрибуты подкласса расположены в одной таблице, ограничения NOT NULL
не допускаются для столбцов, принадлежащих подклассам. Будучи автоматически унаследованными всеми подклассами, атрибуты базового класса могут быть ненулевыми.
С точки зрения целостности данных это ограничение противоречит цели согласованности (гарантированной свойствами ACID). Тем не менее, правила целостности данных могут быть применены с помощью процедур запуска базы данных или ПРОВЕРКИ
ограничений (ненулевое значение столбца учитывается на основе значения дискриминатора класса).
Другой подход заключается в перемещении проверки на уровень доступа к данным. Проверка компонентов может проверять @NotNull
атрибуты во время выполнения. JPA также определяет методы обратного вызова (например, @prePersist
, @preUpdate
), а также прослушиватели сущностей (например, @EntityListeners
), которые могут создавать исключение при нарушении ненулевого ограничения.
Стандарт SQL определяет ограничение CHECK
, которое может использоваться для применения проверки на уровне строк для каждой вставленной записи таблицы. В зависимости от базовой базы данных ограничение CHECK
может быть применено (например, Oracle, SQL Server, PostgreSQL) или проигнорировано (например, MySQL ).
Для вышеупомянутых таблиц базы данных столбец content
никогда не должен быть пустым, если базовой записью является Post
, а столбец validUntil
не должен быть пустым, если строка базы данных представляет Объявление
сущность. К счастью, столбец ТИП
по умолчанию определяет тип сущности, связанный с каждой конкретной строкой таблицы.
Для обеспечения вышеупомянутых правил целостности данных необходимо добавить следующие ограничения ПРОВЕРКИ
:
ALTER TABLE Topic ADD CONSTRAINT post_content_check CHECK ( CASE WHEN DTYPE = 'Post' THEN CASE WHEN content IS NOT NULL THEN 1 ELSE 0 END ELSE 1 END = 1 ) ALTER TABLE Topic ADD CONSTRAINT announcement_validUntil_check CHECK ( CASE WHEN DTYPE = 'Announcement' THEN CASE WHEN validUntil IS NOT NULL THEN 1 ELSE 0 END ELSE 1 END = 1 )
При наличии этих ПРОВЕРЯЮЩИХ
ограничений при попытке вставить Сообщение
сущность без содержимого
:
entityManager.persist(new Post());
PostgreSQL выдает следующее сообщение об ошибке:
INSERT INTO topic ( board_id, createdOn, owner, title, content, DTYPE, id ) VALUES ( (NULL(BIGINT), '2016-07-15 13:45:16.705', NULL(VARCHAR), NULL(VARCHAR), NULL(VARCHAR), 'Post', 4 ) -- SQL Error: 0, SQLState: 23514 -- new row for relation "topic" violates check constraint "post_content_check"
Начиная с версии 8.0.16, MySQL поддерживает пользовательские ограничения проверки SQL. Для получения более подробной информации ознакомьтесь с этой статьей .
Для MySQL до версии 8.0.16 тот же результат может быть достигнут с помощью ТРИГГЕРА
вместо этого.
CREATE TRIGGER post_content_insert_check BEFORE INSERT ON topic FOR EACH ROW BEGIN IF NEW.DTYPE = 'Post' THEN IF NEW.content IS NULL THEN signal sqlstate '45000' set message_text = 'Post content cannot be NULL'; END IF; END IF; END; CREATE TRIGGER post_content_update_check BEFORE UPDATE ON topic FOR EACH ROW BEGIN IF NEW.DTYPE = 'Post' THEN IF NEW.content IS NULL THEN signal sqlstate '45000' set message_text = 'Post content cannot be NULL'; END IF; END IF; END; CREATE TRIGGER announcement_validUntil_insert_check BEFORE INSERT ON topic FOR EACH ROW BEGIN IF NEW.DTYPE = 'Announcement' THEN IF NEW.validUntil IS NULL THEN signal sqlstate '45000' set message_text = 'Announcement validUntil cannot be NULL'; END IF; END IF; END; CREATE TRIGGER announcement_validUntil_update_check BEFORE UPDATE ON topic FOR EACH ROW BEGIN IF NEW.DTYPE = 'Announcement' THEN IF NEW.validUntil IS NULL THEN signal sqlstate '45000' set message_text = 'Announcement validUntil cannot be NULL'; END IF; END IF; END;
При запуске предыдущего Post
insert MySQL генерирует следующий вывод:
INSERT INTO topic ( board_id, createdOn, owner, title, content, DTYPE, id ) VALUES ( (NULL(BIGINT), '2016-07-15 13:50:51.989', NULL(VARCHAR), NULL(VARCHAR), NULL(VARCHAR), 'Post', 4 ) -- SQL Error: 1644, SQLState: 45000 -- Post content cannot be NULL
Вывод
Поскольку для хранения сущностей используется только одна таблица, как чтение, так и запись выполняются быстро. Даже при использовании ассоциации @ManyToOne
или @OneToOne
базового класса Hibernate требуется одно соединение между родительской и дочерней таблицами.
Связь сущностей @OneToMany
базового класса также эффективна, поскольку она либо создает вторичный селектор, либо объединяет одну таблицу.
Хотя это немного многословно, ограничения CHECK
и TRIGGER
очень полезны для обеспечения целостности данных при использовании наследования одной таблицы.