В современных приложениях иногда возникает необходимость получать данные из сложных запросов и аннотация @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<RuntimeConfiguration> 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 и всего, что с ним связано.

Комментарии (0)