JPQL и HQL: объектный язык запросов

Hibernate deep-dive
10 уровень , 0 лекция
Открыта

1. Выбор языка запроса: уровень контроля

Мы уже увидели, что список, таблица и любая внешняя выдача не обязаны жить как managed entity-граф. Как только это стало видно, возникает следующий инженерный вопрос: чем вообще описывать такое чтение, чтобы SQL оставался предсказуемым, а код — вменяемым.

Если у вас уже есть Spring Data репозиторий, очень легко попасть в режим «лишь бы работало»: где-то findByStatus, где-то @Query, где-то «временно» native SQL, а потом всё это живёт в проекте годами и пугает новых людей сильнее, чем merge() в пятницу вечером. Сегодня мы ставим правильную цель: не написать запрос, а выбрать уровень контроля под конкретный сценарий чтения, так чтобы запрос был предсказуемым по SQL и удобным в сопровождении.

В нашем курсе запрос — это не только «как получить данные», но и часть модели поведения Hibernate. Один и тот же бизнес-сценарий может быть реализован так, что Hibernate сделает один аккуратный select, а может — так, что вы получите N+1, огромный результат с дубликатами или случайные дополнительные запросы при обходе ассоциаций. Поэтому выбор языка запроса — это почти как выбор инструмента: вы берёте молоток, когда нужен молоток, и не пытаетесь отвёрткой забивать гвозди. Хотя да, в жизни отвёрткой тоже можно… но потом стыдно показывать это на code review.

Мы будем придерживаться практического правила дня: если сценарий чтения стабилен и хорошо выражается через entity-модель, то первым кандидатом обычно остаётся JPQL / HQL. И только когда запрос начинает «расползаться» или сопротивляться объектному описанию, нужен уже другой уровень описания запроса.

Удобно держать в голове один референс: backoffice-поиск заказов с фильтрами по статусу и клиенту и узкой строкой результата. Ниже будут мелькать и Product, и PurchaseOrder, потому что на маленьких фрагментах проще разложить механику JPQL. Но проверка всё время одна и та же: можно ли этим языком честно описать чтение без лишнего managed-графа и неожиданных SQL.

JPQL: сущности и поля вместо таблиц

JPQL проще всего понять через смену словаря. В SQL вы мыслите таблицами, колонками и внешними ключами. В JPQL вы мыслите сущностями, их полями и ассоциациями. Это похоже на ситуацию, когда вы разговариваете с человеком по имени, а не по номеру паспорта: формально и то и другое идентифицирует, но один способ «роднее» для доменной модели приложения.

Из-за этого у новичков часто возникает довольно смешной (а иногда и дорогой) баг: они пишут JPQL так, будто это SQL, подставляя туда product и product.status. Hibernate смотрит на это примерно как преподаватель на экзамене смотрит на шпаргалку по другому предмету: «интересно, но нет». В JPQL вы пишете Product (entity), а не product (table), и p.status (поле Java), а не p.status_code (колонка БД).

Давайте зафиксируем это в маленькой таблице — как «переводчик» между мирами:

Что вы описываете JPQL / HQL (объектный словарь) SQL (реляционный словарь)
«Источник данных» Product p product p
«Поле/колонка» p.status, p.name p.status, p.name
«Связь» o.customer purchase_order.customer_id -> customer.id
«Join» join o.customer c join customer c on c.id = o.customer_id
«Результат» entity или DTO projection строки/колонки

В реальном проекте Commerce Persistence Lab это ощущается очень практично: модель уже описывает связи (PurchaseOrder -> Customer, PurchaseOrder -> OrderItem, Product -> ProductDetails), и JPQL позволяет писать запросы так, будто вы навигируете по объектам. При этом Hibernate всё равно в конце сделает SQL, и мы, как взрослые люди, посмотрим на этот SQL в логе.

3. HQL vs JPQL: стандарт и расширения

Когда вы работаете с Hibernate, вы почти неизбежно увидите слово HQL. Это Hibernate Query Language — язык запросов Hibernate. Важно понимать спокойную, не драматичную реальность: JPQL — это стандарт JPA, а HQL — более широкий язык Hibernate, который включает JPQL как подмножество. То есть во многих местах, где вы пишете JPQL-совместимый запрос, вы на самом деле пишете HQL, но в «режиме совместимости».

Почему тогда вообще разделять? Потому что это влияет на два инженерных качества: переносимость и «магия в запросах». Если вы придерживаетесь JPQL-подмножества, ваш запрос обычно понятен любому JPA-разработчику и с меньшим шансом сломается при миграции или смене настроек. Если вы начинаете активно использовать Hibernate-специфичные фичи HQL, вы выигрываете в выразительности, но привязываетесь к Hibernate сильнее.

В рамках этого курса мы будем мыслить так: пока запрос прозрачный в JPQL-стиле — отлично, остаёмся там. Это почти как правило «пиши просто»: не потому что «так модно», а потому что через полгода вы сами себе скажете спасибо. Hibernate и так даёт достаточно сюрпризов на уровне fetching и persistence context, чтобы не добавлять ещё и сюрпризы в язык запросов без реальной необходимости.

С другой стороны, слово HQL полезно помнить, чтобы не пугаться документации: когда вы видите «HQL supports…», часто это означает «Hibernate умеет больше, чем минимальный стандарт». Мы это будем использовать аккуратно и осознанно.

4. Базовый JPQL: фильтр и сортировка

Начать лучше с запросов, которые «не стыдно показать маме». Один entity, один фильтр, сортировка. Это та зона, где JPQL действительно сияет: запрос читается как доменная фраза, Hibernate генерирует понятный SQL, а вы не строите из чтения отдельную архитектурную диссертацию.

Предположим, в каталоге у нас есть Product со статусом (например, ACTIVE/HIDDEN). Мы хотим получить список активных товаров, отсортированный по имени. Внутри Commerce Persistence Lab такой сценарий возникает постоянно — это типичная backoffice-таблица.

Вот минимальный вариант через EntityManager:

import jakarta.persistence.EntityManager;

import com.example.commerce.catalog.entity.Product;
import com.example.commerce.catalog.entity.ProductStatus;

// JPQL лучше держать в переменной: легче читать глазами и проще править без конкатенации строк
String jpql = """
    select p
    from Product p
    where p.status = :status
    order by p.name
    """;

// Типизированный запрос: сразу получаем List<Product>, а не Object[] с магическими индексами
var query = entityManager.createQuery(jpql, Product.class);

// Именованный параметр: читаемо, безопасно, не надо экранировать кавычки вручную
List<Product> products = query
    .setParameter("status", ProductStatus.ACTIVE)
    .getResultList();

Обратите внимание на несколько вещей, которые «мелкие», но на практике спасают много нервов. Во-первых, мы используем именованный параметр :status, а не подставляем значение в строку. Это не только про безопасность (хотя и про неё тоже), но и про читаемость и привычку писать предсказуемый код. Во-вторых, запрос типизирован: мы говорим Product.class, и дальше получаем List<Product>, а не List<Object[]>, где вы потом будете вспоминать, что лежит в [0]. И да, это тот момент, когда «типизация» внезапно становится приятной, а не бюрократией.

Java text block для JPQL

В реальном коде длинные запросы лучше форматировать так, чтобы их можно было читать глазами, а не «по слогам». Java уже давно умеет text blocks, и в Java 25 это стандартная гигиена:

// Text block позволяет хранить JPQL «как есть» — без '+' и плясок с переносами строк
String jpql = """
    select p
    from Product p
    where p.status = :status
    order by p.name
    """;

Это не «красота ради красоты». Это напрямую влияет на то, насколько быстро вы поймёте запрос через неделю. Запросы — как шутки: если их приходится объяснять, значит, что-то пошло не так.

Нюанс: запрос может триггернуть flush. Даже если вы делаете select, Hibernate всё равно живёт по правилам unit of work. Если вы на той же транзакции до этого меняли managed-сущности, Hibernate может выполнить flush перед запросом, чтобы запрос увидел корректные данные. Это нормально. Ненормально — не понимать, почему перед вашим select внезапно улетели update. Мы это уже обсуждали в дне про flush, и здесь просто напоминаем: JPQL-запрос — это «серьёзное событие» для persistence context.

5. JPQL и проекции: список как DTO

Списки — главный источник «тихих» проблем в ORM. Если вы читаете список товаров как List<Product>, вы тащите managed entity в память, а вместе с ней — потенциально lazy-связи, риск лишних запросов и overhead dirty checking. Для «таблички в админке» это обычно не то, что вам нужно. Поэтому логичный следующий шаг после базового JPQL — научиться делать узкую выдачу прямо из JPQL.

В нашем проекте это идеально ложится на пакет catalog.dto и catalog.query: вы делаете маленький DTO для строки списка, и возвращаете именно его. Сам DTO можно оформить как record, чтобы он был коротким и не превращался в класс на 80 строк ради трёх полей:

package com.example.commerce.catalog.dto;

/**
 * DTO-строка для списка товаров: проекция из JPQL (не managed entity).
 */
public record ProductRow(Long id, String sku, String name) {
}

Теперь пишем JPQL с constructor expression. Да, выглядит чуть «формально», но это цена за то, что Hibernate создаст объект ProductRow, а не будет материализовать entity:

import jakarta.persistence.EntityManager;

import com.example.commerce.catalog.dto.ProductRow;
import com.example.commerce.catalog.entity.ProductStatus;

// Constructor expression в JPQL: Hibernate создаёт DTO напрямую, не трогая managed-граф сущностей
String jpql = """
    select new com.example.commerce.catalog.dto.ProductRow(p.id, p.sku, p.name)
    from Product p
    where p.status = :status
    order by p.name
    """;

var query = entityManager.createQuery(jpql, ProductRow.class);

// Результат — список DTO, а не сущностей: меньше риск случайного lazy-loading и dirty checking
List<ProductRow> rows = query
    .setParameter("status", ProductStatus.ACTIVE)
    .getResultList();

С точки зрения чтения это очень «взрослый» результат. Вы получаете ровно три поля, которые нужны списку. Эти объекты не managed, Hibernate не будет за ними следить, и вы случайно не отправите update, просто потому что кто-то в коде сделал row.setName() (а у record’а, кстати, сеттеров и нет — в этом тоже есть своя прелесть).

И здесь важно проговорить связь с прошлым днём: projection — это не «оптимизация ради оптимизации», это способ сделать read-model честным. Список — это список, а не «давайте загрузим агрегат целиком, вдруг пригодится». Обычно «вдруг пригодится» превращается в «вдруг всё стало медленно».

6. JPQL и ассоциации: join и fetching

Теперь чуть усложним, но без фанатизма. Частая задача: прочитать заказы клиента по e-mail, отсортировать по дате. В SQL вы бы писали join по внешнему ключу. В JPQL вы идёте по ассоциации PurchaseOrder.customer и пишете join через объектную модель.

Минимальный вариант выглядит так:

import jakarta.persistence.EntityManager;

import com.example.commerce.orders.entity.PurchaseOrder;

// Join в JPQL строится по ассоциации, а не по внешнему ключу вручную
String jpql = """
    select o
    from PurchaseOrder o
    join o.customer c
    where c.email = :email
    order by o.createdAt desc
    """;

var query = entityManager.createQuery(jpql, PurchaseOrder.class);

// Важно: join в условии ≠ join fetch (fetch-план управляется отдельно)
List<PurchaseOrder> orders = query
    .setParameter("email", email)
    .getResultList();

Это хороший момент, чтобы не перепутать два разных смысла слова «join». В JPQL join здесь в первую очередь означает, что вы используете связь, чтобы поставить условие (where c.email = ...). Но это ещё не обещание, что Hibernate обязательно загрузит customer «внутрь» каждого заказа в памяти так, как вы ожидаете. Если вы потом начнёте в цикле делать order.getCustomer().getFirstName() — вы снова можете упереться в lazy-loading и/или N+1, в зависимости от ваших настроек и fetch-plan.

То есть запросный язык и fetch-план — это две разные ручки управления. JPQL отвечает за форму запроса и условия, а fetching — за то, какие части графа будут материализованы без дополнительных round-trip. Мы уже изучали инструменты controlled fetching (fetch join, entity graph, batch fetching). Здесь мы фиксируем мысль: сам факт, что вы написали JPQL, не отменяет дисциплину fetch design.

Если вы в этом месте подумали «ага, значит, я просто везде добавлю join fetch и всё будет хорошо», то поздравляю: у вас хорошая память на прошлый материал про ограничения JOIN FETCH. Мы сегодня не повторяем эти ограничения, просто помним, что они существуют. И да, Hibernate — это тот самый друг, который исполняет ваши желания буквально: попросите «всё загрузить» — он загрузит так, что вы потом будете объяснять, почему один экран делает SQL размером с роман.

7. Критерии хорошего JPQL/HQL-запроса

Строковые запросы пугают новичков не потому что они «плохие», а потому что их часто пишут так, будто цель — создать максимальную боль следующему человеку. На самом деле хороший JPQL-запрос очень похож на хороший метод: короткий, логичный, без сюрпризов.

Практически полезно держать в голове несколько критериев, которые вы можете применить прямо на code review. Если запрос читается сверху вниз как нормальная фраза «выбрать то-то, откуда-то, при таких условиях, отсортировать так-то», то это хороший знак. Если запрос приходится читать как криптограмму, а половина условий собрана через конкатенацию строк в пяти if, то это почти всегда сигнал: форма запроса стала динамической, и строковый стиль начинает проигрывать. В этот момент обычно нужен уже не новый @Query, а программная сборка условий.

Ещё один критерий — форма результата. Если вы делаете список для backoffice, а возвращаете entity просто потому, что «так проще», вы почти наверняка платите лишнюю цену: шире select, больше материализация, больше риск неожиданных lazy-запросов. Если форма выдачи узкая и честная (projection), то JPQL чаще остаётся очень комфортным инструментом.

Наконец, есть «жизненный» критерий: запрос должен быть легко проверяемым по SQL trace. Вы написали JPQL — включили профиль sql-trace — посмотрели, какой SQL реально ушёл в PostgreSQL. Если SQL получился предсказуемым и не делает лишней работы, то вы на правильной стороне ORM-границы. Если SQL внезапно стал монстром, то проблема не в JPQL как таковом, а в том, что вы попросили ORM сделать слишком много «наивно». Тогда уже приходится отдельно выбирать fetch plan, projection или вообще другой уровень запроса.

Чтобы визуально закрепить общую картину, вот упрощённая схема того, что происходит, когда вы пишете строковый JPQL:

flowchart TD
    S["Service method @Transactional"] --> Q["JPQL/HQL query string"]
    Q --> EM["EntityManager.createQuery"]
    EM --> H["Hibernate query engine"]
    H --> SQL["Generated SQL"]
    SQL --> DB["(PostgreSQL)"]
    DB --> SQL
    SQL --> H
    H --> R["Result mapping Entity or DTO"]
    R --> S

Ничего магического, только много мест, где можно сделать либо красиво, либо «ой». Наша задача — всё-таки красиво.

8. Типичные ошибки при работе с JPQL/HQL

Ошибка №1: писать в JPQL имена таблиц и колонок, как в SQL.
Это самая классическая путаница. В SQL вы пишете from product, а в JPQL вы пишете from Product. В SQL вы пишете product.status_code, а в JPQLp.status. Если вы постоянно ловите ошибки вида «неизвестная сущность» или «неизвестное поле», почти всегда причина в том, что вы говорите с Hibernate на «языке базы», а он в этом месте ждёт «язык модели». Хорошая привычка — держать рядом открытым класс entity и буквально сверять: как называется поле, какая ассоциация, как она навигируется.

Ошибка №2: возвращать entity там, где нужен список-строка, и тащить лишний managed-граф.
На уровне «оно же работает» это правда работает. А потом вы удивляетесь, почему список товаров внезапно стал медленным, почему в логах много запросов, и почему где-то в середине запроса вдруг появилась загрузка ProductDetails. Для списков почти всегда полезнее проекция, потому что она делает read-model честным и обрубает лишние побочные эффекты ORM: нет managed entity — меньше шансов на accidental update и меньше причин случайно дергать lazy-связи.

Ошибка №3: склеивать JPQL строками при каждом опциональном фильтре.
Это выглядит невинно: «ну у нас же фильтр по статусу, по имени, по цене, по категории…» — и вот уже запрос собирается из десяти кусков, а в конце вы не уверены, где у вас and, где where, и почему иногда запрос синтаксически битый. Это не «плохой разработчик», это просто сигнал: ваш запрос стал динамическим. В этот момент обычно надо не героически страдать со строками, а переключиться на программную сборку условий через более подходящий инструмент.

Ошибка №4: подставлять значения прямо в строку запроса вместо параметров.
Иногда так делают «для скорости»: "where p.sku = '" + sku + "'". Во-первых, это путь к SQL/JPQL injection (да, JPQL тоже можно «сломать» входом). Во-вторых, это резко ухудшает читаемость и повторное использование запроса. В-третьих, вы начинаете ловить странные баги с кавычками, экранированием и пробелами — и всё это ради того, чтобы не написать :sku и setParameter("sku", sku).

Ошибка №5: забыть, что чтение entity — это ещё и вопрос transaction boundary и lazy loading.
Запрос может выполниться успешно и вернуть список PurchaseOrder, но если вы вынесли эти объекты за пределы транзакции и потом где-то «снаружи» пошли по lazy-связям (order.getItems().size()), вы снова получите старого знакомого LazyInitializationException. Это не значит «JPQL плохой». Это значит, что вы выбрали entity-loading, но не спроектировали fetch-plan под use case и не удержали чтение внутри правильной транзакции. В большинстве backoffice-сценариев это лечится либо правильным fetch-инструментом, либо переходом на projection, где вы вообще не открываете дверь в lazy-граф.

1
Задача
Hibernate deep-dive, 10 уровень, 0 лекция
Недоступна
Список товаров по статусу через JPQL
Список товаров по статусу через JPQL
1
Задача
Hibernate deep-dive, 10 уровень, 0 лекция
Недоступна
Узкая выдача заказов клиента через JPQL-проекцию
Узкая выдача заказов клиента через JPQL-проекцию
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ