Проекции в Spring

DTO-объект можно формировать не только в контроллере или сервисе, но и возвращать сразу из базы. Тогда они называются проекциями. В этой статье мы рассмотрим, как заставить метод репозитория возвращать проекцию.

Роль  проекции может играть как класс, так и интерфейс. Во втором случае Spring создает прокси, так что несмотря на кажущееся отсутствие возвращаемого экземпляра, получить значения полей с помощью геттеров  мы все же можем.

Кроме того, интерфейс поддерживает и получение вложенных объектов, а класс — нет.

Но сначала определим сущности.

Сущности

Рассмотрим отношение ManyToOne — много постов (Post) относятся к одному пользователю (User).

Здесь сущность User является вложенной по отношению к Post.

Класс User:

@Entity
@Data
@NoArgsConstructor
public class Post {
    @Id
    @GeneratedValue(generator = "sequence")
    private Long id;

    private String title;

    private String text;

    @ManyToOne
    private User user;
}

Класс Post:

@Data
@Entity
@NoArgsConstructor
public class User {
    @Id
    @GeneratedValue(generator = "sequence")
    private Long id;

    private String email;

    private String nickname;

    private String password;

    private String role="ROLE_USER";

    private boolean locked=false;

}

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

Interface-based проекции

Для этого зададим проекции.

Проекция PostView:

public interface PostView {

    long getId();
    String getTitle();
    UserView getUser();
}

И проекция UserView:

public interface UserView {

    String getNickname();


}

Геттеры в проекциях соответствуют названиям полей в сущностях. Просто их меньше — прописываем только те, что нужны.

А в репозитории задаем метод, возвращающий проекцию:

public interface PostRepository extends JpaRepository<Post, Long> {

    List<PostView> findByTitle(String title);
}

Проверим, что она возвращается:

@Test
public void shouldFindInterfaceProjection() {
    List<PostView> list = postRepository.findByTitle("Super Post3");

    List expected = List.of("Super Post3");

    List actual = list.stream().map(el -> el.getTitle()).collect(Collectors.toList());

    assertLinesMatch(expected, actual);

}

Такая проекция называется закрытой — в ней нет никаких полей кроме тех, что есть в сущностях Post и User.

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

public interface UserView {

    String getNickname();

    @Value("#{target.email + ' ' + target.password}")
    String getInfo();
}

Она тоже успешно ищется. Напишем новый метод в UserRepository, который извлекает проекцию:

public interface UserRepository extends JpaRepository<User, Long> {

    UserView findByNickname(String nickname);
    
}

Проверим, что метод getInfo() действительно возвращает конкатенацию email и пароля:

@Test
public void shouldFindOpenProjection() {
    UserView userView = userRepository.findByNickname("admin");

    assertEquals("admin@example.com password", userView.getInfo());
}

Class-based проекции

Теперь рассмотрим проекцию на основе класса. Ограничение ее в том, что вложенный объект в ней прописывать смысла нет — он не будет извлечен. Сделаем такую проекцию UserDto:

public class UserDto {
    private Long id;
    private String nickname;

    public UserDto(long id, String nickname) {
        this.id = id;
        this.nickname = nickname;
    }
    //getters
}

Важно, чтобы у такого класса присутствовал конструктор, в который передаются все поля. Также необходимы геттеры.

С помощью библиотеки Lombok класс можно упростить:

@Getter
@AllArgsConstructor
public class UserDto {
    private Long id;
    private String nickname;
}

Добавим в UserRepository метод findByEmail(), возвращающий UserDto:

public interface UserRepository extends JpaRepository<User, Long> {

    UserView findByNickname(String nickname);

    List<UserDto> findByEmail(String email);

}

Проверим, что метод работает:

@Test
public void shouldFindClassProjection() {
    List<UserDto> userDtos = userRepository.findByEmail("admin@example.com");
    UserDto userDto = userDtos.get(0);
    assertEquals("admin", userDto.getNickname());
}

Динамические проекции

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

Добавим метод findByNickname(). Для простоты пусть он возвращает не список, а просто объект (хотя можно и список):

public interface UserRepository extends JpaRepository<User, Long> {

    UserView findByNickname(String nickname);

    List<UserDto> findByEmail(String email);

    <T> T findByNickname(String nickname, Class<T> type);
}

Проверим, что метод работает со всеми возвращаемыми типами: UserDto, User, UserView:

@Test
public void shouldFindDynamicProjection() {
    UserDto userDto = userRepository.findByNickname("admin", UserDto.class);
    assertEquals("admin", userDto.getNickname());

    User user = userRepository.findByNickname("admin", User.class);
    assertEquals("admin", user.getNickname());

    UserView userView = userRepository.findByNickname("admin", UserView.class);
    assertEquals("admin", userView.getNickname());
}

Итоги

Мы рассмотрели проекции в Spring. Код примера доступен на GitHub.

Проекции могут возвращать не только генерируемые по имени методы, которые мы рассмотрели. Это могут быть и более сложные методы с @Query.

 

Проекции в Spring: 10 комментариев

  1. Что делать если вложенные объекты в проекции не инициализируются в интерфейс-бэйсед проекции при использовании пользовательского @Query? Т.е у меня в методе репозитория прописан пользовательский запрос и он не распознает вложенные объекты. Я извлекаю все поля из связанных таблиц с помощью inner join.

    1. К сожалению, более сложные случаи нужно обрабатывать вручную: можно написать свой ResultTransformer.
      В данном примере можно вложить UserDto в PostView и прописать Spel-выражение для формирования вложенного объекта (из полей, возвращаемых SQL; важно — для полей в SQL должны быть прописаны алиасы с теми же именами, что в геттерах и в Spel):
      public interface PostView1 {
      long getId();
      String getTitle();
      @Value(«#{new ru.sysout.projections.dto.UserDto(target.userId, target.nickname)}»)
      UserDto getUser();
      }
      П.С.
      Прошу прощения за долгую задержку.
      Пример обновлен.

      1. Спасибо) Это работает. Изначально я решил это куда более извращенным способом, но сейчас всё стало гораздо лаконичнее. Единственное что — как указать в этом выражении какую-либо коллекцию так и не разобрался (там при вызове конструктора ругается на и какое-либо содержимое этих скобок). В этом моменте прикрутил костыль

        1. Я писал в предыдущем комментарии про треугольные скобки, на них ругается. Они не отобразились, видимо, из-за какой-то настроенной защиты от XSS

  2. В PostRepository можно использовать методы типа findByTitle() и т. д. А как насчет методов интерфейса JpaRepository: findAll() и т. д.? По-моему их с PostView использовать не получится.

    1. Методы JpaRepository конечно возвращают то, что возвращают — саму сущность.
      Но можно написать свой запрос @Query с возвращаемым типом List*PostView*, который ищет все записи. Или даже просто объявить:
      List*PostView* findAllBy() — вернет все.
      (сменить имя, потому что findAll() уже занято, и нельзя перегрузить метод, поменяв только возвращаемый тип)

  3. Супер. Спасибо. Избавился от N+1 запроса и без написания руками FETCH JOIN в моем Many-To-One случае. Три таких соотношения и надо было к полям To-One Entity обращаться в шаблоне и сразу три дополнительных запроса вырисовывалось.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *