← назад к разделу

HIBERNATE Фаза 11. JPQL и Criteria

Обязательно

JPQL, Criteria API и нативные запросы

Java

Параметры, JOIN FETCH, проекции, пагинация, Criteria и пользовательская сортировка.

1. JPQL говорит с сущностями, SQL - с таблицами

1 / 5

JPQL работает с классами и полями, поэтому запрос переживает маппинг и диалект. Native query нужен для специфики базы.

  • Параметры передаются по имени или позиции.
  • Строки условий не собираются конкатенацией.
  • Полиморфный запрос идёт по иерархии.
JPQL: Order SQL: orders where status = :status

2. JOIN FETCH загружает связь вместе с запросом

2 / 5

Обычный JOIN только соединяет, FETCH ещё и заполняет коллекцию. Проекция выбирает поля вместо всей сущности.

  • fetch меняет граф результата.
  • Проекция возвращает DTO или Object[].
  • Обертка исключений остаётся отдельно.
JOIN FETCH Order + items select new OrderDto(...)

3. Пагинация - отдельный запрос на содержимое и count

3 / 5

setFirstResult и setMaxResults ограничивают страницу. Общее число считается отдельно.

  • Не загружайте всю базу для подсчёта.
  • Сортировка стабильна по уникальному ключу.
  • Page и Slice отличаются ценой count.
LIMIT 20 COUNT(*) страница всего

4. Criteria собирает динамический запрос типобезопасно

4 / 5

Criteria API добавляет условия из параметров без строковых фрагментов JPQL. Статические части проще оставить JPQL.

  • Предикаты строятся через CriteriaBuilder.
  • Код длиннее и требует дисциплины.
  • Native query нужен для функций PostgreSQL.
CriteriaBuilder status = :s createdAt > :d Условия добавляются только при наличии параметров

5. Пользовательская сортировка идёт по белому списку

5 / 5

Параметр ORDER BY нельзя параметризовать как значение. Название поля выбирается только из разрешённых.

  • Никакого order by ${input}.
  • Свойство сопоставляется с безопасным JPQL-полем.
  • Цена Criteria растёт с динамикой.
sort = input whitelist total, createdAt - разрешено

JPA предлагает три способа спрашивать базу данных: JPQL, Criteria API и нативный SQL. Каждый решает свою задачу: выбор зависит от того, насколько запрос статичен.

три способа спросить — одна дорога в базу JPQL Order o join o.customer Criteria API root.join("customer") Native SQL from orders join customer Hibernate Order → orders o.customer → customer имена — из маппинга SQL в базу select … from orders o JPQLOrder o join o.customer Criteria APIroot.join("customer") HibernateOrder → orderso.customer → customerимена — из маппингапереименовали поле — правка в маппинге, не в запросах Native SQLfrom orders join customerмимо маппинга: имена таблиц — вашиперед запуском — flush всего контекста SQL в базуselect … from orders o

JPQL и Criteria не уходят в базу как есть: Hibernate переводит имена сущностей и полей в имена таблиц и колонок. Поэтому переименование поля правится в маппинге, а запросы остаются прежними. Нативный SQL идёт мимо этого перевода — имена таблиц в нём ваши, и перед выполнением Hibernate сбрасывает в базу весь накопленный контекст персистентности.

Зачем нужен JPQL, если есть SQL

Без ORM вы пишете SQL напрямую к таблицам: SELECT * FROM orders o JOIN customers c ON c.id = o.customer_id. Это работает, но привязывает логику к схеме: переименовали колонку — правите запросы по всему коду.

JPQL (Java Persistence Query Language) работает не с таблицами, а с сущностями и их полями. Запрос выглядит так:

String jpql = "SELECT o FROM Order o JOIN o.customer c WHERE c.email = :email";
List<Order> orders = em.createQuery(jpql, Order.class)
        .setParameter("email", "buyer@example.com")
        .getResultList();

Здесь Order — Java-класс с аннотацией @Entity, o.customer — поле типа Customer, а не внешний ключ. Если переименуете поле или колонку через маппинг, запрос изменится в одном месте — в аннотации.

Параметры и безопасность

Никогда не вставляйте значения в строку запроса конкатенацией — это SQL-инъекция. Всегда используйте именованные параметры:

TypedQuery<Order> query = em.createQuery(
        "SELECT o FROM Order o WHERE o.status = :status AND o.totalAmount > :min",
        Order.class
);
query.setParameter("status", OrderStatus.PAID);
query.setParameter("min", BigDecimal.valueOf(1000));
List<Order> result = query.getResultList();

TypedQuery<T> — типизированная версия Query, возвращает List<T> без приведения типов.

Для часто используемых запросов есть @NamedQuery — он объявляется на уровне класса и разбирается при старте приложения. Выигрыш тут не в скорости: разобранный план обычного createQuery Hibernate тоже держит в кэше и заново каждый раз не парсит. Выигрыш в том, что опечатка в тексте запроса роняет приложение на старте, а не на первом же вызове в проде:

@Entity
@NamedQuery(
    name = "Order.findByStatus",
    query = "SELECT o FROM Order o WHERE o.status = :status"
)
public class Order { ... }
List<Order> paid = em.createNamedQuery("Order.findByStatus", Order.class)
        .setParameter("status", OrderStatus.PAID)
        .getResultList();

JOIN и JOIN FETCH

Обычный JOIN в JPQL фильтрует результат, но не загружает связанные сущности — они останутся ленивыми прокси:

// фильтруем по статусу покупателя, но customer остаётся lazy
"SELECT o FROM Order o JOIN o.customer c WHERE c.status = :status"

JOIN FETCH говорит Hibernate: загрузи связанную сущность прямо сейчас, в том же SQL-запросе:

"SELECT o FROM Order o JOIN FETCH o.customer c WHERE c.status = :status"

Это главный инструмент борьбы с проблемой N+1 — подробнее в статье про N+1.

Важное ограничение: нельзя сочетать JOIN FETCH коллекции с setMaxResults() — Hibernate предупредит в логах и выгрузит всё в память для ручного ограничения. Если нужны и постраничность, и загрузка коллекций, используйте два запроса или @BatchSize.

Проекции: получать не всю сущность

Иногда из базы нужно несколько колонок, а грузить весь граф сущности расточительно. Подходов два.

Constructor expression

public record OrderSummary(Long id, String customerEmail, BigDecimal total) {}
List<OrderSummary> summaries = em.createQuery(
        "SELECT new com.example.OrderSummary(o.id, c.email, o.totalAmount) " +
        "FROM Order o JOIN o.customer c WHERE o.status = :status",
        OrderSummary.class
).setParameter("status", OrderStatus.PAID).getResultList();

Hibernate вызывает конструктор для каждой строки. Результат — список DTO, не управляемых persistence context.

Интерфейс-проекция (Spring Data)

Через Spring Data JPA можно объявить интерфейс — репозиторий вернёт прокси:

public interface OrderSummary {
    Long getId();
    BigDecimal getTotalAmount();
    CustomerView getCustomer(); // вложенная проекция, а не getCustomerEmail()

    interface CustomerView {
        String getEmail();
    }
}

Плоский геттер по связи — getCustomerEmail() — сработает не всегда, и это зависит от того, как получен результат. У производного запроса (findByStatus) Spring Data разбирает имя так же, как имена методов репозитория: не найдя свойства customerEmail, он режет его по заглавным буквам и приходит к пути customer.email. А вот если у метода свой @Query, возвращающий сущности, геттер приложат к загруженному Order напрямую — свойства customerEmail там нет, и будет ошибка. Полагаться на разбор имени не стоит: надёжнее вложенный интерфейс, как выше, или выражение @Value("#{target.customer.email}"). Подробнее — в статье Spring Data JPA.

Как забрать результат

Три метода, о которые спотыкаются в первый же день, потому что ведут себя по-разному при отсутствии данных.

getResultList() возвращает пустой список — не null, и проверять на null его не нужно. getSingleResult() бросает NoResultException, если не нашлось ничего, и NonUniqueResultException, если нашлось больше одного; оба — непроверяемые исключения, и оба легко приезжают в прод. Поэтому для «найти одну строку или ничего» берут getResultStream().findFirst() или, в JPA 3.2 и Hibernate 6.5+, getSingleResultOrNull().

Optional<Customer> found = em.createQuery("select c from Customer c where c.email = :email", Customer.class)
        .setParameter("email", email)
        .getResultStream()
        .findFirst();

getResultStream() полезен и сам по себе: он позволяет обрабатывать выдачу по мере поступления, не собирая её в список целиком. Но помнить надо две вещи — поток надо закрывать (в блоке try-with-resources), а транзакция должна быть открыта всё время обхода. В Spring Data то же самое делают методы репозитория, возвращающие Stream<T>, и они требуют @Transactional на вызывающем методе.

Пагинация: два запроса, а не один

Постраничный вывод в JPQL — это setFirstResult и setMaxResults:

List<Order> page = em.createQuery("select o from Order o order by o.createdAt desc, o.id desc", Order.class)
        .setFirstResult(pageNumber * pageSize)
        .setMaxResults(pageSize)
        .getResultList();

Три вещи, которые к этому прилагаются. Сортировка обязательна и должна быть уникальной: без ORDER BY страницы бессмысленны, а при сортировке по неуникальному полю строки перескакивают между страницами — отсюда второй ключ o.id в примере. Общее количество — отдельный запрос: select count(o) from Order o с тем же условием (в Spring Data это countQuery у @Query, и он нужен именно потому, что автоматически выведенный count-запрос ломается на сложных выборках). И LIMIT не спасает от сортировки: чтобы отдать двадцать строк, база отсортирует всю выборку, если нет подходящего индекса — разбор в статье про индексы и сортировку.

Отдельно: JOIN FETCH на коллекции с постраничностью не сочетается; правило из статьи про N+1 простое: страница по идентификаторам, потом загрузка коллекций.

Criteria API — динамические запросы

JPQL — строка. Собирать строку с условиями через if-ветки неудобно и опасно. Criteria API строит запрос программно:

CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Order> cq = cb.createQuery(Order.class);
Root<Order> root = cq.from(Order.class);

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

if (status != null) {
    predicates.add(cb.equal(root.get("status"), status));
}
if (minTotal != null) {
    predicates.add(cb.greaterThanOrEqualTo(root.get("totalAmount"), minTotal));
}
if (email != null) {
    Join<Order, Customer> customer = root.join("customer");
    predicates.add(cb.equal(customer.get("email"), email));
}

cq.where(predicates.toArray(new Predicate[0]));
cq.orderBy(cb.desc(root.get("createdAt")));

List<Order> result = em.createQuery(cq).getResultList();

Условия копятся списком, склеиваются через AND, значения уходят отдельно. Тот же скелет на голой Java:

живой пример

import java.util.ArrayList;
import java.util.List;

public class DynamicWhere {
    static void show(String status, Integer minTotal, String email) {
        List<String> where = new ArrayList<>();
        List<Object> params = new ArrayList<>();
        if (status != null) { where.add("o.status = ?"); params.add(status); }
        if (minTotal != null) { where.add("o.total_amount >= ?"); params.add(minTotal); }
        if (email != null) { where.add("c.email = ?"); params.add(email); }
        String sql = "select o.* from orders o join customers c on c.id = o.customer_id";
        System.out.println(where.isEmpty() ? sql : sql + " where " + String.join(" and ", where));
        System.out.println("  параметры: " + params);
    }

    public static void main(String[] args) {
        show(null, null, null);
        show("PAID", null, null);
        show("PAID", 1000, "buyer@example.com");
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Три набора фильтров — три разных запроса, и значения ни в одном не попали внутрь текста. Criteria API делает то же, только узлами дерева вместо строк.

JPQL текст запроса разбор строки SQL Criteria Root<Order> root.join Predicate cq.where SQL

Сверху запрос живёт строкой, снизу собирается узлами: Root даёт корень, join добавляет связь, каждый Predicate это одно условие, cq.where складывает их через AND, и на выходе получается тот же SQL.

Он гарантирует синтаксически корректный запрос и безопасен по типам — если вместо строки "status" использовать метамодель: она генерируется из @Entity-классов через JPA Annotation Processor и даёт доступ вида Order_.status:

cb.equal(root.get(Order_.status), OrderStatus.PAID);   // опечатка в имени поля не скомпилируется

Недостаток — многословность: для фиксированных запросов JPQL читается лучше, Criteria API оправдывает себя при трёх и более опциональных фильтрах.

Criteria в Spring: Specification

В приложении на Spring голый CriteriaBuilder встречается редко: то же самое делается через Specification — обёртку, которая умеет складываться из частей и подставляться в обычный метод репозитория.

public interface OrderRepository extends JpaRepository<Order, Long>, JpaSpecificationExecutor<Order> { }

public final class OrderSpecs {
    public static Specification<Order> status(OrderStatus status) {
        return (root, query, cb) -> cb.equal(root.get("status"), status);
    }

    public static Specification<Order> createdAfter(Instant from) {
        return (root, query, cb) -> cb.greaterThanOrEqualTo(root.get("createdAt"), from);
    }
}

Specification<Order> spec = Specification.where(null);
if (status != null)  spec = spec.and(OrderSpecs.status(status));
if (from != null)    spec = spec.and(OrderSpecs.createdAfter(from));

Page<Order> page = orders.findAll(spec, PageRequest.of(0, 20, Sort.by("createdAt").descending()));

Выигрыш не только в краткости: JpaSpecificationExecutor сам делает count-запрос для страницы, а условия остаются проверяемыми компилятором на уровне типов значений. Имена полей всё ещё строки — от опечатки в них спасает метамодель (Order_.status вместо "status").

Нативные запросы — когда SQL неизбежен

Нужно вставить строку и сразу получить её сгенерированные поля или вставить «если нет, иначе обновить» одной командой: в JPQL таких конструкций нет, это RETURNING и INSERT ... ON CONFLICT самого PostgreSQL, как и его специфичные функции. Оконные функции, кстати, в этот список больше не входят — начиная с Hibernate 6 их понимает и HQL.

Сам запрос — обычный SQL по таблицам, не по сущностям:

SELECT o.id, c.email, sum(oi.unit_price * oi.quantity) AS items_total
FROM orders o
JOIN customers c ON c.id = o.customer_id
JOIN order_items oi ON oi.order_id = o.id
WHERE o.created_at > '2024-01-01'
GROUP BY o.id, c.email

В Hibernate он попадает через createNativeQuery:

List<Tuple> rows = em.unwrap(Session.class)
        .createNativeQuery(sql, Tuple.class)
        .getResultList();

for (Tuple row : rows) {
    Long orderId = row.get("id", Long.class);
    BigDecimal total = row.get("items_total", BigDecimal.class);
}

Короткая форма em.createNativeQuery(sql) тоже работает, но отдаёт строки массивами Object[] — и отдаёт их нетипизированным List, без параметра типа. Присвоить такой результат в List<Object[]> компилятор позволит только с предупреждением об unchecked, а дальше каждое поле придётся доставать по номеру и приводить руками. Поэтому либо Tuple, как выше, либо разложить строки в сущность или DTO через @SqlResultSetMapping. Для частых запросов есть @NamedNativeQuery, аналог @NamedQuery.

Неожиданность не в результате, а в том, что случится перед запросом. Чужой SQL Hibernate не разбирает и не знает, каких таблиц тот касается, поэтому перед каждым нативным запросом сбрасывает в базу весь контекст персистентности. Сузить это можно, назвав затронутые таблицы, — но у стандартного jakarta.persistence.Query такого метода нет, он живёт на хибернейтовском NativeQuery:

em.createNativeQuery(sql)
        .unwrap(NativeQuery.class)
        .addSynchronizedEntityClass(Order.class);

Обратная сторона есть и у нативного SQL, и у JPQL: массовые UPDATE и DELETE идут мимо контекста персистентности одинаково. Уже загруженные сущности о таком запросе не узнают и останутся со старыми значениями — после него нужен em.clear().

Как это выглядит в репозитории, а не в EntityManager — отдельный вопрос, потому что там своя оговорка:

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query(value = """
        select o.id, o.status, sum(l.quantity * l.unit_price) as amount
        from orders o join order_lines l on l.order_id = o.id
        where o.seller_id = :sellerId
        group by o.id, o.status
        """,
        countQuery = "select count(*) from orders o where o.seller_id = :sellerId",
        nativeQuery = true)
    Page<OrderRow> findRows(@Param("sellerId") long sellerId, Pageable pageable);
}

countQuery здесь обязателен: для нативного запроса Spring Data не умеет вывести count-запрос сам и на попытке страницы выдаст ошибку. Второе: сортировка из Pageable для нативного запроса подставляется как текст, и Sort с именем поля от пользователя — та же дыра, что описана выше; белый список нужен и здесь. Третье: результат приезжает не сущностями, а строками — либо интерфейсной проекцией с методами getId(), getStatus(), либо через @SqlResultSetMapping для сложных случаев.

Любой из трёх запросов превращается в SQL и тормозит уже по причинам базы, а не ORM: смотреть надо план через EXPLAIN ANALYZE.

Когда что выбирать

ЗадачаИнструмент
Фиксированный запрос по сущностямJPQL
Несколько опциональных фильтровCriteria API
ON CONFLICT, RETURNING, специфика БДNative SQL
Репозитории, постраничность, проекцииSpring Data JPA (поверх JPQL/Native)
Дополнительно: при первом чтении можно пропустить

Глубже: ORDER BY из пользовательского ввода: единственная реальная дырарасширенное

Параметры закрывают значения, и этого достаточно почти всегда. Но есть место, куда параметр не поставить: имя поля и направление сортировки. Запрос order by :field не работает — JPQL не позволяет параметризовать структуру запроса, — и разработчик, которому нужна сортировка «по любой колонке из таблицы», пишет склейку строк:

// так не надо: sortField приходит из запроса
var query = em.createQuery("select o from Order o order by o." + sortField + " " + direction, Order.class);

Это и есть настоящая инъекция в JPQL. Она ограничена синтаксисом языка (подзапрос с чужой таблицей туда не вставить так легко, как в нативный SQL), но утечь через неё можно: сортировкой по полю из связанной сущности, обращением к полю, которого не должно быть в выдаче, или просто поломкой запроса до ошибки, раскрывающей структуру.

Лечение одно и простое — белый список:

private static final Map<String, String> SORTABLE = Map.of(
        "createdAt", "o.createdAt",
        "total", "o.totalAmount",
        "status", "o.status");

String field = SORTABLE.getOrDefault(sortField, "o.createdAt");
String dir = "desc".equalsIgnoreCase(direction) ? "desc" : "asc";

В Spring Data с Sort и Pageable проверка нужна ровно та же: Sort.by(request.getParameter("sort")) так же подставляет имя поля в запрос, и белый список обязателен. Criteria API от этой дыры защищает лучше — там поле выбирается методом root.get(...), и неизвестное имя даёт исключение до обращения к базе, а не запрос с чужим текстом.

Глубже: цена Criteriaрасширенное

За динамичность платят двумя вещами.

Первая — сборка. Дерево критериев строится заново на каждый вызов: создаются объекты условий, обходится метамодель, генерируется текст запроса. На запрос это доли миллисекунды, но в горячем пути с тысячами вызовов в секунду разница с готовым текстом JPQL становится видна в профиле.

Вторая, важнее: кэш планов запросов мимо. Hibernate хранит разобранные запросы по их тексту, и когда текст один и тот же (обычный JPQL с параметрами), разбор происходит один раз. Динамический запрос каждый раз получает другой текст — в зависимости от того, какие фильтры пришли, — и кэш не помогает. У базы та же история со своим кэшем планов.

Практический вывод: Criteria и Specification берут там, где фильтров действительно много и их комбинации непредсказуемы (поиск с десятком необязательных полей). Для двух-трёх вариантов дешевле и понятнее написать два-три обычных запроса, а не собирать один из кусков.

Глубже: HQL, JPQL и полиморфные запросырасширенное

Про имена: JPQL — это язык из стандарта JPA, HQL — язык Hibernate, который его включает и добавляет своё. Всё, что написано выше, — JPQL, и работает в любой реализации. Сверх него HQL умеет, например, оконные функции, limit/offset прямо в тексте запроса, приведение типов и вызовы функций базы. Цена очевидная: запрос на HQL привязывает вас к Hibernate. Практически это редко проблема, но знать, где кончается стандарт, полезно — особенно когда чужой пример «не компилируется в JPA».

Отдельная возможность, за которой сюда отправляет статья про наследование, — запросы по типу. Выборка по базовому классу возвращает все подтипы; отфильтровать по конкретному позволяет TYPE:

// только карточные платежи
List<Payment> cards = em.createQuery(
        "select p from Payment p where type(p) = CardPayment", Payment.class).getResultList();

// поле подтипа в условии: сначала приведение через treat
List<Payment> visa = em.createQuery("""
        select p from Payment p
        where treat(p as CardPayment).cardNetwork = :network
        """, Payment.class).setParameter("network", "VISA").getResultList();

type(p) отвечает «какого класса эта строка» — в SINGLE_TABLE это условие по колонке-дискриминатору, в JOINED база определяет тип по тому, в какой дочерней таблице нашлась строка. treat нужен, когда в условии или в выборке участвует поле, которого нет в базовом классе: без него запрос не скомпилируется, потому что у Payment нет cardNetwork.

Полезная оговорка про план: в SINGLE_TABLE запрос по одному подтипу — это обычное условие по дискриминатору, и для него нужен индекс (часто частичный). В JOINED выборка по базовому классу превращается в левое соединение со всеми дочерними таблицами, и на десятке подтипов это заметно.

Коротко

  • JPQL работает над сущностями, а не таблицами — переименование поля меняется в одном месте.
  • TypedQuery<T> исключает приведение типов; @NamedQuery разбирается при старте, поэтому опечатка в запросе валит приложение сразу, а не в проде.
  • JOIN FETCH загружает связанные сущности в одном SQL — основной способ избежать N+1.
  • Constructor expression (new ClassName(...)) возвращает DTO, не управляемые persistence context.
  • Criteria API — выбор для динамических фильтров; многословен, но безопасен по типам.
  • Перед нативным запросом Hibernate сбрасывает в базу весь контекст, а массовые UPDATE и DELETE — хоть нативные, хоть на JPQL — проходят мимо контекста: после них нужен em.clear().
  • getSingleResult() бросает NoResultException, пустой список не бывает null, а «одна или ничего» — это getResultStream().findFirst().
  • Пагинация — setFirstResult/setMaxResults плюс уникальная сортировка и отдельный count-запрос (countQuery для нативного обязателен).
  • Имя поля сортировки из запроса пользователя параметром не закрыть — нужен белый список; это единственная реальная инъекция в JPQL.
  • Criteria в Spring — это Specification, и платит она сборкой дерева и промахом по кэшу планов; HQL шире JPQL, а по подтипам фильтруют через type() и treat().

Что почитать дальше