JavaRush /Курси /Spring Data JPA /Типові похідні запити для каталогу

Типові похідні запити для каталогу

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

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 сильні там, де метод короткий і очевидний; коли ви виходите за цю межу, репозиторій перестає бути контрактом і перетворюється на квест.

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