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 обращаться в шаблоне и сразу три дополнительных запроса вырисовывалось.