[Перевод] Вся правда о @Subselect: преимущества, недостатки и альтернативы в Hibernate
В современных приложениях иногда возникает необходимость получать данные из сложных запросов и аннотация @Subselect
в Hibernate может стать отличным решением.
В новой статье от эксперта сообщества Spring АйО, Михаила Поливахи, вы узнаете как использовать @Subselect
, какие существуют альтернативы и в чём заключаются их преимущества и недостатки.
1. Введение
В этом посте мы разберём аннотацию @Subselect
в Hibernate: как её применять, какие преимущества она даёт и с какими ограничениями можно столкнуться.
2. Что такое @Subselect?
@Subselect позволяет помапить неизменяемую сущность на результаты SQL-запроса. Давайте разберем это определение, начав с того, что означает маппинг сущности на результаты SQL-запроса.
2.1 Маппинг на SQL-запрос
Когда мы создаём наши сущности в Hibernate, мы аннотируем их с помощью @Entity
. Благодаря этой аннотации мы указываем, что это сущность и что она должна управляться persistence context’ом. Дополнительно можно использовать аннотацию @Table
, чтобы указать, с какой именно таблицей в базе данных Hibernate должен связать эту сущность. По умолчанию, если мы создаём сущность в Hibernate, предполагается, что она напрямую связана с определённой таблицей. В большинстве случаев это именно то, что нам нужно, но не всегда.
Иногда наша сущность не связана напрямую с конкретной таблицей в базе данных, а является результатом выполнения SQL-запроса. Например, у нас может быть сущность Client, где каждая её запись представляет строку из результата выполнения SQL-запроса:
SELECT
u.id as id,
u.firstname as name,
u.lastname as lastname,
r.name as role
FROM users AS u
INNER JOIN roles AS r
ON r.id = u.role_id
WHERE u.type = 'CLIENT'
Важно отметить, что в базе данных вообще может не быть отдельной таблицы clients. Именно это и означает маппинг сущности на SQL-запрос — мы получаем сущности из подзапроса SQL, а не из таблицы. Этот запрос может выбирать данные из любых таблиц и выполнять любую логику — Hibernate это не волнует.
2.2 Неизменяемость
Соответственно, иногда мы можем работать с сущностью, которая не привязана к конкретной таблице. В результате становится непонятно, как выполнять операции INSERT
или UPDATE
. Просто нет таблицы, например clients
(как в приведённом выше примере), в которую можно вставлять записи.
Действительно, Hibernate не знает, какой именно SQL используется для получения данных. Поэтому Hibernate не может выполнять операции записи для такой сущности — она становится сущностью только для чтения.
Самое интересное тут в том, что мы всё же можем попросить Hibernate вставить запись в таблицу, связанную с этой сущностью, но наша просьба завершится ошибкой. Почему? Потому что невозможно (по крайней мере, в рамках ANSI SQL) выполнить INSERT в подзапрос (sub-select).
3. Пример использования
Теперь, когда мы понимаем, что делает аннотация @Subselect
, давайте попробуем применить её на практике. Вот пример простой сущности, которую мы будем мапить на подзапрос — RuntimeConfiguration
:
@Data
@Entity
@Immutable
// language=sql
@Subselect(value = """
SELECT
ss.id,
ss.key,
ss.value,
ss.created_at
FROM system_settings AS ss
INNER JOIN (
SELECT
ss2.key as k2,
MAX(ss2.created_at) as ca2
FROM system_settings ss2
GROUP BY ss2.key
) AS t ON t.k2 = ss.key AND t.ca2 = ss.created_at
WHERE ss.type = 'SYSTEM' AND ss.active IS TRUE
""")
@EqualsAndHashcode(onlyExplicitelyIncluded = true)
public class RuntimeConfiguration {
@Id
@EqualsAndHashcode.Include
private Long id;
@Column(name = "key")
private String key;
@Column(name = "value")
private String value;
@Column(name = "created_at")
private Instant createdAt;
}
Этот объект представляет собой актуальный параметр рантайма нашего приложения. Однако, чтобы просто получить набор актуальных параметров, относящихся к нашему приложению, нам нужно выполнить определённый SQL-запрос к таблице system_settings
. Как видно, тело аннотации @Subselect
содержит этот SQL-запрос. Поскольку каждая запись RuntimeConfiguration
по сути является парой «ключ-значение», мы можем реализовать простой запрос — получить самую последнюю активную запись RuntimeConfiguration
с конкретным ключом.
Обратите внимание, что мы аннотировали наш объект @Immutable
. Это означает, что Hibernate отключит отслеживание изменений для этого объекта (имеется в виду dirty-checking), чтобы избежать случайных запросов UPDATE
.
Таким образом, если мы хотим получить RuntimeConfiguration
с конкретным ключом, мы можем сделать следующее:
@Test
void givenEntityMarkedWithSubselect_whenSelectingRuntimeConfigByKey_thenSelectedSuccessfully() {
String key = "config.enabled";
CriteriaBuilder criteriaBuilder = entityManager.getCriteriaBuilder();
CriteriaQuery query = criteriaBuilder.createQuery(RuntimeConfiguration.class);
var root = query.from(RuntimeConfiguration.class);
RuntimeConfiguration configurationParameter = entityManager
.createQuery(query.select(root).where(criteriaBuilder.equal(root.get("key"), key))).getSingleResult();
Assertions.assertThat(configurationParameter.getValue()).isEqualTo("true");
}
Здесь мы используем Hibernate Criteria API, чтобы выполнить запрос RuntimeConfiguration
по ключу. Теперь давайте посмотрим, какой именно запрос Hibernate генерирует для выполнения нашего запроса:
select
rc1_0.id,
rc1_0.created_at,
rc1_0.key,
rc1_0.value
from
( SELECT
ss.id,
ss.key,
ss.value,
ss.created_at
FROM
system_settings AS ss
INNER JOIN
( SELECT
ss2.key as k2, MAX(ss2.created_at) as ca2
FROM
system_settings ss2
GROUP BY
ss2.key ) AS t
ON t.k2 = ss.key
AND t.ca2 = ss.created_at
WHERE
ss.type = 'SYSTEM'
AND ss.active IS TRUE ) rc1_0
where
rc1_0.key=?
Как мы видим, Hibernate просто выбирает записи из SQL-запроса, указанного в аннотации @Subselect
. Каждый фильтр, который мы задаём, будет применён к результирующему набору записей подзапроса.
4. Альтернативы
Опытные Hibernate-разработчики могут заметить, что существуют другие способы добиться похожего результата. Один из них — DTO проджекшен, другой — воспользоваться маппингом сущности на view. У каждого из этих подходов есть свои плюсы и минусы. Давайте разберем их по порядку.
4.1 Проекция данных в DTO
Давайте немного поговорим о DTO проекциях. Этот подход позволяет отображать результаты SQL-запросов в DTO, которые не являются сущностями. Считается, что работа с DTO быстрее, чем с сущностями. DTO также являются неизменяемыми, что означает, что Hibernate не управляет такими объектами и не выполняет проверку изменений (dirty-checking).
Однако у этого подхода есть свои ограничения. Одно из главных — отсутствие поддержки ассоциаций в DTO. Это логично, поскольку мы имеем дело с объектами, которые не являются управляемыми сущностями. Благодаря этому DTO быстрее работают в Hibernate, но это также означает, что контекст персистентности не управляет ими. Следовательно, в DTO нельзя использовать поля OneToX или ManyToX.
Тем не менее, если мы отображаем сущность на SQL-запрос, то работаем именно с сущностью. Такая сущность может включать управляемые ассоциации. Поэтому данное ограничение не относится к отображению сущности на запрос.
Еще одно важное и концептуальное отличие состоит в том, что аннотация @Subselect
позволяет представлять сущность как SQL-запрос. Hibernate выполняет то, что отражено в названии аннотации — использует предоставленный SQL-запрос для выборки данных (наш запрос становится подзапросом) и применяет к нему дополнительные фильтры. Например, если для получения сущности X нам нужно выполнить фильтрацию, группировку и другие операции, то при использовании DTO мы будем вынуждены каждый раз прописывать эти фильтры и группировки в каждом JPQL- или нативном запросе. При использовании @Subselect
мы можем определить этот запрос один раз и просто выполнять выборки из него.
4.2 Маппинг на View
Не многие знают, но Hibernate позволяет отображать сущности на SQL Views прямо из коробки. Это очень похоже на отображение сущности на SQL-запрос. Представление в базе данных почти всегда доступно только для чтения. Существуют исключения в некоторых СУБД, например, простые представления (Simple Views) в PostgreSQL, но в общем и целом запись во View как функциональность полностью vendor-specific. Маппинг во View означает, что наши сущности также будут неизменяемыми: мы сможем только читать данные из View, но не обновлять или добавлять их (опять же, есть исключения).
В целом разница между аннотацией @Subselect
и отображением сущности на представление минимальна. В первом случае используется SQL-запрос, который мы указываем в аннотации, а во втором — уже существующее представление. Оба подхода поддерживают управляемые ассоциации, поэтому выбор между ними зависит только от ваших требований.
5. Заключение
В этой статье мы обсудили, как использовать аннотацию @Subselect
для выборки сущностей не из конкретной таблицы, а из подзапроса. Это очень удобно, если мы не хотим дублировать одни и те же части SQL-запросов для получения сущностей. Однако это приводит к тому, что сущности с аннотацией @Subselect
фактически становятся неизменяемыми, и пытаться сохранять их из кода приложения не следует. Есть и альтернативы @Subselect
, например, привязка сущностей к View в Hibernate или использование DTO проекций. У каждого подхода есть свои плюсы и минусы, поэтому, как всегда, выбор зависит от требований и здравого смысла.
Как обычно, исходный код доступен на GitHub.
Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.