Практическая задача: Реализация сложного фильтра поиска
Представьте типичный REST API интернет-магазина. Пользователь открывает каталог и начинает комбинировать фильтры: выбирает категорию, задает диапазон цен, отмечает галочками несколько статусов («Новинка», «Скидка»). На сервер прилетает запрос: GET /api/products?categoryId=5&minPrice=100&maxPrice=500&statuses=NEW,SALE&page=0&size=20.
Нам нужно вернуть страницу с результатами. Но мы не хотим выгружать из базы тяжеловесные сущности Product со всеми их связями и текстовыми описаниями (проблема SELECT *, которую мы обсуждали в прошлой главе). Нам нужен только легкий DTO для отображения карточки товара.
Задача звучит просто: скрестить динамический запрос (из главы 27) с проекцией (из главы 28) и добавить пагинацию. Однако именно на стыке этих трех механизмов разработчики чаще всего ломают копья, получая либо неоптимальные SQL-запросы, либо падения в рантайме.
В этой статье мы реализуем production-ready фильтр тремя разными способами, разберем их нюансы и обойдем главную ловушку динамической пагинации.
Дано: Доменная модель и контракты
У нас есть сущность товара, связанная с категорией.
@Entity
public class Product {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private BigDecimal price;
@Enumerated(EnumType.STRING)
private ProductStatus status;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id")
private Category category;
// ... тяжелые поля, например, byte[] image или String description
}
На вход мы получаем объект фильтра (обычно собирается в контроллере из query-параметров):
public record ProductFilter(
Long categoryId,
BigDecimal minPrice,
BigDecimal maxPrice,
Set<ProductStatus> statuses
) {}
На выходе мы обязаны отдать страницу легковесных DTO (Java Record):
public record ProductSummary(
Long id,
String name,
BigDecimal price
) {}
Вариант 1: Spring Data Fluent Query API (Современный подход)
Начиная со Spring Data JPA 2.6, появился элегантный способ комбинировать Specification и проекции без написания кастомных реализаций репозиториев — Fluent Query API.
Сначала мы создаем фабрику спецификаций (как делали в главе 27):
public class ProductSpecifications {
public static Specification<Product> byFilter(ProductFilter filter) {
return (root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>();
if (filter.categoryId() != null) {
predicates.add(cb.equal(root.get("category").get("id"), filter.categoryId()));
}
if (filter.minPrice() != null) {
predicates.add(cb.greaterThanOrEqualTo(root.get("price"), filter.minPrice()));
}
// ... добавление остальных условий
return cb.and(predicates.toArray(new Predicate[0]));
};
}
}
Далее в репозитории мы наследуем интерфейс JpaSpecificationExecutor<Product>. Этот интерфейс предоставляет метод findBy, который принимает спецификацию и функцию-трансформатор запроса FluentQuery.FetchableFluentQuery.
public interface ProductRepository extends JpaRepository<Product, Long>, JpaSpecificationExecutor<Product> {
// Дополнительные методы не нужны, используем встроенный findBy
}
Использование в сервисе выглядит так:
@Service
@Transactional(readOnly = true)
public class ProductCatalogService {
private final ProductRepository repository;
public Page<ProductSummary> search(ProductFilter filter, Pageable pageable) {
Specification<Product> spec = ProductSpecifications.byFilter(filter);
return repository.findBy(spec, q -> q
.as(ProductSummary.class) // Указываем проекцию (Record)
.page(pageable) // Применяем пагинацию
);
}
}
Нюансы Fluent Query API
- Маппинг в Record: Spring Data JPA попытается сопоставить выбранные колонки с конструктором
ProductSummary. Имена полей в сущности и DTO должны совпадать.
- Ограничение JOIN: Если ваша проекция требует полей из связанных сущностей (например,
categoryName), Fluent API может не справиться с автоматическим маппингом в Record без дополнительных алиасов. В таких случаях лучше использовать интерфейсные проекции (Closed Projections).
Вариант 2: Querydsl и Projections.constructor (Enterprise стандарт)
Если фильтры сложные, а проекции ветвистые, Specification становится громоздким. Querydsl справляется с этим лучше благодаря абсолютной типобезопасности и мощному механизму проекций.
В Querydsl есть класс Projections, который позволяет напрямую инструктировать ORM генерировать SQL вида SELECT col1, col2 и сразу передавать их в конструктор DTO.
@Repository
public class ProductQuerydslRepository {
private final JPAQueryFactory queryFactory;
public Page<ProductSummary> search(ProductFilter filter, Pageable pageable) {
QProduct product = QProduct.product;
BooleanBuilder where = new BooleanBuilder();
if (filter.categoryId() != null) {
where.and(product.category.id.eq(filter.categoryId()));
}
if (filter.minPrice() != null) {
where.and(product.price.goe(filter.minPrice()));
}
// ...
// 1. Запрос самих данных (только нужные колонки)
List<ProductSummary> content = queryFactory
.select(Projections.constructor(ProductSummary.class,
product.id,
product.name,
product.price
))
.from(product)
.where(where)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
// 2. Запрос общего количества (Count) для пагинации
JPAQuery<Long> countQuery = queryFactory
.select(product.count())
.from(product)
.where(where);
return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
}
}
Нюансы Querydsl
Здесь мы используем утилиту PageableExecutionUtils.getPage из Spring Data. Она оптимизирует выполнение: если мы запросили первую страницу размером 20 элементов, а БД вернула всего 15, Spring Data не будет выполнять countQuery, так как и так понятно, что это последняя страница и всего элементов 15. Это экономит один SQL-запрос.
Ловушка пагинации: JOIN FETCH и Count-запрос
При ручной или полуавтоматической реализации пагинации разработчики часто совершают фатальную ошибку. Допустим, в нашем фильтре появилось условие по имени категории, и мы решили использовать JOIN FETCH, чтобы заодно подтянуть категорию и избежать проблемы N+1.
Если мы передадим такую спецификацию (или Querydsl-запрос с fetchJoin()) в метод, возвращающий Page<T>, мы получим ошибку.
Почему? Пагинация всегда состоит из двух SQL-запросов:
- Запрос данных:
SELECT ... FROM product p JOIN category c ON ... LIMIT 20
- Запрос количества:
SELECT COUNT(p.id) FROM product p JOIN category c ON ...
Если в спецификации указан JOIN FETCH, Hibernate попытается сгенерировать Count-запрос вида:
SELECT COUNT(p) FROM Product p JOIN FETCH p.category
Этот JPQL-запрос синтаксически некорректен. Оператор FETCH указывает ORM материализовать связанную сущность в памяти, но агрегатная функция COUNT возвращает число (Long), а не графы объектов. Hibernate выбросит QueryException: query specified join fetching, but the owner of the fetched association was not present in the select list.
Как правильно делать JOIN при пагинации?
Если вы используете проекции (как в нашей задаче), JOIN FETCH вам в принципе не нужен! Вы не возвращаете Entity, вы возвращаете плоский DTO. Вам нужен обычный SQL JOIN для фильтрации, а не для загрузки графа.
В Criteria API / Specifications это делается так:
// НЕПРАВИЛЬНО (упадет при пагинации)
root.fetch("category", JoinType.INNER);
// ПРАВИЛЬНО (создаст SQL JOIN для фильтрации, безопасно для COUNT)
Join<Product, Category> categoryJoin = root.join("category", JoinType.INNER);
predicates.add(cb.equal(categoryJoin.get("name"), "Electronics"));
Если же вы возвращаете Page<Product> (сущности) и вам критически нужен JOIN FETCH для обхода N+1, Spring Data JPA умеет автоматически вырезать FETCH при генерации Count-запроса, но только если вы используете аннотацию @Query или @EntityGraph. В случае со Specification безопаснее разделять логику: сначала получить страницу ID-шников без FETCH, а вторым запросом вытащить сущности по этим ID уже с JOIN FETCH (паттерн, который мы разбирали в главе 17).
Вариант 3: Чистый Criteria API (Полный контроль)
Иногда Fluent Query API не хватает гибкости, а тащить зависимость Querydsl в проект не хочется. В этом случае мы пишем кастомный репозиторий (глава 26) и используем метод CriteriaBuilder.construct().
public class ProductRepositoryCustomImpl implements ProductRepositoryCustom {
@PersistenceContext
private EntityManager em;
@Override
public Page<ProductSummary> searchCustom(ProductFilter filter, Pageable pageable) {
CriteriaBuilder cb = em.getCriteriaBuilder();
// 1. Создаем запрос для DTO
CriteriaQuery<ProductSummary> query = cb.createQuery(ProductSummary.class);
Root<Product> root = query.from(Product.class);
// Формируем предикаты
List<Predicate> predicates = buildPredicates(filter, cb, root);
query.where(predicates.toArray(new Predicate[0]));
// Указываем проекцию через construct
query.select(cb.construct(ProductSummary.class,
root.get("id"),
root.get("name"),
root.get("price")
));
// Выполняем запрос данных
List<ProductSummary> content = em.createQuery(query)
.setFirstResult((int) pageable.getOffset())
.setMaxResults(pageable.getPageSize())
.getResultList();
// 2. Создаем отдельный запрос для COUNT
CriteriaQuery<Long> countQuery = cb.createQuery(Long.class);
Root<Product> countRoot = countQuery.from(Product.class);
countQuery.select(cb.count(countRoot));
countQuery.where(buildPredicates(filter, cb, countRoot).toArray(new Predicate[0]));
Long total = em.createQuery(countQuery).getSingleResult();
return new PageImpl<>(content, pageable, total);
}
private List<Predicate> buildPredicates(ProductFilter filter, CriteriaBuilder cb, Root<Product> root) {
// ... логика сборки условий
}
}
Этот код наглядно демонстрирует, что именно делает Spring Data JPA под капотом, когда вы вызываете repository.findBy(spec, q -> q.as(...).page(...)). Вы явно видите два независимых дерева Criteria API: одно для выборки колонок в DTO, второе для подсчета строк.
Резюме: Чек-лист идеального фильтра поиска
При реализации сложных каталогов и таблиц с фильтрами придерживайтесь следующих правил:
- Никогда не возвращайте
@Entity в списках. Используйте классовые проекции (Records) для извлечения только нужных колонок. Это снижает потребление памяти и убирает риск случайного триггера ленивых связей при сериализации в JSON.
- Используйте Fluent Query API (
q.as(DTO.class)), если ваши фильтры можно описать через Specification, а DTO плоский. Это самый быстрый путь.
- Переходите на Querydsl, если фильтры содержат сложную логику (вложенные
OR/AND, подзапросы) или проекции требуют трансформации данных на лету.
- Остерегайтесь
JOIN FETCH в спецификациях. Если метод возвращает Page<T>, используйте обычный root.join() для фильтрации по связанным таблицам.