JavaRush /Курси /Spring Data JPA /Вибір інструмента для запитів у Spring Data JPA

Вибір інструмента для запитів у Spring Data JPA

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

1. Як обирати інструмент для запитів

Коли derived query, JPQL, projection, native query і Specification уже знайомі окремо, виникає інша проблема: в одному репозиторії їх легко змішати без жодної логіки. Тоді будь-який спосіб читання даних здається однаково добрим: «Ну, працює ж!». І це справді так… аж до першого реального проєкту або до другого репозиторію, який ви відкриєте через тиждень і не впізнаєте. У Spring Data JPA є кілька паралельних шляхів до одного результату: derived query, JPQL, projection, native query, Specification. Якщо не мати простого правила вибору, ви неминуче почнете змішувати все одразу — і репозиторій стане схожим на кухню студента наприкінці сесії: формально їжа є, але жити там страшно.

Ключова думка цієї лекції: вибір інструмента для запитів починається не з того, «що я вмію», і не з того, «що модно», а з того, який сценарій використання ми закриваємо і яку форму результату хочемо отримати. Тобто спочатку ми формулюємо запит до даних, а вже потім обираємо, якою мовою його висловити. Тут потрібен не новий синтаксис, а просте правило вибору.

Сценарій використання і форма результату важливіші за синтаксис

Зазвичай помилка новачка виглядає так: спочатку він обирає інструмент, а потім намагається «впхнути» в нього завдання. Виходить як у житті: купили дриль — і починаєте думати, що все у світі має бути просвердлене. У data-layer рівно те саме: полюбили derived queries — почнете робити derived-монстрів; вивчили JPQL — почнете писати @Query на кожен дрібний випадок; дізналися про native SQL — і раптом Spring Data JPA перетворюється на «SQL в інтерфейсах».

Здоровіший старт — поставити собі два запитання.

Перше запитання: що саме я намагаюся показати або обчислити? Наприклад, «знайти товар за SKU», «показати сторінку каталогу», «дати адмінський пошук з опціональними фільтрами», «побудувати звіт про залишки».

Друге запитання: у якому вигляді я хочу повернути результат? Іноді потрібен повноцінний Product як сутність (наприклад, для команди зміни статусу). Іноді потрібні 3–4 поля для списку — назва, ціна, категорія: це вже прямий кандидат на projection. Іноді потрібен лише long (скільки активних товарів) — це взагалі не історія про читання сутностей. Саме форма результату часто сама підштовхує вас до правильного інструмента.

Щоб закріпити це в голові, давайте на прикладах із mini-shop.

Якщо вам потрібно «за SKU знайти товар, щоб змінити ціну», то сутність — розумний вибір: ви справді хочете Product. Якщо вам потрібно «показати каталог товарів», де відображаються лише name, price, categoryName, то тягнути повноцінний Product із половиною непотрібних полів — дивно: ви будуєте read-модель під список, а не готуєтеся до запису.

І лише після цього ви обираєте механізм: derived / JPQL / projection / native / Specification. Тепер розберімо кожен інструмент у його рідному середовищі.

2. Derived queries: імʼя методу як запит

Derived query — це той випадок, коли Spring Data бере імʼя методу, розбирає його і сам генерує запит. У перші дні навчання це виглядає як магія рівня «я написав findByStatus і отримав SQL». Але насправді магія тут цілком інженерна: derived queries чудово працюють, доки ваш запит простий, умови стабільні, а імʼя методу лишається читабельним.

У проєкті shop-data-jpa derived-підхід доречний для речей на кшталт «знайти товари за статусом», «знайти товар за SKU», «порахувати кількість товарів у статусі». Це короткі, зрозумілі й повторювані сценарії. Тут важливий саме принцип: derived query — про просте формулювання наміру, а не про спробу змусити імʼя методу стати повноцінною мовою запитів.

Приклад «здорового» derived-методу для каталогу:

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface ProductRepository extends JpaRepository<Product, Long> {

    // Derived query: Spring Data сам побудує запит за імʼям методу.
    // SKU очікується унікальним, тому 0..1 результат виражаємо через Optional.
    Optional<Product> findBySku(String sku);
}

Ще один класичний випадок — коли нам потрібен саме лічильник, а не список:

import org.springframework.data.jpa.repository.JpaRepository;

public interface ProductRepository extends JpaRepository<Product, Long> {

    // Повертаємо скаляр (long), бо сценарій — "скільки", а не "що саме".
    long countByStatus(ProductStatus status);
}

Зверніть увагу на психологічний ефект: за однією сигнатурою видно і намір, і форму результату. Це й є «самодокументованість», якої ми прагнемо.

Межа derived queries проявляється приблизно там, де ви ловите себе на думці: «Ну так, читається… якщо довго дивитися і не кліпати». Щойно імʼя методу стає складнішим за сам запит, derived перестає бути перевагою. Це не «погано», це просто сигнал: настав час змінити інструмент.

3. JPQL і @Query: текст запиту замість роману

JPQL (Java Persistence Query Language) — мова запитів до entity-моделі, а не до таблиць. Вона зручна, коли derived-метод починає розростатися, а вам потрібно явно виразити умову. Тут важливо не перетворювати JPQL на «ще одну магію»: це звичайний текст запиту, просто він говорить мовою сутностей.

Головна причина обрати JPQLвиразність і читабельність. Так, це рядок (і так, рядки іноді ламаються під час рефакторингу). Але в якийсь момент рядок стає набагато чеснішим і коротшим, ніж derived-імʼя завдовжки в абзац.

Приклад: нам потрібен шматок каталогу — «активні товари з ціною від X», відсортовані за назвою. У derived-стилі це вже може вийти не дуже гарно, а JPQL виглядає зрозуміло:

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.math.BigDecimal;
import java.util.List;

public interface ProductRepository extends JpaRepository<Product, Long> {

    // Явний JPQL: читаємо запит як текст і не намагаємося "зашити" логіку в імʼя методу.
    // Іменовані параметри легше читати й складніше випадково переплутати, ніж позиційні.
    @Query("""
           select p
           from Product p
           where p.status = :status
             and p.price >= :min
           order by p.name
           """)
    List<Product> findCatalogChunk(@Param("status") ProductStatus status,
                                   @Param("min") BigDecimal min);
}

Зверніть увагу на два моменти, які варто взяти за звичку. Ми використовуємо іменовані параметри :status і :min, тому що так їх легше читати і складніше випадково переплутати. Ми також використовуємо багаторядковий рядок, щоб запит виглядав як нормальний текст, а не як клубок лапок.

Ще одна ознака, що JPQL підходить: вам потрібен join по асоціації, наприклад фільтр за категорією. Derived queries теж уміють ходити по властивостях, але JPQL часто робить намір значно яснішим:

@Query("""
       select p
       from Product p
       where p.category.id = :categoryId
         and p.status = :status
       """)
List<Product> findByCategoryAndStatus(
        @Param("categoryId") Long categoryId, // Фільтрація по асоціації (категорія)
        @Param("status") ProductStatus status // Додаткова умова за статусом
);

Чому це чесніше? Тому що читач бачить запит одразу і не змушений розбирати лінгвістику імені методу. І це особливо цінно, коли код живе довше тижня.

4. Projections: легка read-модель

Projections — це спосіб сказати: «Мені не потрібна ціла сутність, дайте мені лише потрібні поля». І це не примха. У реальному backend-коді половина читань — це списки та summary-подання, де потрібна легка read-модель, а не повноцінний обʼєкт, який потенційно тягне за собою зайві деталі.

Важливо: projection — це не DTO «для web-шару». Ми зараз працюємо в курсі data-layer, і projection тут — це інструмент read-моделі на стороні backend. Тобто write-модель у нас часто entity-орієнтована, а read-модель — орієнтована на сценарій використання.

Почнімо з інтерфейсної projection — це найпростіший вхід. Для каталогу нам може знадобитися «рядок списку»:

import java.math.BigDecimal;

public interface ProductCatalogRow {
    // В інтерфейсній projection методи-аксесори повинні збігатися з аліасами/іменами полів.
    Long getId();
    String getName();
    BigDecimal getPrice();
}

І тепер ми можемо повернути не Product, а ProductCatalogRow, причому навіть на derived-методі:

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface ProductRepository extends JpaRepository<Product, Long> {

    // Derived query + projection: фільтр простий, а форма результату — "легка read-модель".
    List<ProductCatalogRow> findByStatus(ProductStatus status);
}

Для того, хто читає репозиторій, це дуже акуратно: фільтр простий, derived доречний, а форма результату вже легка. І саме тут ви починаєте бачити, як інструменти комбінуються, а не конкурують.

Якщо вам потрібна більш явна структура і ви хочете отримувати її як нормальний Java-тип, а не як «інтерфейс із геттерами», можна використати record-projection:

import java.math.BigDecimal;

// Record як read-модель: зручно, компактно, неможливо випадково "дописати сетери".
public record ProductPriceRow(Long id, BigDecimal price) {
}

І JPQL, який створює цей record через constructor expression:

@Query("""
       select new com.example.shopdatajpa.catalog.query.ProductPriceRow(p.id, p.price)
       from Product p
       where p.status = :status
       order by p.id
       """)
List<ProductPriceRow> findActivePrices(@Param("status") ProductStatus status); // Повертаємо саме read-модель, а не entity

Так, рядок став трохи довшим через повну назву класу. Зате контракт абсолютно прозорий: ви точно знаєте, що повернеться, і не тягнете зайве.

Межа projections зазвичай там, де ви починаєте плутати їх із сутностями. Projection — це read-модель, вона не призначена для зміни та збереження. Якщо ви зловили себе на бажанні зробити productCatalogRow.setPrice(...) — значить, ви емоційно повернулися до DTO-мислення, і projection перестала виконувати свою роль.

5. Native query: коли SQL справді потрібен

Native query — це «говоримо з базою її рідною мовою», тобто SQL. Це потужно. І саме тому небезпечно як звичка. У здоровому проєкті native query — точковий інструмент для випадків, коли JPQL стає незручною або коли ви робите звіт, де SQL-мислення природніше.

У нашому mini-shop класичний виправданий приклад — звіт про низькі залишки (low-stock report). Він часто виглядає як таблиця з кількох полів, і писати це на SQL може бути справді простіше й зрозуміліше.

Зробімо projection для звіту:

public interface LowStockRow {
    // У native query особливо важливо, щоб імена збігалися з аліасами (див. запит нижче).
    String getSku();
    Integer getAvailableQuantity();
}

І native query в репозиторії залишків:

import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;

import java.util.List;

public interface StockItemReportRepository extends Repository<StockItem, Long> {

    // Native SQL: використовуємо, коли "SQL-first" читається простіше, ніж JPQL.
    // Аліаси (as sku / as availableQuantity) роблять мапінг у projection передбачуваним.
    @Query(value = """
            select p.sku as sku, si.available_quantity as availableQuantity
            from stock_item si
            join product p on p.id = si.product_id
            where si.available_quantity < :threshold
            order by si.available_quantity asc
            """, nativeQuery = true)
    List<LowStockRow> findLowStock(int threshold); // threshold — вхідний параметр звіту
}

Зверніть увагу на дві дисципліни. По-перше, ми повертаємо не сутність, а report-projection: так підкреслюємо звітний характер читання. По-друге, ми використовуємо аліаси as sku і as availableQuantity, щоб мапінг був передбачуваним. У native SQL «дисципліна аліасів» — це не естетика, а спосіб не ловити сюрпризи.

Native query легко перетворюється на фабрику технічного боргу, якщо ви починаєте писати на SQL усе підряд. Тоді Spring Data JPA у вас лишається лише в назві залежностей, а код переїжджає в «рядки в інтерфейсах». Тому правило просте: native query — коли справді є причина, а не коли «я так звик».

6. Specification: динамічні фільтри

Specification — це відповідь на типовий біль «адмінського пошуку», коли фільтрів багато і кожен із них може бути вказаний або не вказаний. У derived-світі ви б отримали комбінаційний вибух: findByStatusAndCategoryAndMinPriceAndMaxPriceAndNameContaining і так далі. У JPQL-світі ви б отримали дуже довгий запит із купою (:param is null or ...). Працює, але читабельність починає страждати.

Specification дозволяє збирати умови як конструктор, акуратно додаючи їх лише тоді, коли параметр справді заданий. Це не означає, що Specification потрібно використовувати завжди. Це означає, що в Specification є своя рідна середа існування: динамічні фільтри.

У проєкті це зазвичай виглядає так. Спочатку ми описуємо фільтр як простий record:

import java.math.BigDecimal;

// Вхідні параметри пошуку: кожне поле може бути null (тобто фільтр не задано).
public record ProductAdminFilter(
        ProductStatus status,
        Long categoryId,
        BigDecimal minPrice,
        BigDecimal maxPrice,
        String text
) {
}

Потім робимо набір «цеглинок» специфікацій:

import org.springframework.data.jpa.domain.Specification;

import java.math.BigDecimal;

public final class ProductSpecs {

    private ProductSpecs() {
        // Утилітний клас: екземпляри не потрібні
    }

    public static Specification<Product> status(ProductStatus status) {
        // Специфікація "статус дорівнює": використовуємо лише тоді, коли status != null (див. сервіс нижче).
        return (root, query, cb) -> cb.equal(root.get("status"), status);
    }

    public static Specification<Product> priceGte(BigDecimal min) {
        // Специфікація "ціна >= min": також застосовуємо лише за наявності параметра.
        return (root, query, cb) -> cb.greaterThanOrEqualTo(root.get("price"), min);
    }
}

І в сервісі або query-сервісі збираємо запит із тих умов, які справді надійшли:

import org.springframework.data.jpa.domain.Specification;

public class ProductAdminSearchService {

    public Specification<Product> buildSpec(ProductAdminFilter filter) {
        // Початкова точка: "порожня" специфікація без умов
        Specification<Product> spec = Specification.where(null);

        // Додаємо умови лише тоді, коли параметр справді заданий
        if (filter.status() != null) spec = spec.and(ProductSpecs.status(filter.status()));
        if (filter.minPrice() != null) spec = spec.and(ProductSpecs.priceGte(filter.minPrice()));

        return spec;
    }
}

Тут ми не розбираємо тонкощів API і не будуємо універсальний конструктор запитів на всі випадки життя. Ідея простіша: Specification має лишатися прагматичним інструментом. Якщо у вас дві стабільні умови — derived або JPQL будуть простішими. Якщо у вас «пʼять фільтрів, і будь-який може бути порожнім» — Specification стає дуже доречною.

7. Шпаргалка вибору інструмента

У голові новачка все це може звучати як «ще пʼять способів усе ускладнити». Тому нам потрібна не енциклопедія, а проста схема вибору. Уявіть, що ви в майстерні: у вас є викрутка, ключ, пасатижі, паяльник і молоток. Можна, звісно, відкрити банку фарби молотком. Але краще все-таки відкрити її відкривачкою. У data-layer рівно так само.

Спочатку — таблиця-шпаргалка. Не як догма, а як орієнтир.

Інструмент Коли брати Що зазвичай повертаємо Головний плюс Головний мінус
Derived query Фільтр простий, умови стабільні, імʼя методу коротке й читабельне Optional, List, інколи projection або long Мінімум коду, хороший intent Не масштабується на складні випадки
JPQL (@Query) Імʼя методу стає монстром або потрібен явний запит/join/логіка List, projection, агрегати Читабельність запиту, виразність Рядок запиту вимагає дисципліни
Projection Для списків або summary, де потрібна частина полів interface/record projection Читаєте рівно те, що потрібно сценарію Не можна плутати із сутністю й «редагувати»
Native query Звіти, vendor-специфіка, складне SQL-читання projection/скаляри Максимальна свобода SQL Ціна супроводу, SQL-дисципліна
Specification Багато опціональних фільтрів, динамічна композиція зазвичай List або paged result, часто ще й projection Немає комбінаційного вибуху методів Зайва складність для простих випадків

Тепер — мініалгоритм, який справді можна застосовувати в голові. Він спеціально короткий і по-людськи.

flowchart TD
    A["Є read-сценарій"] --> B{"Потрібна вся сутність?"}
    B -->|Так| C{"Фільтр простий і стабільний?"}
    B -->|Ні, потрібна лише частина полів| D["Projection"]
    C -->|Так| E["Derived query"]
    C -->|Ні| F{"Фільтрів багато й вони опціональні?"}
    F -->|Так| G["Specification"]
    F -->|Ні| H{"Потрібен звіт у SQL або специфіка БД?"}
    H -->|Так| I["Native query"]
    H -->|Ні| J["JPQL @Query"]

Зверніть увагу: projection стоїть дуже близько до початку. Це зроблено навмисно. Часта дитяча хвороба — за замовчуванням читати сутність. А реальний світ часто хоче подання, тобто легкі read-моделі.

8. Приклади вибору в mini-shop

Щоб правило не лишилося на рівні «гарної схеми», розгляньмо кілька сценаріїв у стилі «що обрати і чому», прямо по домену mini-shop.

Якщо бізнес-питання звучить як «знайти товар за SKU» — це практично ідеальний derived query. Він короткий, зрозумілий, і ви очікуєте 0 або 1 результат. Тут немає сенсу писати JPQL: derived виглядає чистіше.

Якщо питання звучить як «показати сторінку каталогу, де в рядку товару потрібні лише name і price» — це кандидат на projection. І тут у вас два варіанти: або derived + projection (якщо фільтр простий), або JPQL + projection (якщо фільтр складніший). Сенс один: ви підлаштовуєте read-модель під список.

Якщо питання звучить як «адмінський пошук: статус опціональний, категорія опціональна, діапазон ціни опціональний, текст опціональний» — тут Specification дуже швидко перемагає derived (занадто багато комбінацій) і JPQL (занадто багато if param is null). Ви описуєте кожну умову один раз і збираєте запит так, як збираєте лего.

Якщо питання звучить як «звіт про низькі залишки, потрібен join таблиць і особливі умови та сортування, які простіше виразити SQL» — native query стає нормальним вибором. Але це саме звітний, локальний сценарій. Ви не переїжджаєте всією архітектурою в SQL-first підхід.

І, нарешті, якщо питання звучить як «потрібна складна логіка, але це все ще читання по entity-моделі, без потреби в SQL-специфіці» — JPQL лишається золотою серединою. Він дозволяє явно виразити запит, але залишає вас у світі entity-моделі, а отже, ви менше залежите від схемних деталей таблиць.

Уся ця картина важлива ще й тому, що вона захищає репозиторій від «стилістичного зоопарку». Якщо команда домовилася, що прості фільтри робимо derived, складні — JPQL, списки — projection, динаміку — Specification, звіти — native, то репозиторії стають передбачуваними. Передбачуваність — це суперсила, яку на початку шляху чомусь рідко цінують. А потім дуже цінують. Інколи навіть із сльозами.

І щойно ця матриця стає зрозумілою, наступна типова плутанина виникає вже не у виборі між @Query і projection. Вона виникає в самих базових дієсловах репозиторію: де код хоче прочитати сутність, де — взяти посилання, де — просто перевірити існування.

9. Типові помилки під час вибору інструмента для запитів

Коли у вашому розпорядженні пʼять способів організувати читання даних, рука сама тягнеться зробити «як вийде». Це нормально: мозок лінивий, і йому хочеться повторювати те, що вже спрацювало. Але в цих звичок є характерні поломки.

Помилка №1: «Я завжди повертаю entity, тому що так простіше».
Спершу це справді простіше, а потім ви раптом починаєте читати багато даних там, де потрібен короткий список. Код роздувається, контракт читання розмивається, і ваш read-side перестає бути орієнтованим на сценарій використання. Звичка думати про projection як про нормальну read-модель лікує це майже одразу.

Помилка №2: derived query як «універсальна мова запитів».
Коли ви перетворюєте імена методів на романи, ви платите не продуктивністю, а супроводжуваністю. Найприкріше, що через місяць ви вже самі насилу розбираєте, що там написано, а IDE перетворюється на «помічника-перекладача з вашої ж мови на людську». Щойно імʼя перестало читатися — це не «ще трохи потерпіти», це сигнал змінити інструмент.

Помилка №3: JPQL на будь-яку дрібницю «тому що я навчився @Query».
JPQL корисний, але це рядок, а отже він вимагає дисципліни: іменовані параметри, читабельне форматування, зрозумілий намір. Писати @Query там, де derived був би простішим, — це теж форма технічного боргу, просто більш «інтелігентна».

Помилка №4: Native query як стиль за замовчуванням.
Native SQL — інструмент, який вимагає справжньої SQL-дисципліни. Щойно ви починаєте масово писати native queries для звичайних читань, ви непомітно перетворюєте Spring Data JPA на дорогу обгортку навколо рядків SQL. Це рідко саме те, чого ви хочете в застосунку, де ORM обрано як базовий підхід.

Помилка №5: Specification заради одного фільтра.
Specification — це про динаміку і композицію. Якщо у вас один-два стабільні параметри, Specification виглядатиме як «я збудував космічний корабель, щоб сходити по хліб». Працює, але викликає запитання в команди. Краще тримати Specification як інструмент для тих місць, де вона справді рятує від комбінаційного вибуху.

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