JPA предлагает три способа спрашивать базу данных: JPQL, Criteria API и нативный SQL. Каждый решает свою задачу: выбор зависит от того, насколько запрос статичен.
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 делает то же, только узлами дерева вместо строк.
Сверху запрос живёт строкой, снизу собирается узлами: 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().
Что почитать дальше
- Проблема N+1 и JOIN FETCH — как JPQL-запросы связаны с количеством SQL к базе.
- Маппинг сущностей — как аннотации определяют, что именно будет в запросе.
- Кэширование в Hibernate — что кэшируется, а что нет при разных типах запросов.
- Spring Data JPA — репозитории, проекции и derived queries поверх JPA.