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

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 на коллекции не работает — Hibernate загрузит всё и разложит страницы в памяти. Это разобрано в статье про N+1, и правило там простое: страница по идентификаторам, потом загрузка коллекций.

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 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"), которую генерирует обработчик аннотаций.

Цена Criteria

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

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

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

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

Нативные запросы — когда 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.

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
Несколько опциональных фильтровCriteria API
ON CONFLICT, RETURNING, специфика БДNative SQL
Репозитории, постраничность, проекцииSpring Data JPA (поверх JPQL/Native)

Коротко

  • 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().

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