💾 Розділ 16 · Питання #27

Що таке Criteria API і коли його використовувати

На відміну від рядкового JPQL ("SELECT u FROM User u WHERE u.age > 18"), у якому одрук в імені поля виявляється лише в Runtime при зверненні до БД, Criteria API компілюється ком...

JPA Criteria API — це програмний, типобезпечний (Type-Safe) API для динамічного конструювання об’єктних запитів до бази даних. Замість написання статичних рядкових запитів JPQL розробник збирає дерево абстрактного синтаксису запиту за допомогою Java-методів і класів. Головна сфера застосування Criteria API — динамічний пошук із безліччю опціональних фільтрів (Search Forms / Filters), де кількість умов WHERE формується на льоту під час виконання програми.


🟢 Junior Level

Що таке Criteria API та базові компоненти

На відміну від рядкового JPQL ("SELECT u FROM User u WHERE u.age > 18"), у якому одрук в імені поля виявляється лише в Runtime при зверненні до БД, Criteria API компілюється компілятором Java:

// 1. CriteriaBuilder — фабрика виразів, предикатів та функцій
CriteriaBuilder cb = entityManager.getCriteriaBuilder();

// 2. CriteriaQuery<T> — структура запиту з типізованим результатом
CriteriaQuery<User> cq = cb.createQuery(User.class);

// 3. Root<T> — коренева сутність (аналог FROM User u в SQL)
Root<User> user = cq.from(User.class);

// 4. Збирання умов WHERE та SELECT
cq.select(user)
  .where(cb.greaterThan(user.get("age"), 18));

// 5. Виконання запиту
List<User> adults = entityManager.createQuery(cq).getResultList();

Основні компоненти Criteria API

  • CriteriaBuilder: Центральна фабрика. Створює предикати (cb.equal(), cb.like(), cb.between()), агрегатні функції (cb.count(), cb.max()) і сам об’єкт запиту.
  • CriteriaQuery<T>: Каркас майбутнього запиту. Визначає тип результату (select), секцію where, сортування (orderBy) та групування (groupBy).
  • Root<T>: Точка відліку запиту. Представляє скановану таблицю сутності.
  • Predicate: Умова фільтрації (логічний вираз true/false), що підставляється в секцію WHERE.

🟡 Middle Level

Динамічна фільтрація з опціональними параметрами

Головна перевага Criteria API — зручність додавання умов залежно від переданих користувачем фільтрів:

public List<User> searchUsers(String name, Integer minAge, String status) {
    CriteriaBuilder cb = entityManager.getCriteriaBuilder();
    CriteriaQuery<User> cq = cb.createQuery(User.class);
    Root<User> root = cq.from(User.class);

    List<Predicate> predicates = new ArrayList<>();

    if (name != null && !name.isBlank()) {
        predicates.add(cb.like(cb.lower(root.get("name")), "%" + name.toLowerCase() + "%"));
    }
    if (minAge != null) {
        predicates.add(cb.greaterThanOrEqualTo(root.get("age"), minAge));
    }
    if (status != null) {
        predicates.add(cb.equal(root.get("status"), status));
    }

    // Об'єднуємо предикати через логічне AND:
    cq.where(predicates.toArray(new Predicate[0]));
    cq.orderBy(cb.asc(root.get("name")));

    return entityManager.createQuery(cq).getResultList();
}

Канонічна типобезпечність: JPA Metamodel (User_)

Виклик root.get("name") спирається на рядковий літерал і не захищає від помилок у написанні поля.

Для досягнення 100% Type-Safety підключається процесор анотацій hibernate-jpamodelgen, який на етапі компіляції генерує канонічні метамоделі:

<dependency>
    <groupId>org.hibernate.orm</groupId>
    <artifactId>hibernate-jpamodelgen</artifactId>
    <scope>provided</scope>
</dependency>

Згенерований клас метамоделі:

@StaticMetamodel(User.class)
public abstract class User_ {
    public static volatile SingularAttribute<User, Long> id;
    public static volatile SingularAttribute<User, String> name;
    public static volatile SingularAttribute<User, Integer> age;
}

Тепер звернення до полів валідується компілятором:

// Одрук в імені поля User_.agx викличе помилку компіляції!
predicates.add(cb.greaterThanOrEqualTo(root.get(User_.age), minAge));

🔴 Senior Level

Патерн Specification у Spring Data JPA

У реальних Enterprise-застосунках чистий Criteria API є занадто багатослівним. Spring Data JPA інкапсулює його в елегантний функціональний інтерфейс Specification<T>:

public interface UserRepository extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> {
}

Створення перевикористовуваних атомарних специфікацій:

public final class UserSpecs {

    public static Specification<User> hasName(String name) {
        return (root, query, cb) -> 
            name == null ? null : cb.like(cb.lower(root.get(User_.name)), "%" + name.toLowerCase() + "%");
    }

    public static Specification<User> isAdult() {
        return (root, query, cb) -> cb.greaterThanOrEqualTo(root.get(User_.age), 18);
    }

    public static Specification<User> hasRole(String roleName) {
        return (root, query, cb) -> {
            Join<User, Role> roleJoin = root.join(User_.roles);
            return cb.equal(roleJoin.get(Role_.name), roleName);
        };
    }
}

Композиція фільтрів у контролері/сервісі:

Specification<User> spec = Specification.where(UserSpecs.isAdult())
    .and(UserSpecs.hasName(request.getName()));

// Автоматично враховує пагінацію і генерує оптимальний SQL:
Page<User> result = userRepository.findAll(spec, pageable);

Пастка Fetch Join у Spring Data JPA Page<T>

Критична production-помилка при використанні Specification із пагінацією:

public static Specification<Order> fetchItems() {
    return (root, query, cb) -> {
        // ❌ ПАДІННЯ В RUNTIME ПРИ ПАГІНАЦІЇ!
        root.fetch(Order_.items, JoinType.LEFT);
        return null;
    };
}

Чому це падає:

  1. Метод findAll(Specification, Pageable) виконує два запити:
    • Запит даних: SELECT o FROM Order o ...
    • Запит загальної кількості: SELECT COUNT(o) FROM Order o ...
  2. Специфікація JPA прямо забороняє наявність FETCH JOIN у COUNT-запитах (сенс підрахунку рядків ламається при джойні колекцій). Hibernate викидає помилку: org.hibernate.query.SemanticException: query specified join fetching, but the query result type is not the interface of the entity.

Senior-рішення: Перевіряти тип результату запиту перед додаванням fetch:

public static Specification<Order> safeFetchItems() {
    return (root, query, cb) -> {
        // Додаємо FETCH тільки якщо це НЕ COUNT-запит!
        Class<?> resultType = query.getResultType();
        if (resultType != Long.class && resultType != long.class) {
            root.fetch(Order_.items, JoinType.LEFT);
        }
        return null;
    };
}

4 Tricky Questions

1. У чому різниця між викликами root.join() та root.fetch() у Criteria API?

Відповідь:

  1. root.join("items"): Виконує SQL JOIN для можливості фільтрації за дочірніми колонками (where(cb.equal(join.get("price"), 100))). Дочірні об’єкти items не матеріалізуються в пам’яті й залишаються неініціалізованими лінивими проксі (PersistentBag). При зверненні до колекції виникне проблема $N+1$.
  2. root.fetch("items"): Виконує SQL JOIN FETCH, за один запит завантажуючи в пам’ять та ініціалізуючи дочірню колекцію. При цьому повернений Fetch<X, Y> інтерфейс обмежений: від нього не можна будувати подальші предикати фільтрації в JPA 2.1 (у JPA 2.2 / Hibernate 6 Fetch дозволяє подальші джойни).

2. Як у Criteria API побудувати DTO-проєкцію без використання CriteriaQuery<Tuple>?

Відповідь:

  1. Використовується фабричний метод cb.construct():
    CriteriaQuery<UserDto> cq = cb.createQuery(UserDto.class);
    Root<User> root = cq.from(User.class);
    
    cq.select(cb.construct(
        UserDto.class,
        root.get(User_.id),
        root.get(User_.name),
        root.get(User_.email)
    ));
    
  2. Клас UserDto зобов’язаний мати публічний конструктор із аргументами, що точно збігаються за типами та порядком.
  3. Цей підхід формує в СУБД прямий запит тільки вибраних колонок без матеріалізації сутностей у PersistenceContext.

3. Чому Criteria API вважається менш пріоритетним інструментом порівняно з QueryDSL або jOOQ?

Відповідь:

  1. Багатослівність (Boilerplate): Базовий запит на Criteria API вимагає в 3–4 рази більше рядків коду, ніж еквівалент на JPQL або QueryDSL.
  2. Погана читабельність: Структура SQL-запиту губиться за нагромадженням CriteriaBuilder, Root, Path та приведень типів.
  3. Відсутність підтримки частини синтаксису СУБД: На відміну від jOOQ, Criteria API обмежений специфікацією JPA і не вміє працювати зі складними віконними функціями, рекурсивними CTE та специфічними функціями баз даних без громіздких обгорток cb.function().

4. Що станеться, якщо в cq.where() передати порожній масив предикатів predicates.toArray(new Predicate[0])?

Відповідь:

  1. Якщо список предикатів порожній, виклик cq.where(new Predicate[0]) еквівалентний відсутності секції WHERE.
  2. Hibernate згенерує SQL-запит SELECT * FROM table без будь-яких умов фільтрації.
  3. Помилки чи винятку не відбудеться, запит поверне всі рядки таблиці.

🎯 Шпаргалка для інтерв’ю

30-секундна відповідь (Elevator Pitch)

«JPA Criteria API — це програмний типобезпечний спосіб побудови об’єктних запитів. Його головне призначення — динамічні запити з опціональними фільтрами, де статичний JPQL перетворився б на громіздку конкатенацію рядків. У зв’язці з hibernate-jpamodelgen він забезпечує повну перевірку типів під час компіляції через статичні метамоделі (User_). У Spring Data JPA Criteria API загортається в зручний патерн Specification. Для статичних запитів використовувати Criteria API не рекомендується через надлишкову багатослівність; кращою альтернативою для складних динамічних сценаріїв слугують QueryDSL або jOOQ».

Ключові метрики для Production-моніторингу

  • Тривалість фази побудови CriteriaQuery: складні графи предикатів можуть споживати помітні мілісекунди CPU при високій частоті запитів (RPS).
  • Дублювання запитів при пагінації: моніторинг часу генерації COUNT-запитів при використанні JpaSpecificationExecutor.

Червоні прапорці (Чого говорити НЕ МОЖНА)

  • ❌ «Я пишу всі запити в проєкті виключно на Criteria API для типобезпечності» — Надлишковий оверхед і погіршення читабельності коду; для статики краще JPQL/HQL.
  • ❌ «Criteria API працює напряму з таблицями БД, як SQL» — Criteria API оперує метамоделлю JPA-сутностей.
  • ❌ «Використовую рядкові імена полів root.get(“username”) у Criteria без метамоделі» — Втрачається головна перевага — типобезпечність на етапі компіляції.
  • ❌ «Ставлю root.fetch() у Specification без перевірки типу запиту при пагінації» — Зламає автоматичний COUNT-запит Spring Data.

Пов’язані теми