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.
Что делать если вложенные объекты в проекции не инициализируются в интерфейс-бэйсед проекции при использовании пользовательского @Query? Т.е у меня в методе репозитория прописан пользовательский запрос и он не распознает вложенные объекты. Я извлекаю все поля из связанных таблиц с помощью inner join.
вложенные объекты кстати, тоже прокции
а запрос нативный
К сожалению, более сложные случаи нужно обрабатывать вручную: можно написать свой 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();
}
П.С.
Прошу прощения за долгую задержку.
Пример обновлен.
Спасибо) Это работает. Изначально я решил это куда более извращенным способом, но сейчас всё стало гораздо лаконичнее. Единственное что — как указать в этом выражении какую-либо коллекцию так и не разобрался (там при вызове конструктора ругается на и какое-либо содержимое этих скобок). В этом моменте прикрутил костыль
Я писал в предыдущем комментарии про треугольные скобки, на них ругается. Они не отобразились, видимо, из-за какой-то настроенной защиты от XSS
В PostRepository можно использовать методы типа findByTitle() и т. д. А как насчет методов интерфейса JpaRepository: findAll() и т. д.? По-моему их с PostView использовать не получится.
Методы JpaRepository конечно возвращают то, что возвращают — саму сущность.
Но можно написать свой запрос @Query с возвращаемым типом List*PostView*, который ищет все записи. Или даже просто объявить:
List*PostView* findAllBy() — вернет все.
(сменить имя, потому что findAll() уже занято, и нельзя перегрузить метод, поменяв только возвращаемый тип)
findAllBy() да, это вариант.
Супер. Спасибо. Избавился от N+1 запроса и без написания руками FETCH JOIN в моем Many-To-One случае. Три таких соотношения и надо было к полям To-One Entity обращаться в шаблоне и сразу три дополнительных запроса вырисовывалось.