1. Що насправді робить Specification
Якщо раніше Specification сприймалася як «ще одна фішка Spring, яка якось там будує запити», то тепер ми знімемо магічний плащ і залишимо тільки механіку. Усередині Specification<T> є одне ключове завдання: перетворити вашу ідею фільтра на шматок умови для SQL (точніше, для JPA Criteria Query), щоб ORM могла зібрати where і відправити запит до бази.
Уявити це можна дуже просто: специфікація — це функція, яка отримує «точку входу» в сутність (Root), «конструктор умов» (CriteriaBuilder) і об’єкт запиту (CriteriaQuery), а на виході віддає Predicate — логічну умову, яка потрапить у where. Spring Data JPA прямо будує специфікації поверх JPA Criteria API, тому ці типи — не випадковість, а фундамент інтерфейсу.
Щоб не лякатися сигнатури, можна сприймати її так: «Дайте мені доступ до полів Product, дайте мені інструменти для порівняння значень, і я поверну одну логічну умову».
Невеликий «скелет» того, що ми постійно писатимемо (не копіюйте як є — це лише для розуміння форми):
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
public interface MySpecLikeThing<T> {
/**
* @param root "точка входу" в сутність і її поля/зв’язки
* @param query об'єкт запиту (іноді потрібен, наприклад, для distinct тощо)
* @param cb "конструктор" умов (equal/like/and/or/...)
* @return одна логічна умова для майбутнього WHERE
*/
Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder cb);
}
Зверніть увагу на важливу думку: Specification<Product> — це не запит цілком, і не «сервіс пошуку», і не «read use case». Це рівно один шматок фільтра, який потім можна поєднувати з іншими шматками.
2. Root<T>: доступ до полів і зв’язків сутності
Коли вперше бачиш root.get("status"), хочеться запитати: «А чому рядок? Ми ж у Java, де все типізовано». Питання слушне, і саме тому Root<T> треба зрозуміти, а не запам’ятовувати як заклинання. Root<Product> — це об’єкт, який представляє поточну сутність запиту і дає змогу будувати шляхи до її полів і зв’язків.
Усередині toPredicate(...) ви не пишете product.getStatus(). Ви будуєте опис шляху: спочатку поле status, потім, якщо треба, поле пов’язаної сутності (category.id), потім, якщо потрібно, ще глибше. Це схоже на те, як у SQL ви пишете p.status або p.category_id, тільки в об’єктному вигляді.
Найчастіший патерн — root.get("fieldName"). Для простих полів Product він читається майже як англійська: «візьми поле status». Для зв’язків працює ланцюжок: root.get("category").get("id") — «візьми поле category, а в ньому — id». Так, це звучить як команда роботу-пилососу, але цього разу робот корисний.
Проблема рядків тут теж практична: якщо ви перейменували поле в entity, помилка вилізе під час виконання, а не на етапі компіляції. Тому ми й робимо маленькі специфікації в одному місці, з хорошими назвами: так менше шансів, що рядкові шляхи розповзуться по проєкту.
3. CriteriaBuilder: інструменти для SQL‑умов
CriteriaBuilder — це ваш «конструктор» умов. Якщо Root відповідає на питання «до чого ми звертаємося», то CriteriaBuilder відповідає на питання «як саме порівнюємо». І тут зручно думати так: ви збираєте умову майже як SQL, але у вигляді викликів методів.
Важливо: CriteriaBuilder не виконує запит. Він будує опис умови. Це все ще «креслення», а не «будинок». Будинком стане SQL, який Hibernate врешті-решт відправить у PostgreSQL.
Ось невелика табличка-підказка, щоб пов’язати методи CriteriaBuilder зі звичним SQL. Тримайте її поруч, доки око й рука не звикнуть:
| CriteriaBuilder-метод | «Людський» сенс | Приблизний SQL-сенс |
|---|---|---|
| cb.equal(a, b) | дорівнює | |
| cb.like(a, pattern) | рядок схожий на шаблон | |
| cb.greaterThanOrEqualTo(a, x) | більше або дорівнює | |
| cb.lessThanOrEqualTo(a, x) | менше або дорівнює | |
| cb.or(p1, p2) | логічне АБО | |
| cb.and(p1, p2) | логічне І | |
| cb.lower(expr) | привести рядок до нижнього регістру | |
Цієї таблиці сьогодні достатньо. Якщо ви почнете шукати «а де тут group by і having», то це вже інший фільм, інша франшиза і, можливо, інший курс.
Predicate: зрозумілий і маленький «шматок where»
Слово Predicate звучить так, ніби ви раптово опинилися на лекції з математичної логіки. На практиці це всього лише об’єкт, який означає: «ось умова, яка має бути істинною, щоб рядок потрапив у результат». З погляду SQL це частина where.
Ключовий момент для архітектури: одна специфікація має повертати одне зрозуміле правило. Не десять правил усередині одного лямбда-блоку і не «універсальний запит на всі випадки життя». Коли ви повертаєте один Predicate, ви отримуєте будівельний блок, який можна безпечно поєднувати з іншими.
І тут з’являється дуже важливий стиль: ми найчастіше збираємо загальний фільтр через AND (тобто додаємо умови одну за одною), а OR намагаємося тримати локально всередині одного правила. Наприклад, «текстовий пошук за name АБО за sku» — це один осмислений блок. А ось «то OR це OR третє OR четверте» на рівні всього фільтра швидко перетворюється на кашу.
4. ProductSpecifications: каркас класу
Перш ніж писати самі специфікації, давайте узгодимо їхнє місце в проєкті. У нас shop-data-jpa організований за принципом package-by-feature, і для каталогу є окремий пакет. Тому специфікації для Product логічно тримати поруч з іншим read-кодом каталогу, наприклад у com.example.shopdatajpa.catalog.query.
Ідея проста: один клас-утиліта з маленькими статичними методами. Це не «сервіс», не «репозиторій» і не «магія» — просто набір фабрик, які повертають Specification<Product>.
Ось мінімальний каркас класу:
import com.example.shopdatajpa.catalog.entity.Product;
import org.springframework.data.jpa.domain.Specification;
public final class ProductSpecifications {
private ProductSpecifications() {
// Утилітний клас: екземпляри не потрібні
}
}
Далі в цей клас ми додаватимемо маленькі методи, кожен з яких робить рівно одну умову. І саме такі методи потім приємно читати під час складання фільтра: вони звучать як нормальна українська, а не як «ось тут десь шматок Criteria API».
5. Мікро-спеки: статус і межі ціни
Почнімо з найпростішого й найзрозумілішого: фільтра за статусом і за ціною. Це як Hello, world для специфікацій: ніякої навігації по зв’язках, ніяких join, просто поле і порівняння.
Специфікація hasStatus виглядає майже непристойно короткою — і це добре. Короткий код легше перевірити очима, легше тестувати і складніше зіпсувати:
import com.example.shopdatajpa.catalog.entity.Product;
import com.example.shopdatajpa.catalog.entity.ProductStatus;
import org.springframework.data.jpa.domain.Specification;
public static Specification<Product> hasStatus(ProductStatus status) {
return (root, query, cb) ->
// status — це звичайне поле сутності Product
cb.equal(root.get("status"), status);
}
Тепер межі ціни. Ми не робимо один метод на «між», тому що наші фільтри необов’язкові: може бути лише minPrice, може бути лише maxPrice. Тому дві мікроспеки читаються краще і складаються простіше:
import java.math.BigDecimal;
public static Specification<Product> priceGte(BigDecimal minPrice) {
return (root, query, cb) ->
// price >= minPrice
cb.greaterThanOrEqualTo(root.get("price"), minPrice);
}
І симетрична «верхня межа»:
import java.math.BigDecimal;
public static Specification<Product> priceLte(BigDecimal maxPrice) {
return (root, query, cb) ->
// price <= maxPrice
cb.lessThanOrEqualTo(root.get("price"), maxPrice);
}
Зверніть увагу на важливий методичний момент: ці методи передбачають, що вхідні параметри вже валідні. Якщо minPrice чомусь відʼємна або minPrice > maxPrice, це не завдання Specification. Специфікація — про where, а не про «розрулити весь безлад вхідних даних».
6. Фільтри за зв’язками: категорія і наявність
Тепер додамо трохи життя: фільтр за пов’язаною сутністю і фільтр за пов’язаним StockItem. Саме на цьому місці зазвичай і починається паніка: «А як у Criteria API пройти по зв’язку?». Спокійно: технічно це все той самий шлях, просто не до поля напряму, а до нього через зв’язок.
Категорія — це ManyToOne у Product. Отже, шлях до category.id виглядає так: root.get("category").get("id"). І умова залишається звичайним equal:
public static Specification<Product> inCategory(Long categoryId) {
return (root, query, cb) ->
// Навігація за зв’язком: product.category.id = :categoryId
cb.equal(root.get("category").get("id"), categoryId);
}
Тепер «лише товари, які є в наявності». У нашому проєкті це може означати, що у Product є stockItem, а у stockItem є availableQuantity. Ми хочемо availableQuantity > 0.
Так, порівняння «більше нуля» звучить дуже «базово», але в реальних адмінках саме такі фільтри й живуть роками:
public static Specification<Product> inStock() {
return (root, query, cb) ->
// Важливо: умова за зв’язком може "звузити" вибірку,
// якщо stockItem відсутній (можливий join, і такі записи не пройдуть фільтр).
cb.greaterThan(root.get("stockItem").get("availableQuantity"), 0);
}
Тут є тонкість, яку важливо розуміти, але зараз не потрібно перетворювати її на окрему дисертацію. Коли ви навігуєте за зв’язком, JPA може побудувати join. І якщо пов’язана сутність відсутня (наприклад, товар без StockItem), то поведінка фільтра може виявитися «суворішою», ніж ви очікували: такі товари не потраплять у вибірку. У межах нашого навчального домену це нормально, тому що товар без залишку — не найкорисніший товар. Але сам факт варто тримати в голові.
7. Текстовий пошук: like, нормалізація та локальний or
Текстовий пошук — це вічна класика. Користувач вводить "iph" і хоче побачити "iPhone", а не сувору математику «рядок має збігтися повністю». У SQL це зазвичай like '%iph%', а щоб не залежати від регістру, додають lower(...) або використовують PostgreSQL-специфічне ILIKE. У специфікаціях ми зробимо універсальний варіант через lower.
Дуже важливо: or ми тримаємо усередині правила «textSearch», а не в загальному складанні фільтра. Тоді правило «пошук за текстом» залишається одним осмисленим кубиком: «збіглося або за назвою, або за SKU».
public static Specification<Product> textSearch(String text) {
// Приводимо до нижнього регістру на стороні застосунку,
// а в БД порівнюємо через lower(...) — так пошук стає нечутливим до регістру.
String pattern = "%" + text.toLowerCase() + "%";
return (root, query, cb) -> cb.or(
// (lower(name) like :pattern) OR (lower(sku) like :pattern)
cb.like(cb.lower(root.get("name")), pattern),
cb.like(cb.lower(root.get("sku")), pattern)
);
}
Кілька практичних зауважень. Якщо text порожній або складається з пробілів, цей метод краще взагалі не застосовувати (тобто не додавати специфікацію до складання фільтра). А ще такий lower(...) може впливати на використання індексу в базі. Це вже питання оптимізації та схеми, а не сьогоднішньої лекції, але знати про це корисно, щоб не думати «ORM усе прискорить сама».
8. Композиція: складання читабельного фільтра
Зараз настає момент, заради якого весь підхід «маленькі специфікації» взагалі існує. Ми хочемо писати код, який читається як фраза: «візьми товари зі статусом ACTIVE, у категорії N, з ціною від X і ще щоб текст збігся». А не як величезний лямбда-комбайн, де логіка схована всередині трьох рівнів дужок.
Специфікації спеціально вміють поєднуватися через and(...) і or(...), а для нейтрального старту в Spring Data JPA 4 є статична специфікація Specification.unrestricted(): це «нічого не фільтруємо».
Ось маленький приклад «ручного складання» (не повноцінний ProductAdminFilter — його ми зберемо пізніше, зараз лише принцип):
import java.math.BigDecimal;
import org.springframework.data.jpa.domain.Specification;
// Стартуємо з «нічого не фільтруємо», щоб зручно додавати AND-умови ланцюжком
Specification<Product> spec = Specification.unrestricted()
.and(ProductSpecifications.hasStatus(ProductStatus.ACTIVE)) // status = ACTIVE
.and(ProductSpecifications.priceGte(new BigDecimal("10.00"))); // price >= 10.00
І як це може виглядати в сервісі каталогу, коли ви застосовуєте специфікацію до репозиторію:
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
public Page<Product> findActiveExpensiveProducts() {
// На рівні сервісу вже вирішуємо, які спеки застосовувати (а які — ні)
Specification<Product> spec = Specification.unrestricted()
.and(ProductSpecifications.hasStatus(ProductStatus.ACTIVE));
// Репозиторій отримує готовий "шматок WHERE", а не монолітну логіку на 50 рядків
return productRepository.findAll(spec, PageRequest.of(0, 20));
}
Якщо ввімкнено SQL-логи, ви побачите щось дуже схоже на такий SQL (форма може відрізнятися, але сенс буде той самий):
select p.*
from product p
where p.status = 'ACTIVE'
limit 20;
І саме тут ви нарешті отримуєте інженерне задоволення: ваші умови лежать у маленьких методах, їхні назви говорять, що ми робимо, а складання фільтра перетворюється на читабельний ланцюжок.
9. Типові помилки під час написання Specification і мікроумов
Помилка №1: писати одну “гігантську” специфікацію.
Коли вся логіка фільтрації зібрана в один метод на десятки рядків, ви втрачаєте читабельність і керованість. З часом навіть автору важко зрозуміти, що саме відбувається. Специфікації мають бути маленькими й композиційними, а не монолітом.
Помилка №2: перетворювати Specification на мінісервіс.
Якщо всередині з’являються перевірки null, нормалізація, валідація і побічні ефекти — це вже не “умова для where”. Специфікація має описувати лише предикат. Рішення «застосовувати чи ні» та підготовка даних мають відбуватися поза нею.
Помилка №3: розмазувати рядкові шляхи root.get("...") по проєкту.
Рядкові імена полів не перевіряються компілятором. Під час рефакторингу вони легко ламаються і дають помилки лише під час виконання. Якщо такі рядки централізовані (наприклад, у ProductSpecifications), їх принаймні можна швидко знайти й виправити.
Помилка №4: використовувати or занадто широко.
Коли or застосовується на рівні всієї специфікації, логіка фільтра стає розмитою: записи починають проходити за однією умовою, ігноруючи інші. Набагато безпечніше тримати or локально всередині конкретного правила, а загальний фільтр будувати через and.
Помилка №5: не враховувати вплив зв’язків на результат вибірки.
Фільтрація за пов’язаними сутностями (stockItem.availableQuantity) може неявно виключати записи без зв’язку. Це змінює семантику запиту. Якщо раптом “зникли дані”, насамперед варто перевірити joinʼи та умови за пов’язаними таблицями.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ