1. Типові фільтри каталогу
Каталог — це не просто «таблиця product і все». У нормальному застосунку його майже завжди читають із фільтрами: «покажи активні», «у категорії X», «у назві є “чай”», «ціна від 10 до 50», «додано за останній тиждень». Саме ці фільтри визначають, чи буде ваш data-layer зручним у щоденній роботі, чи ви щоразу писатимете новий костиль.
У нашому проєкті shop-data-jpa ми свідомо робимо акцент на derived queries: запити, які Spring Data JPA виводить із назви методу. Це швидкий вхід у read-side: не потрібно писати текст запиту, але все одно треба думати, який саме фільтр ви будуєте й як він виглядатиме на рівні SQL. Інакше вийде найнебезпечніша магія у світі — «магія, яка начебто працює, але чому — ніхто не знає».
Щоб не бути абстрактними, припустімо, що в нас є такі сутності: Product (із полями sku, name, status, price, createdAt, category) і Category (із полями code, name). Їхнє точне відображення ми вже зробили раніше — сьогодні нам важливо лише те, як ці поля беруть участь у фільтрах.
2. Поля й типи для запитів
Перед тим як писати derived queries, корисно на хвилину зупинитися й пригадати: ми пишемо методи за полями entity, а отже повинні чітко знати їхні назви та типи. Це як шукати книжку в бібліотеці: якщо ви впевнені, що автора звати «Лев Толстой», а в картці він «Л. Н. Толстой», ви ходитимете колами й сваритимете бібліотекаря (Spring Data), хоча помилилися ви.
Для прикладів нижче спиратимемося на кілька «опорних» типів. Статус товару — це enum, тому що рядок "ACTIVE" у 2026 році все ще може перетворитися на "ActIve" через людський фактор, а enum хоча б ламається чесно — на етапі компіляції. Ціна — BigDecimal, тому що гроші, як і коти, не люблять double. Дата створення — LocalDateTime, щоб можна було робити «після / до / між» без зайвої математики.
Ось мінімальний enum, щоб у прикладах не було відчуття «а звідки взявся ACTIVE»:
// Статус товару в каталозі — фіксований набір значень.
public enum ProductStatus {
DRAFT,
ACTIVE,
ARCHIVED
}
Тепер важливе застереження: якщо в БД колонка називається created_at, а в Java поле createdAt, то в derived query ви все одно пишете CreatedAt. Spring Data дивиться на Java-поле, а не на назву колонки. Це одна з найчастіших відповідей на запитання «чому воно не працює, я ж бачу колонку в БД».
3. Фільтр за статусом
Фільтр за статусом — це той самий «Hello, world» каталогу. Він простий, його легко читати, і він чудово показує головну ідею derived queries: після findBy іде назва поля, і Spring Data JPA будує умову рівності. У реальному житті статусом часто відсікають «чернетки», «архів», «приховані» товари і все, що не повинно потрапляти до користувацького каталогу.
Почнемо з дуже базового методу в ProductRepository. Зверніть увагу: тут немає жодної «магії SQL», ви просто задаєте намір, а SQL уже буде наслідком цього наміру.
import com.example.shopdatajpa.catalog.entity.Product;
import com.example.shopdatajpa.catalog.entity.ProductStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface ProductRepository extends JpaRepository<Product, Long> {
// Derived query: фільтрація за полем Product.status (рівність).
List<Product> findByStatus(ProductStatus status);
}
Якщо перевести це на «SQL-мислення», то всередині ви очікуєте щось на кшталт:
-- Очікувана ідея запиту: вибираємо товари із заданим статусом.
select * from product p
where p.status = ?
І важлива думка: параметр — це не шматок SQL, а значення. Жодних лапок, жодного status = 'ACTIVE' вручну. Ви передаєте ProductStatus.ACTIVE, а інфраструктура сама зіставляє це з потрібним значенням у запиті.
Щоб стало зовсім приземлено, давайте додамо в сервіс один метод, який читає активні товари. Тут ми поки не обговорюємо транзакції та інші радощі життя — просто показуємо «репозиторій як інструмент читання».
import com.example.shopdatajpa.catalog.entity.Product;
import com.example.shopdatajpa.catalog.entity.ProductStatus;
import com.example.shopdatajpa.catalog.repository.ProductRepository;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class CatalogService {
private final ProductRepository productRepository;
public CatalogService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
public List<Product> findActiveProducts() {
// Явно фіксуємо бізнес-умову: беремо лише ACTIVE.
return productRepository.findByStatus(ProductStatus.ACTIVE);
}
}
4. Фільтр за категорією: property path
Категорія — це перше місце, де derived queries починають виглядати розумніше, ніж просто рівність за полем. Чому? Тому що в Product категорію зазвичай зберігають не як Long categoryId, а як нормальне посилання Category category. Це означає, що ви можете будувати запити не за category_id, а за бізнес-полем категорії — наприклад, за code. І саме тут починається найцікавіше: property path.
Якщо в Product є поле category, а у Category є поле code, то derived query може пройти шлях product.category.code. У назві методу це виглядає як CategoryCode. Спочатку це читається дивно, але за кілька днів мозок звикає, і ви починаєте сприймати це як «просту навігацію по об’єктах».
Ось такий метод — один із найпрактичніших у каталозі:
import com.example.shopdatajpa.catalog.entity.Product;
import com.example.shopdatajpa.catalog.entity.ProductStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface ProductRepository extends JpaRepository<Product, Long> {
// Property path: Product.category.code + додаткова умова за Product.status.
List<Product> findByCategoryCodeAndStatus(String categoryCode, ProductStatus status);
}
На рівні SQL-мислення це зазвичай означає, що буде join (явний або неявний, залежно від mapping і генерації SQL), тому що code лежить у таблиці категорії:
-- Очікувана ідея запиту: робимо join category заради c.code і фільтруємо за статусом товару.
select p.*
from product p
join category c on c.id = p.category_id
where c.code = ? and p.status = ?
Чому це краще, ніж findByCategoryId(...)? Тому що categoryId — технічне поле, а categoryCode — доменне. Із categoryCode простіше жити в API, простіше читати логи й простіше обговорювати баги. «У вас товари зникли з категорії ELECTRONICS» звучить як розмова про продукт, а «у вас зникли з категорії 42» — як розмова про долю. Приблизно однаково філософськи, але бізнес зазвичай віддає перевагу першому варіанту.
До речі, іноді новачок намагається написати щось на кшталт findByCategory_code(...) або findByCategory_Code(...), тому що в SQL є category_code. Це не працює, бо Spring Data очікує Java-style property path, а не snake_case. Тут усе тримається на Java-полях і їхніх назвах.
5. Пошук за частиною назви
Пошук за частиною назви — класика жанру. Користувач вводить tea, чашка, ігрова миша, а ви хочете отримати товари, де це трапляється в назві. На SQL-рівні ви очікуєте LIKE, а на рівні Spring Data — ключове слово Containing. Якщо ще додати IgnoreCase, вийде дуже «користувацький» пошук: регістр уже не має значення.
Ось акуратний метод:
import com.example.shopdatajpa.catalog.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface ProductRepository extends JpaRepository<Product, Long> {
// LIKE-пошук за назвою: «містить підрядок» + ігнорування регістру.
List<Product> findByNameContainingIgnoreCase(String namePart);
}
У SQL-моделі ви очікуєте щось на кшталт:
-- Очікувана ідея запиту: пошук за підрядком без урахування регістру.
where lower(p.name) like lower(concat('%', ?, '%'))
І ось тут важливе правило для початківців: у namePart ви майже завжди передаєте просто tea, а не %tea%. Spring Data сам «обгортає» рядок, коли ви використовуєте Containing. Якщо ви передасте %tea%, то отримаєте %%tea%% (що зазвичай не ламає світ, але виглядає як подвійна паніка).
Трохи практичніший варіант для каталогу — шукати за категорією і частиною назви одночасно. Він усе ще читабельний:
import com.example.shopdatajpa.catalog.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface ProductRepository extends JpaRepository<Product, Long> {
// Поєднання property path (CategoryCode) і LIKE-пошуку за назвою.
List<Product> findByCategoryCodeAndNameContainingIgnoreCase(String categoryCode, String namePart);
}
Якщо ви спробуєте читати це вголос, то вийде майже людська фраза: «знайти товари за кодом категорії та за назвою, що містить частину, ігноруючи регістр». Так, довго, але поки мозок не кровоточить — усе нормально.
6. Діапазон ціни
Фільтр за ціною — типовий магазинний сценарій, і він майже завжди діапазонний: «від 10 до 50». На рівні SQL це between або пара умов >= і <=. У derived queries найчитабельніший варіант — ключове слово Between. І тут Spring Data знову показує свій характер: ви пишете метод, який звучить як use case, а SQL лишається деталлю.
Приклад:
import com.example.shopdatajpa.catalog.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;
import java.math.BigDecimal;
import java.util.List;
public interface ProductRepository extends JpaRepository<Product, Long> {
// Діапазонний фільтр за ціною (зазвичай включає межі, як SQL BETWEEN).
List<Product> findByPriceBetween(BigDecimal minPrice, BigDecimal maxPrice);
}
У SQL-уявленні це:
-- Очікувана ідея запиту: ціна в діапазоні (із включенням меж).
where p.price between ? and ?
Тут потрібно пам’ятати дві речі. По-перше, SQL BETWEEN зазвичай включає межі, тобто «між 10 і 50» охоплює 10 і 50. По-друге, порядок параметрів важливий: спочатку нижня межа, потім верхня. Якщо ви переплутаєте їх, метод працюватиме коректно, просто повертатиме «нічого» — і це той випадок, коли код не падає, а вам сумно.
Часто в каталозі хочеться поєднати ціну зі статусом, щоб не бачити архівні товари. Це теж читабельно:
import com.example.shopdatajpa.catalog.entity.Product;
import com.example.shopdatajpa.catalog.entity.ProductStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import java.math.BigDecimal;
import java.util.List;
public interface ProductRepository extends JpaRepository<Product, Long> {
// Комбінація умов: статус + ціна в діапазоні.
List<Product> findByStatusAndPriceBetween(ProductStatus status, BigDecimal min, BigDecimal max);
}
Зверніть увагу на порядок: у назві методу він такий самий, як і в параметрах. Це не залізне правило зі специфікації, але це залізне правило для виживання команди. Якщо параметрів багато, порядок — це те, що рятує від «а чому в нас min і max помінялися місцями».
7. Фільтр за датою створення
Фільтр за датою — це вже фільтр із категорії «адмінський, але щоденний». «Покажи товари, створені після понеділка», «покажи нові надходження за тиждень», «покажи все, що додали сьогодні». На рівні SQL це порівняння timestampʼів, а в derived queries для цього зручно використовувати ключові слова After і Before. Вони читаються майже як англійська: createdAt after X.
Приклад:
import com.example.shopdatajpa.catalog.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDateTime;
import java.util.List;
public interface ProductRepository extends JpaRepository<Product, Long> {
// Фільтр «новіші за вказану дату-час» (createdAt > ?).
List<Product> findByCreatedAtAfter(LocalDateTime createdAfter);
}
SQL-модель:
-- Очікувана ідея запиту: беремо товари, створені строго пізніше за вказаний момент.
where p.created_at > ?
Практично завжди такий фільтр використовують разом зі статусом, тому що «нові архівні товари» звучать як загадка. Приклад:
import com.example.shopdatajpa.catalog.entity.Product;
import com.example.shopdatajpa.catalog.entity.ProductStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDateTime;
import java.util.List;
public interface ProductRepository extends JpaRepository<Product, Long> {
// Комбінація: статус + «створено після дати».
List<Product> findByStatusAndCreatedAtAfter(ProductStatus status, LocalDateTime after);
}
Час — тема, де навіть досвідчені люди іноді падають у яму. Зараз ми не йдемо в часові пояси, але важливу практичну думку зафіксуємо: якщо ви використовуєте «сьогодні» як дату, перетворіть її на конкретний LocalDateTime (наприклад, початок дня), а не намагайтеся «порівняти з LocalDate». Інакше ви шукатимете товари «після 2026-03-21», а база порівнюватиме timestamp і робитиме те, що їй логічно, але для вас — неочікувано.
8. Комбінації через And
Коли ви опанували And, виникає природне бажання написати один «універсальний» метод: і статус, і категорію, і ціну, і частину назви, і дату. Формально Spring Data може спробувати це розібрати. Практично ви отримуєте назву методу, яка схожа на довгу партію в шахи, зіграну в один рядок. І ваш репозиторій починає виглядати так, ніби він пише роман, а не код.
Приклад, який поки що терпимо, ми вже бачили:
// Поєднання двох умов — зазвичай читається нормально.
List<Product> findByCategoryCodeAndStatus(String categoryCode, ProductStatus status);
А ось приклад, який виглядає як «я хотів допомогти, але випадково створив заклинання»:
import com.example.shopdatajpa.catalog.entity.Product;
import com.example.shopdatajpa.catalog.entity.ProductStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
public interface ProductRepository extends JpaRepository<Product, Long> {
// Приклад "методу-монстра": формально валідно, але важко читати/викликати/підтримувати.
List<Product> findByCategoryCodeAndStatusAndPriceBetweenAndNameContainingIgnoreCaseAndCreatedAtAfter(
String categoryCode, ProductStatus status,
BigDecimal min, BigDecimal max,
String namePart, LocalDateTime after
);
}
Так, як і ідея, це працює. Але в команді це зазвичай обертається на біль: такі методи важко читати, важко викликати (параметри легко переплутати), важко розширювати. Для сьогоднішньої теми наша мета — триматися в зоні «коротко, очевидно, прикладно». Якщо ви впіймали себе на думці «я зараз впхаю ще один And», це гарний момент зупинитися й спитати: «а цей сценарій справді потрібен саме в такому вигляді?»
Іноді правильне рішення — мати кілька маленьких методів, які закривають різні зрозумілі сценарії. Наприклад, один метод для «активні в категорії», інший — для «пошук за назвою», третій — для «у діапазоні ціни». Так простіше і читати, і підтримувати, а реальні потреби каталогу це цілком покриває.
9. Типові помилки derived queries каталогу
Помилка № 1: використовувати назву колонки замість назви Java-поля.
Дуже часта ситуація: у таблиці колонка називається created_at, студент за звичкою пише findByCreated_AtAfter(...) або findByCreatedAt(...), але з неправильним регістром, підкресленнями чи формою назви. У derived queries беруть участь саме Java-поля entity, тобто createdAt. Якщо сумніваєтеся, відкрийте сутність, а не pgAdmin.
Помилка № 2: намагатися писати SQL-шаблони в параметрах Containing.
Коли ви використовуєте Containing, вам зазвичай потрібно передати "tea", а не "%tea%". Якщо ви починаєте вручну малювати %, це сигнал, що подумки ви вже перейшли в режим «я пишу SQL», але робите це через параметри, де це не потрібно. У найкращому разі це просто некрасиво, у найгіршому — ви почнете вигадувати дивні комбінації й дивуватися результату.
Помилка № 3: переплутати min і max у Between.
Between виглядає нешкідливо, але це рівно той метод, який «не падає, а тихо повертає порожній список». Якщо ви передали min=50, max=10, репозиторій чесно виконає запит і нічого не знайде. Тому в сервісі іноді корисно або валідувати межі (мінімум не більший за максимум), або явно називати параметри minPrice і maxPrice, щоб їх було складніше переплутати.
Помилка № 4: писати findByCategoryId..., коли в моделі вже є category.
Якщо ви побудували нормальний зв’язок ManyToOne і в Product є поле category, то запити рівня «знайди за categoryId» повертають вас до стилю «ORM як JDBC з анотаціями». Можна, звісно, так зробити, але ви втрачаєте головну перевагу моделі: доменні поля пов’язаної сутності. findByCategoryCode... читається як бізнес, а не як внутрішня механіка FK.
Помилка № 5: робити один метод на всі випадки життя й отримувати “метод-монстр”.
Spring Data справді вміє розбирати довгі назви, але ваш мозок і мозок ваших колег — ні. Якщо назва стала схожою на заклинання з фентезі («Іменем усіх And… хай прийде результат!»), це гарний момент зупинитися і повернутися до простих, зрозумілих сценаріїв. Derived queries сильні там, де метод короткий і очевидний; коли ви виходите за цю межу, репозиторій перестає бути контрактом і перетворюється на квест.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ