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, бо «ніби працює, але якісь товари зникли». Вони не зникли — ви їх просто перегорнули.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ