JavaRush /Курси /Spring Data JPA /@Query і параметри

@Query і параметри

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

1. Роль параметрів у @Query

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

У найпростішому випадку запит можна «зварити» з рядків вручну, але це шлях у світ лапок, SQL-інʼєкцій (так, навіть у JPQL можна влаштувати собі пригоду) і несподіваної поведінки в робочому середовищі. Тому в Spring Data JPA нормальний підхід — параметризовані запити: значення передаються окремо, а не вплітаються в текст.

З точки зору нашого проєкту shop-data-jpa це виглядає так: репозиторій оголошує метод на кшталт «знайти товари каталогу за кодом категорії та верхньою межею ціни», а конкретні значення ("electronics", 499.99) приходять як параметри методу. Ми отримуємо читабельний контракт і менше «магії» там, де її й так вистачає.

Нижче знову будуть локальні фрагменти методів і репозиторіїв: зараз нам важлива саме механіка параметрів, а не весь інтерфейс цілком.

Для орієнтиру згадаємо, як ми не хочемо це робити:

// Погана ідея: значення вшиті в рядок.
// Так сьогодні не робимо.
//
// Проблема не лише в тому, що це «некрасиво»: такий запит не можна нормально повторно використати,
// і він провокує «латання на місці» замість явних параметрів.
@Query("""
       select p
       from Product p
       where p.sku = 'ABC-123'
       """)
Optional<Product> findHardcoded();

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

2. Як виконується запит @Query

Коли ви пишете інтерфейс репозиторію, відчуття іноді дивне: «я оголосив метод, а реалізацію не написав — і все працює». З @Query це відчуття стає ще загадковішим: «я ще й рядок поклав в анотацію — і воно пішло в базу». Щоб не сприймати це як чорну магію, корисно розуміти ланцюжок подій хоча б на рівні здорового глузду.

Spring Data, коли піднімає контекст, сканує репозиторії й для кожного методу вирішує, як його реалізувати. Якщо метод без @Query, він намагається вивести запит із назви (derived query). Якщо метод із @Query, він бере ваш текст JPQL як джерело істини й готує «шаблон запиту», у який потім підставить параметри. Далі Hibernate, як JPA-провайдер, перетворює JPQL на SQL і виконує його через JDBC-драйвер PostgreSQL. І так, зрештою все одно буде «звичайний» SQL, просто ви його не писали вручну.

Ось спрощена схема того, що відбувається, коли ви викликаєте метод репозиторію з @Query:

flowchart TD
    A["Викликаємо метод репозиторію"] --> B["@Query: JPQL-рядок"]
    B --> C["Spring Data: пов’язує параметри методу й параметри запиту"]
    C --> D["Hibernate: перетворює JPQL на SQL"]
    D --> E["JDBC-драйвер PostgreSQL: виконує SQL"]
    E --> F["Результат перетворюється на об’єкти й значення"]

І в цій схемі параметри — центральний момент. Якщо ви написали :sku у запиті, а пов’язування параметра розходиться за іменами або взагалі лишається неявним, на рівному місці виникає дуже «веселий» баг: запит ніби написано, а значення до нього не пов’язалося. Застосунок або впаде під час старту, або під час першого виклику методу.

3. Іменовані параметри та @Param

Іменовані параметри — це коли в JPQL ви пишете «дірку» на кшталт :sku, а потім кажете Spring Data: «ось це значення з аргументу методу підстав у параметр sku». Це найзручніший для людини стиль. Він читається майже як звичайний текст: where p.sku = :sku. Навіть якщо ви не фанат баз даних, зміст видно одразу.

У нашому проєкті це особливо зручно, тому що в нас є доменні поля зі зрозумілими іменами: sku, orderNumber, customerEmail, category.code. І коли в запиті написано :orderNumber, то в методі хочеться бачити параметр orderNumber, а не a чи x.

Додаймо до репозиторію каталогу метод, який знаходить товар за sku. Так, derived query і так могла б це зробити, але нам важливий саме приклад пов’язування параметра.

package com.example.shopdatajpa.catalog.repository;

import com.example.shopdatajpa.catalog.entity.Product;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface ProductRepository extends JpaRepository<Product, Long> {

    // Іменований параметр у JPQL: ":sku".
    // Це «імʼя», за яким Spring Data пов’язуватиме аргумент методу з рядком запиту.
    @Query("""
           select p
           from Product p
           where p.sku = :sku
           """)
    // @Param("sku") — явне пов’язування аргументу методу з іменованим параметром ":sku" у JPQL.
    Optional<Product> findBySkuJpql(@Param("sku") String sku);
}

Зверніть увагу на три деталі, які варто зробити звичкою.

По-перше, @Param береться з org.springframework.data.repository.query.Param, а не з JPA. Це саме «клей» Spring Data між параметром методу та параметром запиту.

По-друге, ім’я в @Param("sku") має збігатися з тим, що стоїть після двокрапки в запиті (:sku). Збігатися буквально, включно з регістром. Комп’ютер жартів про «я ж майже так написав» не розуміє.

По-третє, метод можна назвати коротко й за змістом. Якщо в нас у проєкті вже є derived-метод findBySku, то findBySkuJpql — тимчасова навчальна назва. У реальному коді частіше роблять навпаки: залишають derived findBySku, а @Query використовують там, де назва вже не передає сценарій.

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

package com.example.shopdatajpa.catalog.repository;

import com.example.shopdatajpa.catalog.entity.Product;
import java.math.BigDecimal;
import java.util.List;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface ProductRepository extends org.springframework.data.jpa.repository.JpaRepository<Product, Long> {

    // Зверніть увагу: параметри називаємо за доменом, щоб запит читався як домовленість.
    // Імена :categoryCode та :maxPrice мають збігтися з @Param("...") нижче.
    @Query("""
           select p
           from Product p
           where p.category.code = :categoryCode
             and p.category.active = true
             and p.price <= :maxPrice
           """)
    List<Product> findCatalogProducts(@Param("categoryCode") String categoryCode,
                                      @Param("maxPrice") BigDecimal maxPrice);
}

Тут ми ще заодно використовуємо Java text block (""" ... """). Це не обов’язкова магія, а просто спосіб не перетворювати запит на «локшину» з рядків і плюсів. Для новачків це справжній подарунок: JPQL виглядає майже як нормальний багаторядковий текст.

Найважливіше — не намагатися «зекономити» на @Param, сподіваючись, що Spring сам здогадається за іменем аргументу методу. Іноді він справді здогадається, якщо проєкт компілюється з прапорцем -parameters і імена параметрів доступні в рантаймі, а іноді — ні. Ще гірше, коли на одній машині в одного розробника «працює», а в іншого — «чомусь ні». Тому в навчальному й командному коді @Param — чесна та стабільна звичка.

Якщо хочете зрозуміти, чому це буває нестабільно, ось приклад мінімального налаштування Gradle, яке вмикає збереження імен параметрів. Це загальний Java-нюанс, не лише про Spring Data:

tasks.withType<JavaCompile>().configureEach {
    // Зберігаємо імена параметрів методів у байткоді (потрібно, щоб фреймворки могли читати їх у рантаймі).
    // Навіть із цим прапорцем @Param зазвичай краще залишати: він робить пов’язування параметрів явним.
    options.compilerArgs.add("-parameters")
}

Але навіть із цим прапорцем @Param залишається добрим стилем, тому що робить зв’язок параметрів явним у коді. Явність у шарі даних зазвичай окупається.

4. Позиційні параметри ?1, ?2

Позиційні параметри — це другий стиль, який підтримує JPQL у Spring Data JPA. Замість :maxPrice ви пишете ?2, а потім передаєте другий аргумент методу як значення для цього параметра. Стиль робочий, але крихкіший: читачеві потрібно тримати в голові, що таке ?1 і ?2, та як вони співвідносяться із сигнатурою методу.

Іноді позиційні параметри трапляються в старому коді й у прикладах з інтернету, особливо в «копіпасті з 2015-го». Ми маємо вміти їх читати й розуміти, але використовувати їх як варіант за замовчуванням — не найкраща ідея, якщо запит не зовсім тривіальний.

Ось той самий запит «каталожні товари», але в позиційному стилі:

package com.example.shopdatajpa.catalog.repository;

import com.example.shopdatajpa.catalog.entity.Product;
import java.math.BigDecimal;
import java.util.List;
import org.springframework.data.jpa.repository.Query;

public interface ProductRepository extends org.springframework.data.jpa.repository.JpaRepository<Product, Long> {

    // Позиційні параметри: ?1 відповідає першому аргументу методу, ?2 — другому.
    // Важливо: під час перестановки аргументів методу цей запит легко «зламати логічно», не зламавши компіляцію.
    @Query("""
           select p
           from Product p
           where p.category.code = ?1
             and p.price <= ?2
           """)
    List<Product> findCatalogProductsByPosition(String categoryCode, BigDecimal maxPrice);
}

Формально все нормально: ?1 — перший параметр методу, ?2 — другий. Але далі починається людський фактор. Через місяць ви додасте третій параметр, наприклад status, переставите аргументи місцями в методі, і доведеться дуже уважно перевіряти, що ?1, ?2, ?3 залишилися коректними. Це те саме місце, де компілятор не врятує, а баг виглядатиме як «дані дивно фільтруються».

Щоб зафіксувати різницю, корисна невелика порівняльна таблиця:

Стиль параметрів Як виглядає в JPQL Як виглядає в методі Основна перевага Основний недолік
Іменований :sku, :maxPrice @Param("sku") String sku читабельність і стійкість до перестановок потрібно дисципліновано збігатися за іменами
Позиційний ?1, ?2 String sku, BigDecimal maxPrice коротко, швидко накидати легко зламати під час рефакторингу сигнатури

Важливо запам’ятати одне практичне правило: коли параметрів більше одного і запит не «на три слова», іменовані параметри зазвичай виграють. І виграють не у Spring, а в людей, які читатимуть код.

5. Дисципліна параметрів у запитах

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

Найперше правило звучить нудно, але рятує: не змішуйте стилі параметрів в одному запиті. Тобто або всі параметри іменовані (:sku, :maxPrice), або всі позиційні (?1, ?2). Змішування майже завжди закінчується помилками й зайвими запитаннями до себе: «а чому ось тут :code, а тут раптом ?2?».

Друге правило — давайте параметрам імена за доменом, а не за настроєм. Якщо в нас є categoryCode, то так і називаємо. Якщо є orderNumber, то не треба скорочувати до n. Скорочення в запитах — це як коментар «зроблено на коліні»: формально працює, але читати неприємно.

Третє правило — робіть запит візуально читабельним. Java text blocks тут дуже допомагають. Особливо коли умов дві, три або більше.

Ось приклад запиту, де ми шукаємо товари за частиною назви через like і обмежуємо ціну. Сенс не в самому like, а в тому, як виглядають параметри й як легко оком зіставити їх з аргументами методу.

package com.example.shopdatajpa.catalog.repository;

import com.example.shopdatajpa.catalog.entity.Product;
import java.math.BigDecimal;
import java.util.List;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface ProductRepository extends org.springframework.data.jpa.repository.JpaRepository<Product, Long> {

    // namePart — це «шматок рядка», який ми обгортаємо в %...% прямо в JPQL.
    // maxPrice — верхня межа ціни (параметр обов’язковий, інакше умова <= стане проблемною).
    @Query("""
           select p
           from Product p
           where lower(p.name) like lower(concat('%', :namePart, '%'))
             and p.price <= :maxPrice
           order by p.name
           """)
    // Важливо: значення передаються окремо від тексту запиту — це і безпечніше, і зрозуміліше під час читання.
    List<Product> searchByNameAndMaxPrice(@Param("namePart") String namePart,
                                         @Param("maxPrice") BigDecimal maxPrice);
}

Тут ви прямо бачите зв’язок: :namePart пов’язаний із @Param("namePart"), а :maxPrice — із @Param("maxPrice"). Навіть якщо ви не пам’ятаєте синтаксис напам’ять, логіка читається без зусиль.

І ще один маленький, але важливий нюанс: не бійтеся використовувати @Param навіть там, де здається, що «і так зрозуміло». Парадоксально, але в запитах явність економить більше часу, ніж забирає.

6. Типові помилки параметрів @Query

Помилка №1: параметр у JPQL називається одним словом, а в @Param — іншим.
Найчастіший сценарій виглядає так: ви пишете запит ... where p.sku = :sku, а в методі ставите @Param("productSku") String sku. Spring Data не читає думки й не розуміє, що «productSku — це ж майже sku». У підсумку під час старту застосунку або під час виклику методу ви отримаєте помилку, що параметр не пов’язано. Лікується це банально: ім’я після двокрапки та ім’я в @Param("...") мають збігатися буквально.

Помилка №2: забули двокрапку в іменованому параметрі.
Якщо написати where p.sku = sku замість where p.sku = :sku, JPQL-парсер спробує трактувати sku як ідентифікатор, наприклад як аліас або поле, і далі почнеться плутанина, яка виглядає як «якась дивна синтаксична помилка». Тут допомагає проста звичка: очима шукати двокрапки в усіх параметрах і тримати параметри візуально однаковими за стилем.

Помилка №3: змішали іменовані та позиційні параметри в одному запиті.
Технічно це майже завжди призводить до помилки, а методично — до нервових тиків у тих, хто це читає. Наприклад, ... where p.category.code = :code and p.price <= ?2. Навіть якщо це раптом заведеться в якомусь середовищі, далі воно почне ламатися при мінімальній зміні сигнатури. Краще обрати один стиль для одного методу й дотримуватися його.

Помилка №4: позиційні параметри зламалися після рефакторингу сигнатури методу.
З позиційним стилем легко зробити так: ви додали параметр першим (status), а запит усе ще очікує, що ?1 — це categoryCode. У результаті дані фільтруються не так, але формально код компілюється, і тести можуть не покрити цей випадок. Якщо вже ви використовуєте позиційні параметри, ставтеся до зміни порядку аргументів як до небезпечної операції: після неї запит треба перечитати цілком. У більшості випадків простіше перейти на іменовані параметри й забути про цю категорію помилок.

Помилка №5: передали null у параметр і очікували, що запит «якось сам розбереться».
JPQL не сприймає null як «умову не застосовувати». Якщо у вас написано p.price <= :maxPrice, а maxPrice дорівнює null, ви отримуєте або помилку, або запит, який логічно не робить того, чого ви очікуєте. У межах сьогоднішньої теми правильний підхід простий: параметри для таких запитів вважаємо обов’язковими. Коли сценарій передбачає «інколи є фільтр, інколи немає», його потрібно проєктувати інакше, але це вже окрема історія й окремий набір інструментів.

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