JavaRush /Курсы /Spring Data JPA /Сборка ProductAdminFilter<...

Сборка ProductAdminFilter

Spring Data JPA
15 уровень , 3 лекция
Открыта

1. Сценарий: админский поиск товаров

Отдельные hasStatus(...), priceGte(...) и textSearch(...) полезны только до тех пор, пока из них не собирается реальный поиск. У админки запрос один: быстро найти товары по любому сочетанию optional-полей, не плодя 30 методов в ProductRepository.

Представим типичный админский экран в нашем проекте shop-data-jpa: “Найти товары по тексту, статусу, категории, диапазону цены, показать только те, что есть на складе, и ещё чтобы всё это было постранично”. Наивный подход — сделать метод репозитория под каждую комбинацию условий. Мы уже видели, чем это заканчивается: репозиторий превращается в энциклопедию заклинаний.

Наша цель здесь очень практичная: сделать один понятный pipeline “фильтр → спецификация → запрос → страница результатов”.

Вот схема, которую мы хотим получить (и держать в голове, когда код станет чуть длиннее):

flowchart TD
    A["ProductAdminFilter
(данные условий)"] --> B["ProductSpecifications.byFilter(filter)
сборка Specification"] B --> C["Specification<Product>
итоговый WHERE"] C --> D["productRepository.findAll(spec, pageable)"] D --> E["Page<Product>
контент + метаданные страницы"]

И маленькая «карта соответствий» (чтобы потом не потеряться, кто за что отвечает):

Поле фильтра Что означает по‑человечески Какая спецификация (идея)
text искать по sku или name textSearch(text)
status только ACTIVE / ARCHIVED hasStatus(status)
categoryId товары из категории inCategory(categoryId)
minPrice цена от… priceGte(minPrice)
maxPrice цена до… priceLte(maxPrice)
inStockOnly только то, что есть в наличии inStock()
createdFrom создано не раньше даты createdAfter(createdFrom)

2. ProductAdminFilter: контракт поиска

Сам filter object у нас уже есть: text, status, categoryId, minPrice, maxPrice, inStockOnly, createdFrom. Он по-прежнему хранит только условия поиска, а Pageable остаётся отдельным параметром, потому что отвечает уже не за WHERE, а за форму результата.

Сейчас важно не заново обсуждать поля фильтра, а превратить их в одну цепочку spec = spec.and(...), где каждое optional-поле либо добавляет своё условие, либо вообще не участвует в запросе.

3. byFilter: композиция через and

Сейчас мы сделаем то, из‑за чего обычно ломается психика новичка: соберём фильтр так, чтобы он не выглядел как суп из if-ов. Хорошая новость: суп всё равно будет из if-ов, просто это будет суп, в который вы заранее положили нормальный рецепт. Ключевой принцип: нет значения — нет условия. Мы не пытаемся “эмулировать SQL”, мы просто постепенно добавляем спецификации туда, где фильтр реально активирован.

Чтобы репозиторий вообще умел исполнять спецификации, он должен расширять JpaSpecificationExecutor<Product>. Это не отменяет JpaRepository, а добавляет возможность делать findAll(spec, pageable).

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;

import com.example.shopdatajpa.catalog.entity.Product;

// Репозиторий остаётся обычным JpaRepository,
// но дополнительно получает поддержку Specification-запросов.
public interface ProductRepository
        extends JpaRepository<Product, Long>, JpaSpecificationExecutor<Product> {
}

Теперь создадим класс‑сборщик спецификаций. По архитектуре проекта это отлично ложится в catalog.query, потому что это именно read-side querying.

import org.springframework.data.jpa.domain.Specification;

import com.example.shopdatajpa.catalog.entity.Product;

// Утилитный класс: собирает спецификации для поиска.
// Экземпляры не нужны — только static-методы.
public final class ProductSpecifications {

    private ProductSpecifications() {
        // Защита от случайного создания экземпляра
    }

    public static Specification<Product> byFilter(ProductAdminFilter filter) {
        // Стартуем с "ничего не фильтруем"
        Specification<Product> spec = Specification.unrestricted();

        // Дальше сюда последовательно добавляются условия:
        // spec = spec.and(...)

        return spec;
    }
}

Смысл Specification.unrestricted() простой: это нейтральное «ничего не фильтруем». Это как начать собирать бутерброд с чистого хлеба, а не с загадочного объекта “может хлеб, может не хлеб”.

Дальше начинается главная часть: добавляем условия только когда они активны. И здесь важно помнить одну деталь, которая часто ускользает: spec.and(...) не меняет объект spec “внутри”. Он возвращает новый Specification. Поэтому мы всегда делаем присваивание spec = spec.and(...).

Вот типичный кусок сборки для текста, статуса и категории:

// 1) Текст: учитываем только если непустой
if (filter.text() != null && !filter.text().isBlank()) {
    // Важно: and(...) возвращает новый Specification
    spec = spec.and(textSearch(filter.text()));
}

// 2) Статус: если задан — фильтруем
if (filter.status() != null) {
    spec = spec.and(hasStatus(filter.status()));
}

// 3) Категория: если задана — фильтруем
if (filter.categoryId() != null) {
    spec = spec.and(inCategory(filter.categoryId()));
}

Почему это читаемо? Потому что здесь нет ветвлений “если одно, иначе другое”. Все условия независимы, и это отражено в коде. Мы не пытаемся вычислить “какой именно запрос нужен” заранее — мы просто добавляем фрагменты WHERE, когда это нужно.

А теперь важное уточнение про стиль. Очень легко скатиться в гигантский byFilter, где всё написано прямо в одном методе. Мы этого избегаем так: byFilter занимается только “оркестрацией” условий, а каждое условие вынесено в маленькую спецификацию вроде hasStatus, priceGte, textSearch. В прошлой лекции мы как раз учились писать такие маленькие штуки — сегодня они должны окупиться.

4. Диапазоны: цена и дата

Диапазоны — это классическое место, где начинающий разработчик делает «условие‑монстра». Например, пытается запихнуть minPrice, maxPrice, проверки на null и ещё три бизнес‑мысли в один метод. На практике гораздо проще и устойчивее мыслить диапазон как два независимых полуусловия: “не меньше” и “не больше”. Если задана только одна граница — всё равно работает. Если заданы обе — получится “между”.

Начнём с нижней и верхней границы цены. Важно: мы фильтруем по BigDecimal, потому что деньги (и похожие значения) мы в JPA-мире держим именно так.

import java.math.BigDecimal;

import org.springframework.data.jpa.domain.Specification;

import com.example.shopdatajpa.catalog.entity.Product;

public static Specification<Product> priceLte(BigDecimal maxPrice) {
    return (root, query, cb) ->
            // price <= maxPrice
            cb.lessThanOrEqualTo(root.get("price"), maxPrice);
}

priceGte(minPrice) будет симметричной (мы писали такую спецификацию в прошлой лекции). Теперь createdFrom. С датами всё то же самое: “не раньше заданного момента”.

import java.time.LocalDateTime;

import org.springframework.data.jpa.domain.Specification;

import com.example.shopdatajpa.catalog.entity.Product;

public static Specification<Product> createdAfter(LocalDateTime from) {
    return (root, query, cb) ->
            // createdAt >= from
            cb.greaterThanOrEqualTo(root.get("createdAt"), from);
}

И добавляем эти условия в сборку по тем же правилам “нет значения — нет условия”:

// Нижняя граница цены
if (filter.minPrice() != null) {
    spec = spec.and(priceGte(filter.minPrice()));
}

// Верхняя граница цены
if (filter.maxPrice() != null) {
    spec = spec.and(priceLte(filter.maxPrice()));
}

// Дата "создано не раньше"
if (filter.createdFrom() != null) {
    spec = spec.and(createdAfter(filter.createdFrom()));
}

Теперь важный нюанс, который мы проговорим специально, потому что на этом месте часто появляются «странные результаты» и желание обвинить Hibernate. Если пользователь (или ваш вызывающий код) передал диапазон “вверх ногами”, например minPrice = 1000, maxPrice = 10, то спецификации честно соберутся, SQL честно выполнится — и вы честно получите пустую страницу. Это не баг JPA. Это отсутствие валидации входа.

Сама сборка спецификаций не обязана заниматься валидацией (иначе она станет и фильтром, и валидатором, и гадалкой на кофейной гуще). Простейшую проверку лучше делать в сервисе (даже до того, как вы построили спецификацию):

import java.math.BigDecimal;

public static void validate(ProductAdminFilter filter) {
    // Валидация границ диапазона: min <= max
    BigDecimal min = filter.minPrice();
    BigDecimal max = filter.maxPrice();

    if (min != null && max != null && min.compareTo(max) > 0) {
        // Это именно ошибка входных данных, а не проблема JPA/Hibernate
        throw new IllegalArgumentException("minPrice must be <= maxPrice");
    }
}

Обратите внимание: для BigDecimal сравнение — через compareTo, а не через > (и это хороший момент, чтобы напомнить себе: деньги — не double, как бы вам ни хотелось «чтобы было проще»).

5. Флаг inStockOnly и остатки

Булевый флаг в фильтре — штука deceptively simple: кажется, что это “одна строчка”. На практике именно булевые флаги чаще всего ломаются из‑за null, из‑за неправильной трактовки false, или из‑за того, что условие завязано на связанную сущность. У нас как раз такой случай: inStockOnly означает “показывай товары, у которых stockItem.availableQuantity > 0”.

Сначала — аккуратная проверка флага в сборке. Самый безопасный стиль для optional-Boolean:

// Фильтр включается только при явном true
if (Boolean.TRUE.equals(filter.inStockOnly())) {
    spec = spec.and(inStock());
}

Почему так, а не if (filter.inStockOnly())? Потому что filter.inStockOnly() может быть null, и тогда вы получите NullPointerException. А если у вас NPE в фильтре, админский поиск превращается в аттракцион.

Теперь сама спецификация. В простом варианте (и для OneToOne это обычно нормально) можно пройтись по пути root.get("stockItem").get("availableQuantity"):

import org.springframework.data.jpa.domain.Specification;

import com.example.shopdatajpa.catalog.entity.Product;

public static Specification<Product> inStock() {
    return (root, query, cb) ->
            // availableQuantity > 0
            cb.greaterThan(
                    root.get("stockItem").get("availableQuantity"),
                    0
            );
}

На этом месте полезно проговорить одну тонкость. Когда вы навигируете по связи, JPA может построить join. И если связанная сущность отсутствует, фильтр окажется строже, чем кажется на первый взгляд: товар без StockItem просто не пройдёт условие availableQuantity > 0.

Если вам важно сделать join явным и потом точнее управлять его семантикой, можно написать вариант с LEFT JOIN. Но сам по себе LEFT JOIN здесь ещё не спасает товары без stockItem: predicate availableQuantity > 0 в WHERE всё равно их отрежет. Этот вариант полезен не тем, что magically меняет результат, а тем, что join становится явным и его проще дальше расширять.

Вот пример варианта с LEFT JOIN (чуть многословнее, но иногда понятнее, потому что join становится явным):

import jakarta.persistence.criteria.Join;
import jakarta.persistence.criteria.JoinType;

import org.springframework.data.jpa.domain.Specification;

import com.example.shopdatajpa.catalog.entity.Product;
import com.example.shopdatajpa.inventory.entity.StockItem;

public static Specification<Product> inStockLeftJoin() {
    return (root, query, cb) -> {
        // Явно делаем LEFT JOIN, чтобы контролировать поведение join-а
        Join<Product, StockItem> stock = root.join("stockItem", JoinType.LEFT);

        // Даже с LEFT JOIN это условие всё равно оставит только товары,
        // у которых availableQuantity > 0.
        return cb.greaterThan(stock.get("availableQuantity"), 0);
    };
}

Здесь важен не синтаксис (его вы всё равно не запомните с первого раза), а ментальная модель: фильтр может смотреть не только на поля Product, но и на поля связанных сущностей. Это одна из причин, почему Specification для админского поиска очень удобна: вы не упираетесь в “я могу фильтровать только по колонкам этой таблицы”.

6. Пагинация и findAll(spec, pageable)

Теперь мы стыкуем две «оси» поиска: условия (Specification) и форму получения списка (Pageable). Легко перепутать роли и начать засовывать Pageable внутрь фильтра или наоборот. Но здесь всё довольно строго: спецификация отвечает на вопрос “какие строки подходят”, а Pageable отвечает на вопрос “какой кусок результата и в каком порядке мы хотим сейчас”.

Именно здесь полезно зафиксировать канон дня: снаружи query-service принимает ProductAdminFilter и Pageable. Specification остаётся внутренним механизмом сборки WHERE, а не новым публичным языком use case.

Внутри реализации query-service это обычно выглядит так:

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

public Page<Product> search(ProductAdminFilter filter, Pageable pageable) {
    // 1) Проверяем входные данные use case
    validate(filter);

    // 2) Собираем WHERE-часть запроса из filter object
    var spec = ProductSpecifications.byFilter(filter);

    // 3) Выполняем запрос постранично
    return productRepository.findAll(spec, pageable);
}

Это и есть рабочий pipeline: validate(filter)byFilter(filter)productRepository.findAll(spec, pageable). Даже когда потом меняется контейнер результата или проекция, внешний вход use case можно не трогать.

Теперь несколько важных деталей про Pageable (это вещи, на которых ломается даже опытный разработчик, если он не выспался):

Параметр page в PageRequest.of(page, size, sort) нулевой-базированный. То есть первая страница — это 0. Если вы пишете “давай первую страницу” и ставите 1, вы удивитесь, почему “пропали” первые size товаров. Это не магия, это обычная математика. И да, это та самая математика, которую мы обещали, что “в программировании почти не понадобится”.

Вот пример создания Pageable для админского поиска:

import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;

// Первая страница — это 0 (а не 1)
Pageable pageable = PageRequest.of(
        0,
        20,
        // Сортировка по имени поля сущности, а не по имени колонки БД
        Sort.by("createdAt").descending()
);

Обратите внимание: в Sort.by("createdAt") мы используем имя поля сущности, а не имя колонки в БД. Если вы назвали поле createdAt, а колонку сделали created_at, то для сортировки вы всё равно пишете createdAt.

И последнее — маленький “живой” пример, чтобы почувствовать, что фильтр и пагинация действительно независимы. Мы можем задать фильтр и просто менять Pageable, не трогая спецификацию.

import java.math.BigDecimal;

import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;

// Фильтр отвечает на вопрос "что искать"
ProductAdminFilter filter = new ProductAdminFilter(
        "iphone",
        ProductStatus.ACTIVE,
        null,
        new BigDecimal("100.00"),
        new BigDecimal("2000.00"),
        true,
        null
);

// Pageable отвечает на вопрос "как показывать": страница/размер/сортировка
var pageable = PageRequest.of(0, 10, Sort.by("price").ascending());

Фильтр описывает “что искать”, PageRequest описывает “как показывать”. И когда вы держите эти штуки отдельно, ваш код начинает напоминать инженерное решение, а не квест “найди, где у нас спрятаны параметры поиска”.

7. Типичные ошибки при сборке фильтра

Ошибка №1: фильтр начинает “сам себя валидировать” и превращается в бизнес-логику.
Очень хочется в byFilter сразу написать “если minPrice > maxPrice — поменять местами” или “если text слишком короткий — игнорировать”. Это быстро превращает сборку спецификации в место, где живёт половина правил продукта. Лучше держать сборку фильтра максимально механической, а валидацию входа — отдельно (в сервисе).

Ошибка №2: Boolean проверяется как if (filter.inStockOnly()), и всё падает с NPE.
Optional-флаги почти всегда требуют Boolean.TRUE.equals(...). Иначе при null вы получите исключение. Самое обидное в этой ошибке то, что она всплывает не на этапе компиляции, а у пользователя, который просто “не включил галочку”.

Ошибка №3: spec.and(...) вызывается без присваивания, и фильтр “не работает”.
Specification ведёт себя как immutable-объект: методы and/or возвращают новый объект. Если написать spec.and(hasStatus(...)); и забыть spec =, то вы вежливо выбросите спецификацию в мусорку, а потом будете долго смотреть в SQL-лог с мыслью “ну почему оно не фильтрует?!”

Ошибка №4: диапазон цены пишется как одно гигантское условие, и потом его невозможно расширять.
Когда minPrice и maxPrice оформлены одним “монстром”, вам тяжело поддерживать случаи “только min”, “только max”, “оба”, “ни один”. Гораздо легче держать две маленькие спецификации priceGte и priceLte и просто добавлять их по необходимости.

Ошибка №5: путаница с нумерацией страниц в PageRequest.
PageRequest.of(0, 20) — это первая страница. Если вы передадите 1, то будете начинать со второй. Эта ошибка особенно коварна, когда вы подключаете UI, потому что “вроде работает, но какие-то товары пропали”. Они не пропали — вы их пролистали.

1
Задача
Spring Data JPA, 15 уровень, 3 лекция
Недоступна
Полный ProductAdminFilter с диапазоном цены
Полный ProductAdminFilter с диапазоном цены
1
Задача
Spring Data JPA, 15 уровень, 3 лекция
Недоступна
DeviceAdminFilter с optional-boolean и датой
DeviceAdminFilter с optional-boolean и датой
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ