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

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

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

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

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

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

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

Короткая формула: JPQL = SQL-синтаксис, но над объектной моделью, а не над таблицами.

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

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

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

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

Для часто используемых запросов есть @NamedQuery — объявляется на уровне класса и парсится при старте, а не при каждом вызове:

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

JOIN и JOIN FETCH

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

// фильтруем по городу пользователя, но user остаётся lazy
"SELECT o FROM Order o JOIN o.user u WHERE u.city = :city"

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

"SELECT o FROM Order o JOIN FETCH o.user u WHERE u.city = :city"

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

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

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

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

Constructor expression

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

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

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

Если вы работаете через Spring Data JPA, можно объявить интерфейс и репозиторий вернёт прокси:

public interface OrderSummary {
    Long getId();
    String getUserEmail(); // маппится на o.user.email через соглашение об имени
    BigDecimal getTotal();
}

Подробнее о проекциях Spring Data — в статье Spring Data JPA.

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("total"), minTotal));
}
if (city != null) {
    Join<Order, User> user = root.join("user");
    predicates.add(cb.equal(user.get("city"), city));
}

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

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

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

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

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

Иногда нужны возможности, которых нет в JPQL: оконные функции, RETURNING, INSERT ... ON CONFLICT, специфичные функции PostgreSQL. Для этого есть createNativeQuery:

List<Object[]> rows = em.createNativeQuery(
        "SELECT o.id, u.email, SUM(oi.price) " +
        "FROM orders o " +
        "JOIN users u ON o.user_id = u.id " +
        "JOIN order_items oi ON oi.order_id = o.id " +
        "WHERE o.created_at > :since " +
        "GROUP BY o.id, u.email"
).setParameter("since", since)
 .getResultList();

Hibernate выполняет SQL как есть и возвращает List<Object[]>. Вы можете замапить результат в сущность через @SqlResultSetMapping или вручную разобрать массив.

Для частых нативных запросов удобен @NamedNativeQuery — объявляется рядом с сущностью аналогично @NamedQuery.

Нативные запросы не кэшируются кэшем второго уровня по умолчанию и не участвуют в автоматическом flush — если перед нативным запросом есть незафлушенные изменения, вы можете получить устаревшие данные. Добавьте явный em.flush() или проверьте настройку FlushModeType.

Как читать план выполнения

Любой запрос — JPQL, Criteria или нативный — в итоге превращается в SQL. Чтобы понять, использует ли запрос индекс и нет ли полного сканирования таблицы, изучите план через EXPLAIN ANALYZE. Как читать вывод — разобрано в статье EXPLAIN и оптимизация запросов.

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

ЗадачаИнструмент
Фиксированный запрос по сущностямJPQL
Несколько опциональных фильтровCriteria API
Оконные функции, ON CONFLICT, специфика БД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 — выбор для динамических фильтров; многословен, но безопасен по типам.
  • Native SQL нужен для возможностей, которых нет в JPQL; flush перед ним — ручной.

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

  • Проблема N+1 и JOIN FETCH — как JPQL-запросы связаны с количеством SQL к базе.
  • Маппинг сущностей — как аннотации определяют, что именно будет в запросе.
  • Кэширование в Hibernate — что кэшируется, а что нет при разных типах запросов.
  • Spring Data JPA — репозитории, проекции и derived queries поверх JPA.