JavaRush /Курси /Spring Data JPA /Збирання ProductAdminFilte...

Збирання ProductAdminFilter

Spring Data JPA
Рівень 15 , Лекція 3
Відкрита

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

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

Уявімо типовий адмінський екран у нашому проєкті shop-data-jpa: «Знайти товари за текстом, статусом, категорією, діапазоном ціни, показати лише ті, що є на складі, і ще щоб усе це було посторінково». Наївний підхід — зробити метод репозиторію під кожну комбінацію умов. Ми вже бачили, чим це закінчується: репозиторій перетворюється на енциклопедію заклинань.

Наша мета тут цілком практична: зібрати один зрозумілий конвеєр «фільтр → специфікація → запит → сторінка результатів».

Ось схема, яку ми хочемо отримати — і тримати в голові, коли код стане трохи довшим:

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: контракт пошуку

Сам об’єкт фільтра у нас уже є: text, status, categoryId, minPrice, maxPrice, inStockOnly, createdFrom. Він і далі зберігає лише умови пошуку, а Pageable залишається окремим параметром, тому що відповідає вже не за WHERE, а за форму результату.

Зараз важливо не знову обговорювати поля фільтра, а перетворити їх на один ланцюжок spec = spec.and(...), де кожне необов’язкове поле або додає свою умову, або взагалі не бере участі у запиті.

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, тому що це саме читальна частина запитів.

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 має бути <= maxPrice");
    }
}

Зверніть увагу: для BigDecimal порівняння — через compareTo, а не через > (і це добрий момент, щоб нагадати собі: гроші — не double, як би вам не хотілося, щоб було простіше).

5. Прапорець inStockOnly і залишки

Булевий прапорець у фільтрі — штука підступно проста: здається, що це «один рядок». На практиці саме булеві прапорці найчастіше ламаються через null, через неправильне трактування false або через те, що умова зав’язана на пов’язаній сутності. У нас якраз такий випадок: inStockOnly означає «показуй товари, у яких stockItem.availableQuantity > 0».

Спочатку — акуратна перевірка прапорця в збиранні. Найбезпечніший стиль для необов’язкового 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: предикат availableQuantity > 0 у WHERE все одно їх відріже. Цей варіант корисний не тим, що магічно змінює результат, а тим, що 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
    var spec = ProductSpecifications.byFilter(filter);

    // 3) Виконуємо запит посторінково
    return productRepository.findAll(spec, pageable);
}

Це і є робочий конвеєр: 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.
Необов’язкові прапорці майже завжди потребують 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, бо «ніби працює, але якісь товари зникли». Вони не зникли — ви їх просто перегорнули.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ