Выборка списка сущностей в API почти всегда подразумевает какую-то фильтрацию данных. Например, список постов конкретного автора, за какой-то срок или только опубликованных. А если данных много даже после фильтрации, то они отдаются постранично. Реализовать необходимую логику можно с помощью:
- Автоматической генерации методов в JPA Repository в простых случаях
- JPA Specifications в более сложных случаях
- QueryDSL или других сторонних библиотек
В этом уроке мы поговорим о JPA Specifications — механизме, который позволяет динамически собирать сложные запросы в рамках одного метода без необходимости создавать новый метод под каждое условие выборки.
JPA Specifications
Для реализации этого механизма нужно выполнить следующие шаги:
- Добавить интерфейс JpaSpecificationExecutorв репозиторий
- Создать DTO для параметров запроса, который будет использоваться для фильтрации
- Описать спецификацию для конкретной сущности
- Внедрить использование спецификации в контроллере
Все это мы будем добавлять для сущности Post. Ее код выглядит так:
package io.hexlet.spring.model;
import static jakarta.persistence.GenerationType.IDENTITY;
import java.time.LocalDate;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
@Entity
@Getter
@Setter
@EntityListeners(AuditingEntityListener.class)
@ToString(includeFieldNames = true, onlyExplicitlyIncluded = true)
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@Table(name = "posts")
public class Post {
    @Id
    @GeneratedValue(strategy = IDENTITY)
    @ToString.Include
    @EqualsAndHashCode.Include
    private Long id;
    @ManyToOne(optional = false)
    private User author;
    @Column(unique = true)
    @ToString.Include
    @NotNull
    private String slug;
    @NotBlank
    @ToString.Include
    private String name;
    @NotBlank
    @ToString.Include
    @Column(columnDefinition = "TEXT")
    private String body;
    @LastModifiedDate
    private LocalDate updatedAt;
    @CreatedDate
    private LocalDate createdAt;
}
Обновление репозитория
Для работы динамического фильтра на базе спецификации нужно добавить интерфейс JpaSpecificationExecutor. В нем описаны методы для работы с данными на основе спецификации:
@Repository
public interface PostRepository extends JpaRepository<Post, Long>, JpaSpecificationExecutor<Post> {
    // Объявлять метод не нужно
    // Он уже есть в JpaSpecificationExecutor
    // Page<Post> findAll(Specification<Post> spec, Pageable pageable);
}
Метод findAll интерфейса JpaSpecificationExecutor возвращает страницу с постами на основе переданной спецификации. Вторым параметром метод принимает Pageable, который определяет смещение и количество данных в части LIMIT. Это хорошая практика, потому что возвращение всех данных почти всегда приводит к проблемам с производительностью:
var posts = repository.findAll(/* спецификация */, PageRequest.of(/* текущая страница */, 10));
Создание DTO
Обычно фильтры состоят больше, чем из одного параметра. В этом случае неудобно получать каждый параметр по отдельности. Гораздо проще создать для них DTO, который будет создан при вызове метода контроллера. Spring Boot автоматически сопоставляет параметры запроса со свойствами объекта и заполняет их, если они переданы:
// Имя DTO содержит часть Params
// Так становится понятно, для чего нужен этот DTO
Page<PostDTO> index(PostParamsDTO params) {
Сам DTO включает те параметры, по которым мы хотим фильтровать. В нашем случае это будет:
- Параметр authorIdвыбирает посты по автору
- Параметр nameContвыбирает посты по вхождению в название поста (здесь Cont обозначает contain — «содержать»)
- Параметр createdAtGtвыбирает посты, появившиеся позже указанной даты (здесь gt обозначает greater than — «более чем»)
- Параметр createdAtLtон выбирает посты, появившиеся раньше указанной даты (здесь lt обозначает lesser than — «менее чем»)
Добавлять суффиксы Cont, Gt и Lt в название полей не обязательно. С другой стороны, это очень удобно, потому что позволяет использовать одно и то же поле несколько раз так, что сразу понятно, для чего нужен этот параметр и как он примерно работает. Ниже код соответствующего DTO:
package io.hexlet.spring.dto;
import java.time.LocalDate;
import lombok.Getter;
import lombok.Setter;
@Setter
@Getter
public class PostParamsDTO {
    private String nameCont;
    private Long authorId;
    private LocalDate createdAtGt;
    private LocalDate createdAtLt;
}
Создание спецификации
Спецификация работает как билдер, которому передаются различные условия фильтрации. На базе этой спецификации Spring Boot JPA выполняет генерацию SQL. Ниже один из примеров описания спецификации:
// src/main/java/spring/specification/PostSpecification.java
package io.hexlet.spring.specification;
import java.time.LocalDate;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Component;
import io.hexlet.spring.dto.PostParamsDTO;
import io.hexlet.spring.model.Post;
@Component // Для возможности автоматической инъекции
public class PostSpecification {
    // Генерация спецификации на основе параметров внутри DTO
    // Для удобства каждый фильтр вынесен в свой метод
    public Specification<Post> build(PostParamsDTO params) {
        return withAuthorId(params.getAuthorId())
                .and(withCreatedAtGt(params.getCreatedAtGt()));
    }
    private Specification<Post> withAuthorId(Long authorId) {
        return (root, query, cb) -> authorId == null ? cb.conjunction() : cb.equal(root.get("author").get("id"), authorId);
    }
    private Specification<Post> withCreatedAtGt(LocalDate date) {
        return (root, query, cb) -> date == null ? cb.conjunction() : cb.greaterThan(root.get("createdAt"), date);
    }
    // Остальные методы
}
В методе build происходит сборка спецификации на основе переданных параметров. Каждый параметр формирует свое условие фильтрации данных. Обработка каждого параметра вынесена в свой метод для удобства. Внутри этих методов есть общая логика, связанная с проверкой наличия параметра. Если он отсутствует, то возвращается cb.conjunction(), который ни на что не влияет, но нужен для работы цепочки методов.
Спецификация представляет собой лямбда-функцию с тремя параметрами:
- Объект root(Root<T>), который считается представлением сущности. С помощью него мы указываем, по какому свойству нужно выполнять фильтрацию, включая обращение к свойствам зависимых сущностей
- Объект cb(CriteriaBuilder), который предоставляет методы для создания фильтров —equal(),like()иgreaterThan()
- Объект query(CriteriaQuery<T>), который отвечает за формирование правильной структуры запроса. Еще с его помощью можно указывать используемые колонки, таблицы, условия фильтрации и сортировки данных
Использование спецификации в контроллере
package io.hexlet.blog.controller.api;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import io.hexlet.spring.dto.PostDTO;
import io.hexlet.spring.dto.PostParamsDTO;
import io.hexlet.spring.mapper.PostMapper;
import io.hexlet.spring.repository.PostRepository;
import io.hexlet.spring.specification.PostSpecification;
@RestController
@RequestMapping("/api")
public class PostsController {
    @Autowired
    private PostRepository repository;
    @Autowired
    private PostSpecification specBuilder;
    @Autowired
    private PostMapper postMapper;
    @GetMapping("/posts")
    @ResponseStatus(HttpStatus.OK)
    public Page<PostDTO> index(PostParamsDTO params, @RequestParam(defaultValue = "1") int page) {
        var spec = specBuilder.build(params);
        // Возвращается Page<PostDTO>
        var posts = repository.findAll(spec, PageRequest.of(page - 1, 10));
        var result = posts.map(postMapper::map);
        return result;
    }
}
Что происходит в этом коде:
- В метод приходят параметры для фильтрации и страница, которую нужно выбрать
- На основе параметров для фильтрации формируется спецификация
- Выполняется выборка данных по спецификации и с учетом указанной страницы данных. Из pageвычитается единица, потому что иначе получится запросLIMIT 10 OFFSET 10вместоLIMIT 10 OFFSET 0
- Возвращенный результат Page<Post>преобразуется вPage<PostDTO>с помощью встроенного вPageметодаmap(), который работает точно так же, какmap()в стримах
Самостоятельная работа
В этом задании добавим теги для постов. Теги позволяют классифицировать посты и обеспечивают гибкую работу с категориями контента.
В результате вы сможете создавать, просматривать, обновлять и удалять теги через API, а посты будут корректно сохранять свои теги с поддержкой связи многие-ко-многим.
- Создайте сущность Tag - Поля: id,name(и другие по желанию)
- Реализуйте интерфейс BaseEntity
 
- Поля: 
- Настройте связь Post ↔ Tag - Один пост может иметь несколько тегов
- Один тег может принадлежать нескольким постам
- Настройте таблицу связывания (@JoinTable) и стратегию загрузки (FetchType.LAZYилиEAGER)
 
- Обновите DTO - PostDTO,- PostCreateDTO,- PostUpdateDTOдолжны поддерживать список тегов
- Создайте TagDTO,TagCreateDTO,TagUpdateDTOдля CRUD тегов
 
- Создайте мапперы - TagMapperдля конвертации между сущностью и DTO
- Обновите PostMapper, чтобы можно было преобразовывать список тегов
 
- Создайте репозитории - TagRepositoryс методами поиска по имени
- При необходимости обновите PostRepository
 
- Напишите интеграционные тесты - CRUD для тегов (index,show,create,update,delete)
- Проверка корректного сохранения и загрузки тегов у поста
 
- CRUD для тегов (
- Реализуйте контроллеры - TagsControllerс полным CRUD
- Обновите PostsController, чтобы возвращать список тегов вместе с постами
 
Итог
- Возможность работать с тегами через API: создавать, просматривать, обновлять и удалять
- Посты корректно сохраняют и загружают свои теги
- Связь многие-ко-многим работает корректно
- Данные остаются консистентными, структура приложения становится более гибкой и расширяемой
Для полного доступа к курсу нужен базовый план
Базовый план откроет полный доступ ко всем курсам, упражнениям и урокам Хекслета, проектам и пожизненный доступ к теории пройденных уроков. Подписку можно отменить в любой момент.