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.