Java
August 16

JPA Entity. Загрузи меня не полностью

JPA часто подвергается критике за невозможность загружать сущности частично, что на самом деле является большим заблуждением. Spring Data JPA и Hibernate включают в себя множество инструментов по частичной загрузке сущностей.

Подготовили статью, в которой рассмотрели имеющиеся в Spring Data JPA инструменты для частичной загрузки сущностей, а также разобрали их особенности и corner-кейсы. Давайте попробуем рассмотреть все способы такой частичной загрузки сущностей на примере основных способов взаимодействия с Hibernate в Spring приложениях:

  • Spring Data JPA
  • EntityManager
  • Criteria API

Также существует проект Jakarta Data, который активно развивается. Однако на момент написания этой статьи вышла только первая стабильная версия 1.0.0. Рассматривать его не станем, пока не накопим достаточное количество реальных использований.

Интересует ли эта проблема сообщество

После очередного холивара в одном из Telegram-каналов про Java на тему того, как плох JPA и Hibernate в частности, как он неоптимизированно выполняет запросы и как много грузится лишних данных, я решил немного углубиться в эти вопросы и попытаться встать на защиту упомянутого выше стэка, отправившись в путешествие на Stackoverflow. Сделаем поиск по тегу [spring-data-jpa] и отсортируем вопросы по популярности. Мы увидим, что вопрос Spring JPA selecting specific columns находится на шестом месте.
В этой статье мы постараемся ответить на этот вопрос максимально широко: рассмотрим не только самые простые случаи с базовыми атрибутами, но также окунемся и в мир JPA-ассоциаций.

Задача

В проекте будет рассматриваться следующая модель данных:

Нашей задачей является загрузка нескольких базовый полей + ToOne (в нашем случае это атрибут author, ссылающийся на сущность Post) ассоциации для каждого способа частичной загрузки. Предположим, мы хотим получить все статьи, заголовок которых содержит некоторый текст, причем поиск не чувствителен к регистру, т.е. contains with ignore case. Итого выгружаем Post: id, slug, title; User(author): id, username.

Проверять результат мы будем в соответствующих тестах, итоговые запросы будут видны в консоли: лог, в котором отобразится SQL-запрос, сгенерированный силами Hibernate.

Тестовые данные

Создадим две записи Post, проинициализировав базовые поля и привязав авторов. Сервис в котором создаются и удаляются данные InitTestDataService.

toOne

Всего был найден 21 способ частичной загрузки для поставленной нами задачи.

Эти способы включают в себя разные подходы написания запроса:

  • Spring Data Repository derived-methods
  • Spring Data Repository query-methods
  • Entity Manager
  • Criteria API

Тестовый класс в котором можно увидеть все тесты с комментариями - ToOneTest.

Предисловие

Стоит внести небольшую ясность перед нашим экспериментом. Derived методы — это методы, автоматически реализуемые фреймворком на основе их имен, т.е. без явного указания аннотации @Query. Поскольку мы не указываем запрос явно, а значит у нас отсутствует возможность указать, какие именно атрибуты нам требуется загрузить, у нас есть всего один способ указания конкретных атрибутов для загрузки - это projection.

Сами же проекции бывают двух видов: основанные на интерфейсах (Interface-based Projections) и на классах (Class-based Projections). Interface-based Projections в свою очередь можно поделить на открытые и закрытые.

В закрытых проекциях геттеры объявляются явно:

interface NamesOnly {
  String getFirstname();
  String getLastname();
}

В случае открытых проекций значение геттеров интерфейсов могут высчитываться на основе SpEL выражения:

interface NamesOnly {  
    @Value("#{target.firstname + ' ' + target.lastname}")
    String getFullName();
}  

Их мы рассматривать не будем, т.к. в документации явно сказано, что для них оптимизация запроса производиться не будет:

Spring Data cannot apply query execution optimizations in this case, because the SpEL expression could use any attribute of the aggregate root.

Для загрузки ToOne-ассоциаций ставим задачу проверить два варианта с flatten (плоскими) атрибутами, с nested (вложенным) классом, а также с Tuple и Map.

Для ToMany имеет смысл проверять работу с плоскими атрибутами, однако в этом случае загрузка будет со сложностью n*m. Это означает, что наш эксперимент подходит и для ToOne, и для ToMany, однако записей в случае с ToManyвыгрузится больше.

Почти все, что мы рассмотрим для ToOne, справедливо и для ToMany, однако Hibernate не способен мапить коллекционные атрибуты на DTO/Projection, в связи с чем Hibernate в силах выполнить только HQL, и для кейса с ToMany будет возвращено декартово произведение n*m. Иначе говоря, кроме выгрузки и мапинга результата нам придется еще схлопывать дубликаты записей. Если эта тема будет интересна, мы обязательно напишем дополнительный пост про частичную загрузку и с ToMany-ассоциациями. Однако с несколькими примерами можно ознакомиться в проекте в тестовом классе ToManyTest.

Все, что будет рассмотрено далее, отлично подойдет и для Embbeded-кейса, поэтому отдельно его рассматривать не имеет смысла

Derived-methods

Interface-based flat projection

Для данного кейса будем в качестве проекции использовать отдельно взятый интерфейс. Пожалуй, данный подход можно отнести к базовой концепции в контексте использования проекций.

Объявим в нашем репозитории метод:

public interface PostRepository extends JpaRepository<Post, Long> {  
	<T> List<T> findAllByTitleContainsIgnoreCase(String title, Class<T> projection);
}

Исключительно в целях удобства метод будет заточен под динамическую проекцию, чтобы не писать под каждую проекцию отдельный метод.
Создадим класс проекции:

 public interface PostWithAuthorFlat {  
    Long getId();  
    String getSlug();  
    String getTitle();  
    Long getAuthorId();  
    String getAuthorUsername();  
}

Протестируем решение, написав тест. Попробуем немного углубиться в работу проекции, воспользовавшись дебагом. Ставим breakpoint на нашем тесте:

Завершающим звеном по получению данных является работа класса TupleBackedMap, сам объект Tuple же будет содержать необходимую нам информацию. Чтобы посмотреть цепочку по получению данных, обозначим границу установкой breakpoint в методе получения значения из TupleBackedMap (org.springframework.data.jpa.repository.query.AbstractJpaQuery.TupleConverter.TupleBackedMap):

И наблюдаем следующую цепочку по получению данных:

Полученный прокси сначала дойдет до метода invoke класса MapAccessingMethodInterceptor:

В результате чего мы получаем объект класса Accessor, который предоставляет доступ к самому propertyName. Преобразованная строка будет передана в TupleBackedMap. Далее полученное значение будет нам представлено из всеми нам знакомого объекта Tuple:

Сам же объект Tuple в свою очередь предоставляет нам полный доступ к ключу и значению.

Всю схему можно описать так:
Под проекцией лежит прокси, в прокси - прокси, в прокси лежит некий target-объект, который является объектом TupleBackedMap.

Или так:
«На море на океане есть остров, на том острове дуб стоит, под дубом сундук зарыт, в сундуке — заяц, в зайце — утка, в утке — яйцо» в яйце игла — смерть Кощея!

Подводя итоги данного кейса можно сделать вывод о том, что Spring предоставляет конвертер, который сам маппит Tuple на проекцию. Этот подход отлично подходит для решения поставленной нами задачи, ведь в запросе присутствуют только те колонки, которые указаны в проекции:

Hibernate: 
    select
        p1_0.id,
        p1_0.slug,
        p1_0.title,
        p1_0.author_id,
        a1_0.username 
    from
        posts p1_0 
    left join
        users a1_0 
            on a1_0.id=p1_0.author_id 
    where
        upper(p1_0.title) like upper(?) escape '\'

Interface-based nested interface projections

Следующим способом для решения нашей задачи могло быть решение, основанное на использовании проекции, имеющей внутри себя еще одну проекцию, для получения данных об авторе поста. Это и есть тот самый nested кейс.

public interface PostWithAuthorNested {  
    Long getId();  
    String getSlug();  
    String getTitle();  
    UserPresentation getAuthor();  
}

Для базовых полей будет загружено только то, что указано в проекции. Мы получим необходимые нам id, slug, title. Однако для вложенного объекта будут загружены абсолютно все поля, что конечно, противоречит нашим требованиям. Проблема известна и даже имеет официальный ответ.

Стоит обратить внимание, что PostWithAuthorNested это все тот же прокси вокруг TupleBackedMap, а вот сам вложенный объект UserPresentation является прокси непосредственно вокруг самой сущности User:

Hibernate: 
    select
        p1_0.id,
        p1_0.slug,
        p1_0.title,
        a1_0.id,
        a1_0.bio,
        a1_0.email,
        a1_0.image,
        a1_0.password,
        a1_0.token,
        a1_0.username 
    from
        posts p1_0 
    left join
        users a1_0 
            on a1_0.id=p1_0.author_id 
    where
        upper(p1_0.title) like upper(?) escape '\'

Подводя итоги: подход работает неоптимально, поставленную нами задачу не решает.

Class-based flat projections

Идем далее и следующим вариантом решения задачи по частичной выгрузке полей сущности можно отметить использования в качестве проекции отдельного Record-класса (метод репозитория остается прежним)

public record PostWithAuthorFlatDto(Long id,  
                                    String slug,  
                                    String title,  
                                    Long authorId,  
                                    String authorUsername) {  
}

Сам тест:

@Test  
void derivedMethodClassFlatPrj() {  
    var posts = postRepository.findAllByTitleContainsIgnoreCase(
      "spring",
      PostWithAuthorFlatDto.class
    );  
    assertEquals(1, posts.size());  
    var postFirst = posts.getFirst();  
    assertEquals(POST1_SLUG, postFirst.slug());  
    assertEquals(POST1_AUTHOR_NAME, postFirst.authorUsername());  
}

Spring Data JPA передает result type в Hibernate, который в свою очередь производит мапинг. На плечах Spring'а только лишь грамотное формирование JPQL запроса. Этот вариант нас полностью устраивает.

Результат:

Hibernate: 
    select
        p1_0.id,
        p1_0.slug,
        p1_0.title,
        p1_0.author_id,
        a1_0.username 
    from
        posts p1_0 
    left join
        users a1_0 
            on a1_0.id=p1_0.author_id 
    where
        upper(p1_0.title) like upper(?) escape '\'

Class-based nested dto projections

Идем хорошим темпом, однако стоило бы рассмотреть и негативные, нерабочие сценарии, чтобы лучше понять логику работы с проекциями. Создадим такой record класс с вложенной DTO-проекцией:

public record PostWithAuthorNestedDto(Long id,  
                                      String slug,  
                                      String title,  
                                      UserPresentationDto author) {  
}

Вроде, все должно заработать, однако при попытке "взлететь" мы получим ошибку:

Cannot set field 'author' to instantiate 'io.spring.jpa.projection.PostWithAuthorNestedDto'

О чем нас предупреждали:

В данном случае вся логика лежит на стороне Hibernate, Spring Data здесь не оказывает никакого влияния.

Class-based nested entity projections

Мы также можем указать для record целую сущность. Однако данный способ обязывает нас получить все поля из вложенной сущности. Придется положить в копилку еще один негативный кейс. Не наш вариант, но знать про это, кажется, было бы полезно:

public record PostWithAuthorEntity(Long id,  
                                   String slug,  
                                   String title,  
                                   User author) {  
}

Получаем:

Hibernate: 
    select
        p1_0.id,
        p1_0.slug,
        p1_0.title,
        a1_0.id,
        a1_0.bio,
        a1_0.email,
        a1_0.image,
        a1_0.password,
        a1_0.token,
        a1_0.username 
    from
        posts p1_0 
    left join
        users a1_0 
            on a1_0.id=p1_0.author_id 
    where
        upper(p1_0.title) like upper(?) escape '\'

Query-methods

А теперь давайте рассмотрим способ, когда мы можем явно в запросе указывать, какие атрибуты нам нужно выгрузить, т.е. к Query-методам. В этом случае мы сразу же решаем одну немаловажную задачу - явно указываем то, что хотим получить. В нашей зоне ответственности остается только лишь правильно реализовать маппинг.

Interface-based flat projections

Spring богат своими возможностями, потому он, например, может обработать JPQL который мы написали самостоятельно, а затем и вовсе сформировать TupleBackedMap.

Данный способ интересен тем, что нам ничего не мешает написать обычный JPQL запрос, а в качестве возвращаемого значения указать проекцию:

@Query("""
            select  a.id as id,
                    a.slug as slug,
                    a.title as title,
                    a.author.id as authorId,
                    a.author.username as authorUsername
            from Post a
            where lower(a.title) like lower(concat('%', ?1, '%'))""")
List<PostWithAuthorFlat> findAllPostWithAuthorFlat(String title);

Сама проекция:

public interface PostWithAuthorFlat {  
    Long getId();  
    String getSlug();  
    String getTitle();  
    Long getAuthorId();  
    String getAuthorUsername();  
}

Ключевым моментом формирования JPQL-запроса является требование по указанию алиасов: они обязательно должны быть такими же, как свойства в проекции, иначе магии маппинга не произойдет. Кстати, как и ожидалось, под интерфейсом лежит все та же прокси с TupleBackedMap.

Сам результат:

Hibernate: 
    select
        p1_0.id,
        p1_0.slug,
        p1_0.title,
        p1_0.author_id,
        a1_0.username 
    from
        posts p1_0 
    join
        users a1_0 
            on a1_0.id=p1_0.author_id 
    where
        lower(p1_0.title) like lower(('%'||?||'%')) escape ''

Class-based flat projections

Данный способ по частичному получению данных является базовым для Hibernate. Нужно всего лишь создать класс-проекцию с конструктором. После чего инициализируем этот класс прямо в JPQL. Алиасы в этом случае нам не требуются. Прокси в этом случае также не создается, используется только DTO-проекция.

@Query("""
            select
                new io.spring.jpa.projection.PostWithAuthorFlatDto(
                    a.id, 
                    a.slug, 
                    a.title, 
                    a.author.id, 
                    a.author.username
                ) 
            from Post a
            where lower(a.title) like lower(concat('%', ?1, '%'))""")
List<PostWithAuthorFlatDto> findAllPostWithAuthorFlatDto(String title);

Проекция:

public record PostWithAuthorFlatDto(Long id,  
                                    String slug,  
                                    String title,  
                                    Long authorId,  
                                    String authorUsername) {  
}

Тест:

@Test  
void queryMethodClassFlat() {  
    var posts = postRepository.findAllPostWithAuthorFlatDto("spring");  
    assertEquals(1, posts.size());  
    PostWithAuthorFlatDto post = posts.getFirst();  
    assertEquals(POST1_SLUG, post.slug());  
    assertEquals(POST1_AUTHOR_NAME, post.authorUsername());  
}

Успех:

Hibernate: 
    select
        p1_0.id,
        p1_0.slug,
        p1_0.title,
        p1_0.author_id,
        a1_0.username 
    from
        posts p1_0 
    join
        users a1_0 
            on a1_0.id=p1_0.author_id 
    where
        lower(p1_0.title) like lower(('%'||?||'%')) escape ''

Class-based nested class projections

Важной особенностью метода с использованием вложенных проекций является то, что можно делать сколько угодно вложенностей. Поскольку мы используем HQL, то можем инициализировать нашу DTO как нам удобно. В том числе и создавая новые DTO объекты внутри DTO.

Напишем такой метод:

@Query("""
            select
                new io.spring.jpa.projection.PostWithAuthorNestedDto(
                    a.id,
                    a.slug,
                    a.title,
                    new io.spring.jpa.projection.UserPresentationDto(
                        a.author.id,
                        a.author.username
                    )
                )
            from Post a
            where lower(a.title) like lower(concat('%', ?1, '%'))""")
List<PostWithAuthorNestedDto> findAllPostWithAuthorNestedDto(String title);

А также пару проекций под него: проекцию для самого поста, внутри нее объявляем проекцию для автора.

public record PostWithAuthorNestedDto(Long id,  
                                      String slug,  
                                      String title,  
                                      UserPresentationDto author) {  
}

Проекция для автора:

public record UserPresentationDto(Long id, String username) {  
}

Напишем простой тест:

@Test  
void queryMethodClassNested() {  
    var posts = postRepository.findAllPostWithAuthorNestedDto("spring");  
    assertEquals(1, posts.size());  
    PostWithAuthorNestedDto post = posts.getFirst();  
    assertEquals(POST1_SLUG, post.slug());  
    assertEquals(POST1_AUTHOR_NAME, post.author().username());  
}

Непосредственно запрос:

Hibernate: 
    select
        p1_0.id,
        p1_0.slug,
        p1_0.title,
        p1_0.author_id,
        a1_0.username 
    from
        posts p1_0 
    join
        users a1_0 
            on a1_0.id=p1_0.author_id 
    where
        lower(p1_0.title) like lower(('%'||?||'%')) escape ''

Tuple, Object[], List<>, Map

Hibernate также позволяет возвращать select expression, которые мы можем написать в виде Object[], Tuple, Map, List. Подробно останавливаться на каждом не станем, разница лишь в возвращаемом значении:

@Query("""
            select
                a.id as id,
                a.slug as slug,
                a.title as title
            from Post a
            where lower(a.title) like lower(concat('%', ?1, '%'))""")
List<Tuple> findAllTupleBasic(String title);
@Query("""
            select
                a.id as id,
                a.slug as slug,
                a.title as title
            from Post a
            where lower(a.title) like lower(concat('%', ?1, '%'))""")
List<Object[]> findAllObjectWithAuthor(String title);
@Query("""
            select
                a.id as id,
                a.slug as slug,
                a.title as title
            from Post a
            where lower(a.title) like lower(concat('%', ?1, '%'))""")
List<List<?>> findAllListWithAuthor(String title);
@Query("""
            select
                a.id as id,
                a.slug as slug,
                a.title as title
            from Post a
            where lower(a.title) like lower(concat('%', ?1, '%'))""")
List<Map<String, Object>> findAllMapWithAuthor(String title);

P.S.: для Derived-методов аналогично можно указывать такие же возвращаемые значения. Spring Data все возьмет на себя и выгрузит все поля.

Entity Manager

Все способы, описанные для @Query, работают и в Entity Manager, кроме Interface-based projection, так как Interface-based projection является концепцией самого Spring, сам же Hibernate ничего про нее не знает.

Criteria API

Criteria API используется для создания типобезопасных и гибких запросов к базе данных на языке Java. Сам по себе Criteria API является "type-safe alternative to HQL".

Мы будем использовать стандартные функции CriteriaBuilder, однако, если возникнет потребность в дополнительных функциях из HibernateCriteriaBuilder, приведенные ниже примеры будут актуальны и для него.

Для начала нам нужно указать атрибуты, которые мы собираемся передать в запрос. Имя атрибута мы можем очень удобно указать с помощью константы, которая была сгенерирована автоматически с использованием зависимости:

dependencies {
	annotationProcessor 'org.hibernate:hibernate-jpamodelgen:{version}'
}
var idPath = owner.<Long>get(Post_.ID);  
var slugPath = owner.<String>get(Post_.SLUG);  
var titlePath = owner.<String>get(Post_.TITLE);  
var authorIdPath = owner.get(Post_.AUTHOR).<Long>get(User_.ID);  
var authorUsernamePath = owner.get(Post_.AUTHOR).<String>get(User_.USERNAME);

В последней документации Hibernate данный способ используется во всех примерах, из чего можно сделать вывод, что это "тихая" рекомендация.

По итогу константы всегда будут перегенерированы и всегда будут находиться в актуальном состоянии, в результате чего мы выигрываем в безопасности и надежности приложения.

DTO

Итак, мы объявили path'ы, остается лишь написать запрос, закинуть результат в коллекцию, после чего убедиться в жизнеспособности подхода:

@Test
void criteriaDto() {
  var cb = em.getCriteriaBuilder();
  var query = cb.createQuery(PostWithAuthorFlatDto.class);
  
  var owner = query.from(Post.class);

  var idPath = owner.<Long>get(Post_.ID);
  var slugPath = owner.<String>get(Post_.SLUG);
  var titlePath = owner.<String>get(Post_.TITLE);
  var authorIdPath = owner.get(Post_.AUTHOR).<Long>get(User_.ID);
  var authorUsernamePath = owner.get(Post_.AUTHOR).<String>get(User_.USERNAME);

  query.multiselect(idPath, slugPath, titlePath, authorIdPath, authorUsernamePath)
           .where(cb.like(cb.lower(titlePath), "%spring%"));

  var resultList = em.createQuery(query).getResultList();

  for (PostWithAuthorFlatDto post : resultList) {
      assertEquals(POST1_SLUG, post.slug());
      assertEquals(POST1_AUTHOR_NAME, post.authorUsername());
  }
}

Проекция:

public record PostWithAuthorFlatDto(Long id,  
                                    String slug,  
                                    String title,  
                                    Long authorId,  
                                    String authorUsername) {  
}

В данном случае мы возвращаем множество элементов (атрибутов) и мапим их на DTO, которое указываем при создании query

jakarta.persistence.criteria.CriteriaBuilder#createQuery(java.lang.Class)

Успешный успех:

Hibernate: 
    select
        p1_0.id,
        p1_0.slug,
        p1_0.title,
        p1_0.author_id,
        a1_0.username 
    from
        posts p1_0 
    join
        users a1_0 
            on a1_0.id=p1_0.author_id 
    where
        lower(p1_0.title) like ? escape ''

К минусам работы через DTO можно отнести только то, что сохраняется риск случайного рефактиринга DTO. В этом случае мы поймаем ошибку, что не нашлось, например, подходящего конструктора. Для решения этой проблемы рассмотрим вариант с использованием Tuple.

Tuple

В случае с Tuple инициализация объекта происходит ручным способом и мы попросту не сможем создать сам объект и, конечно, поймаем ошибку ещё до запуска приложения на этапе компиляции.

Представляем вашему вниманию, пожалуй, самый безопасный способ выполнения частичных запросов:

@Test
void criteriaTuple() {
  var cb = em.getCriteriaBuilder();
  var query = cb.createTupleQuery();

  var owner = query.from(Post.class);

  var idPath = owner.<Long>get(Post_.ID);
  var slugPath = owner.<String>get(Post_.SLUG);
  var titlePath = owner.<String>get(Post_.TITLE);
  var authorIdPath = owner.get(Post_.AUTHOR).<Long>get(User_.ID);
  var authorUsernamePath = owner.get(Post_.AUTHOR).<String>get(User_.USERNAME);

  query.select(cb.tuple(idPath, slugPath, titlePath, authorIdPath, authorUsernamePath))
         .where(cb.like(cb.lower(titlePath), "%spring%"));

  var resultList = em.createQuery(query).getResultList().stream()
           .map(tuple -> new PostWithAuthorNestedDto(
                  tuple.get(idPath),
                  tuple.get(slugPath),
                  tuple.get(titlePath),
                  new UserPresentationDto(
                           tuple.get(authorIdPath),
                           tuple.get(authorUsernamePath)
                  )
            )).toList();

  for (PostWithAuthorNestedDto post : resultList) {
      assertEquals(POST1_SLUG, post.slug());
      assertEquals(POST1_AUTHOR_NAME, post.author().username());
  }
}

Сами проекции:

public record PostWithAuthorNestedDto(Long id,  
                                      String slug,  
                                      String title,  
                                      UserPresentationDto author) {  
}
public record UserPresentationDto(Long id, String username) {  
}

После формирования Path мы достаем значения из Tuple и складываем в наше DTO (если не требуются дополнительные манипуляции с выборкой). Таким образом мы получаем не только "type-safe alternative to HQL", но и безопасную и контролируемую работу с результатом нашего запроса. Концепция очень похожа на работу с Tuple в библиотеке QueryDSL.

В результате получаем запрос, удовлетворяющий наши требования. Остальные примеры для Criteria API рассмотрены вот тут. Работает все максимально типично относительно случаев, рассмотренных выше.

CriteriaDefinition в Hibernate 6.3+

UPD: Начиная с версии 6.3, Hibernate позволяет нам использовать вспомогательный класс CriteriaDefinition для уменьшения многословности criteria-запросов.

Пример с DTO будет выглядеть следующим образом:

@Test
void criteriaDefinitionDto() {
    var query = new CriteriaDefinition<>(em, PostWithAuthorFlatDto.class) {{
    var owner = from(Post.class);

    var titlePath = owner.<String>get(Post_.TITLE);
    var authorPath = owner.get(Post_.AUTHOR);

    multiselect(
              owner.get(Post_.ID),
              owner.get(Post_.SLUG),
              titlePath,
              authorPath.get(User_.ID),
              authorPath.get(User_.USERNAME)
      ).where(like(lower(titlePath), "%spring%"));
  }};

    var resultList = em.createQuery(query).getResultList();

    assertEquals(1, resultList.size());
    for (var post : resultList) {
        assertEquals(POST1_SLUG, post.slug());
        assertEquals(POST1_AUTHOR_NAME, post.authorUsername());
    }
}

Пример с Tuple:

@Test
void criteriaDefinitionTuple() {
    var query = new CriteriaDefinition<>(em, Tuple.class) {};
    var owner = query.from(Post.class);

    var idPath = owner.<Long>get(Post_.ID);
    var slugPath = owner.<String>get(Post_.SLUG);
    var titlePath = owner.<String>get(Post_.TITLE);
    var authorPath = owner.get(Post_.AUTHOR);
    var authorIdPath = authorPath.<Long>get(User_.ID);
    var authorUsernamePath = authorPath.<String>get(User_.USERNAME);

    query.where(query.like(query.lower(titlePath), "%spring%"))
            .multiselect(idPath, slugPath, titlePath, authorIdPath, authorUsernamePath);

    var resultList = em.createQuery(query).getResultList().stream()
            .map(tuple -> new PostWithAuthorNestedDto(
                    tuple.get(idPath),
                    tuple.get(slugPath),
                    tuple.get(titlePath),
                    new UserPresentationDto(
                            tuple.get(authorIdPath),
                            tuple.get(authorUsernamePath)
                    )
            )).toList();

    assertEquals(1, resultList.size());
  
    for (var post : resultList) {
        assertEquals(POST1_SLUG, post.slug());
        assertEquals(POST1_AUTHOR_NAME, post.author().username());
    }
}

Ознакомиться с результатами работы всех способов вы можете в репозитории.

Кстати, до 6 версии Hibernate Criteria API-запросы к сущностям выполнялись следующим образом:

Criteria API генерировал обычный JPQL, Hibernate же в свою очередь проводил его анализ в соответствии с грамматикой HQL, только затем генерировался SQL-запрос.

Начиная с Hibernate 6 Criteria API сразу преобразуется в SQM:

Подробнее про SQM можно почитать тут.

Выводы

  1. Если мы пишем HQL/JPQL query, то мы контролируем запрос и возвращаем только то что мы хотим. Вопрос только в том, как мапить.
  2. Если мы пишем HQL/JPQL всегда можно вернуть Tuple или Map и помапить с него на DTO.
  3. "Использовать ли repository derived method?" - каждый решает сам. В простых случаях и HQL будет простым, в сложных - длина имени метода будет стремиться выйти за пределы нашей солнечной системы. В рамках решаемых задач для этого проекта, конкретно для частичной выгрузки данных, repository derived method выглядит самым небезопасным. Мы не можем контролировать запрос, а HQL может поменяться из-за изменения DTO/Projection или самой Entity.
  4. Когда мы работаем с Tuple , очень удобным решением является использование библиотеки hibernate-jpamodelgen, что позволяет нам пользоваться автосгенерированными константами. В последней документации Hibernate данный способ используется во всех примерах, можно сказать, что это "тихая" рекомендация. Также, используя эти константы, легко создавать jakarta.persistence.criteria.Path для Criteria API.
  5. Когда мы пишем Query в Spring Data и используем DTO, по дефолту будут валидироваться выражения с DTO: будут проверены как типы, так и количество аргументов в конструкторе. А самое главное - никакой прокси-магии.
  6. Не знаете что вернуть? Верните Tuple. Это очень удобно.
  7. HQL + Class-Based Projection, он же DTO, он же select new class конструкция работают всегда, включая вложенные классы.
  8. Для ToMany, при выгрузки данных в виде Projection/DTO/Tuple нам придется решать вопрос о мердже дублирующихся данных.

Источник