1. Когда JPQL ломается на динамике
JPQL хорош, пока форма запроса стабильна и читается одной фразой. Но у обычного backoffice-поиска where редко живёт так спокойно: сегодня нужен фильтр по статусу заказа, завтра по email клиента, послезавтра — по диапазону дат. И вот тут строка быстро перестаёт быть удобной.
Есть такой невинный момент в жизни разработчика: сначала у нас один фильтр, потом второй, потом «а давайте ещё поиск по префиксу, но только если пользователь ввёл», потом «а ещё сортировку можно выбрать», а потом внезапно у вас 25 строк склейки строк, три уровня if, и где-то между ними прячется пробел, который вы забыли. Если повезёт — будет исключение. Если не повезёт — запрос будет работать, но не так, как вы ожидаете, и вы будете час смотреть в SQL‑лог как в гороскоп.
В нашем Commerce Persistence Lab это выглядит очень жизненно на поиске для backoffice. Например, мы хотим читать список заказов по разным необязательным параметрам: статус заказа может быть задан или нет, email клиента может быть задан или нет, диапазон дат может быть задан или нет. На JPQL это можно написать, но когда начинается динамика, вы либо делаете «магическую» строковую склейку, либо начинаете писать запрос с (:param is null or ...) на каждый параметр. Оба подхода работают, но оба быстро становятся неудобными: первый хрупкий, второй — превращает where в ковёр из условий.
Это как раз тот тип чтения, где форма результата может оставаться узкой, а меняться начинает именно where.
Критерий, когда пора доставать Criteria API, звучит просто: если проблема не в том, что JPQL “не умеет”, а в том, что форма where постоянно меняется — Criteria становится спокойнее для мозга. Он не делает SQL лучше автоматически, но делает сборку запроса менее ломкой, чем «конструктор строк на коленке».
Criteria API как конструктор JPQL
Criteria API часто воспринимают как некую «тайную кнопку профессионалов»: мол, написал Criteria — и запрос стал быстрее. Увы (или к счастью), Criteria так не работает. Criteria API — это объектный способ описать тот же запрос, который вы могли бы написать строкой на JPQL. Это не «другая база данных» и не «другой движок». Hibernate всё равно превратит вашу конструкцию в SQL и отправит в PostgreSQL.
Зато Criteria даёт другое: вы строите запрос как дерево объектов, а не как строку. Это особенно приятно, когда условия опциональны. Вместо «склеить кусок текста, не забыть пробел, не забыть and» вы говорите: «если параметр задан — добавь такой Predicate». Психологически это похоже на сборку LEGO: вы не рисуете дом маркером на салфетке, вы складываете блоки, и лишний блок просто не кладёте.
Важно правильно понимать границу. Criteria не отменяет fetch‑дисциплину, не лечит N+1, не отменяет необходимость смотреть SQL trace. Он просто даёт более стабильный способ собирать динамический where и order by в Java‑коде.
Когда такие условия начинают повторяться между несколькими read-case’ами, эту же механику обычно пакуют в именованные фильтры на стороне Spring Data. Но сначала полезно увидеть её в чистом виде.
2. Мини‑карта Criteria API
Когда впервые видишь Criteria‑код, он выглядит как «Java пишет на Java». Ирония в том, что в этом есть своя красота… но не с первого взгляда. Чтобы не утонуть, полезно держать в голове маленькую карту объектов: кто что делает и где обычно находится в коде. Это прям как в команде: один планирует, второй строит, третий приносит результаты — и никто не обязан делать всё сразу.
Ниже таблица, которую удобно воспринимать как «словарик персонажей»:
|
Где берём | За что отвечает (по‑человечески) |
|---|---|---|
| EntityManager | внедряем в query‑класс | «Вход в JPA/Hibernate мир»: создаёт запрос, выполняет его |
| CriteriaBuilder | entityManager.getCriteriaBuilder() | «Фабрика условий»: , , , , |
| CriteriaQuery<T> | cb.createQuery(T.class) | «Шаблон результата»: что выбираем и какого типа будет результат |
| Root<T> | cq.from(Entity.class) | «Корень запроса»: стартовая точка для |
| Join | root.join("association") | «Переход по связи»: чтобы фильтровать по полям связанной сущности |
| Predicate | cb.equal(...), |
Одно условие в |
| TypedQuery<T> | entityManager.createQuery(cq) | «Готовый запрос на выполнение»: можно поставить limit/offset и исполнить |
Если вы держите эту схему в голове, Criteria перестаёт быть “магическим заклинанием” и становится обычным паттерном: сначала строим, потом выполняем. В следующих разделах мы прямо соберём такой запрос в проекте на реальных сущностях PurchaseOrder и Customer.
3. Каркас Criteria‑запроса
Начинать знакомство с Criteria лучше не с «динамики на 12 фильтров», а с простого шаблона, который вы сможете потом расширять. В жизни это выглядит так: создаём CriteriaBuilder, создаём CriteriaQuery<T>, задаём Root<T>, собираем where, добавляем order by, выполняем. Это похоже на готовку: сначала кастрюля, потом ингредиенты, потом «довести до готовности», и только потом пробовать.
Пусть у нас есть базовый read-case: «Дай последние заказы по дате создания». Никакой динамики, просто каркас.
import jakarta.persistence.EntityManager;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Root;
// Предполагаем, что EntityManager уже внедрён (например, в query-класс)
CriteriaBuilder cb = entityManager.getCriteriaBuilder(); // "фабрика" для условий/сортировок
CriteriaQuery<PurchaseOrder> cq = cb.createQuery(PurchaseOrder.class); // описываем тип результата
Root<PurchaseOrder> order = cq.from(PurchaseOrder.class); // корень запроса (от него идут пути к полям)
// Выбираем сущность целиком и задаём сортировку: новые заказы первыми
cq.select(order).orderBy(cb.desc(order.get("createdAt")));
Здесь важно увидеть две вещи. Во‑первых, мы не пишем строку "select o from PurchaseOrder o ...", мы строим объектную модель запроса. Во‑вторых, order.get("createdAt") — это путь к полю entity. Hibernate потом переведёт это в нужную колонку по mapping’у.
Теперь выполним запрос и ограничим выдачу, чтобы не превратить нашу БД в бесплатный «архив всего на свете» за один вызов:
import jakarta.persistence.TypedQuery;
import java.util.List;
// Превращаем CriteriaQuery в исполняемый запрос
TypedQuery<PurchaseOrder> query = entityManager.createQuery(cq);
// Ограничиваем результат, чтобы случайно не вычитать "всю историю за 5 лет"
List<PurchaseOrder> orders = query.setMaxResults(20).getResultList();
Если вы включите профиль sql-trace, вы увидите обычный SQL select ... from purchase_order ... order by created_at desc limit 20. То есть Criteria — это способ описать запрос, а не другой мир выполнения.
4. Динамический where и список Predicate
Теперь переходим к главному: динамике. Типовой запрос backoffice редко имеет один фиксированный фильтр. Чаще это «если пользователь заполнил поле — фильтруем, если нет — не фильтруем». На строковом JPQL это обычно превращается в условный конструктор или в огромный where с (:param is null or ...). Criteria позволяет делать это спокойнее: мы просто накапливаем список условий, а в конце применяем их в where.
Представим фильтр для поиска заказов. Для такого поиска удобно сразу отделить входные параметры от формы выдачи: where живёт своей жизнью, а read-model потом может быть хоть entity, хоть DTO.
import java.time.Instant;
// DTO фильтра: каждое поле может быть задано или null (то есть "не фильтруем по нему")
public record OrderSearchFilter(
String customerEmail,
OrderStatus status,
Instant createdFrom,
Instant createdTo
) {}
Теперь в query‑классе мы собираем Predicate только для тех полей, которые реально заданы:
import jakarta.persistence.criteria.Predicate;
import java.util.ArrayList;
import java.util.List;
// Сюда складываем условия, которые реально нужно применить (динамический where)
List<Predicate> predicates = new ArrayList<>();
if (filter.status() != null) {
// Фильтр по статусу (равенство)
predicates.add(cb.equal(order.get("status"), filter.status()));
}
if (filter.createdFrom() != null) {
// Нижняя граница по дате создания (включительно)
predicates.add(cb.greaterThanOrEqualTo(order.get("createdAt"), filter.createdFrom()));
}
if (filter.createdTo() != null) {
// Верхняя граница по дате создания (обычно "меньше", чтобы to работал как "до начала дня/секунды")
predicates.add(cb.lessThan(order.get("createdAt"), filter.createdTo()));
}
Самый «приятный» момент тут в том, что вы не думаете про and и пробелы. Вы думаете про смысл: «есть параметр — добавь условие». А потом один раз применяете всё собранное:
import jakarta.persistence.criteria.Predicate;
// CriteriaQuery.where(...) принимает varargs Predicate и склеивает их через AND
Predicate[] where = predicates.toArray(new Predicate[0]);
// Применяем динамический where + сортировку (всё ещё на уровне модели, не SQL-строк)
cq.select(order).where(where).orderBy(cb.desc(order.get("createdAt")));
Да, where(where) выглядит немного странно (как будто Java шутит над нами), но суть проста: массив предикатов внутри where соединяется логическим AND. Для большинства «форм поиска» это именно то, что нужно.
5. Join в Criteria: фильтр по email клиента
Динамические фильтры часто упираются не только в поля корневой сущности, но и в связи. Например, в заказах мы хотим фильтровать по Customer.email. В JPQL это выглядит просто: join o.customer c where c.email = :email. В Criteria принцип тот же, только join строится объектно.
Сначала получаем Join от заказа к клиенту. Обычно это делается один раз и используется дальше в предикатах:
import jakarta.persistence.criteria.Join;
import jakarta.persistence.criteria.JoinType;
// Join нужен для фильтрации по полям связанной сущности (это не fetch!)
Join<PurchaseOrder, Customer> customer = order.join("customer", JoinType.INNER);
Теперь можно добавить условие по email, и оно тоже станет опциональным:
if (filter.customerEmail() != null) {
// Фильтруем по email клиента через join
predicates.add(cb.equal(customer.get("email"), filter.customerEmail()));
}
Обратите внимание на важную деталь: мы всё ещё работаем на уровне полей entity, а не колонок таблиц. Это остаётся ORM‑границей: Criteria помогает собирать запрос, но вы всё ещё мыслите моделью.
Есть практический нюанс, который полезно помнить в контексте курса. Такой join сам по себе не означает «мы загрузили клиента внутри заказа». Он означает «мы использовали связь для фильтрации». Для fetch‑плана у Criteria есть отдельные механики (fetch, join fetch), но их мы здесь не разворачиваем, потому что это уже зона controlled fetching из прошлых дней. Наша цель сейчас — честно собрать where, а не случайно превратить запрос в монстра с раздутым result set.
6. Criteria и DTO‑проекция
После дня про projections хочется применять их везде (и это нормально: это выглядит как «наконец-то разумно»). Criteria это тоже умеет. И здесь важно не перепутать два режима мышления: если вы выбираете entity, результат будет managed, попадёт в persistence context и может участвовать в dirty checking. Если вы выбираете DTO‑проекцию, вы получаете обычный объект без «магии управления».
Представим, что для списка заказов в админке нам нужна “строка” вида: id заказа, номер, email клиента, статус. Сделаем простую DTO‑модель (можно record):
// DTO "строка таблицы" для backoffice: только то, что реально нужно для списка
public record OrderRow(
Long id,
String orderNumber,
String customerEmail,
OrderStatus status
) {}
Теперь в Criteria можно собрать DTO через cb.construct(...):
import jakarta.persistence.criteria.CriteriaQuery;
// Здесь результатом запроса будет не entity, а DTO (record) — без persistence context "магии"
CriteriaQuery<OrderRow> cq = cb.createQuery(OrderRow.class);
Root<PurchaseOrder> order = cq.from(PurchaseOrder.class); // основной источник данных
Join<PurchaseOrder, Customer> customer = order.join("customer"); // нужен email клиента
// Конструируем DTO прямо в select (аналог JPQL: select new ...)
cq.select(cb.construct(
OrderRow.class,
order.get("id"),
order.get("orderNumber"),
customer.get("email"),
order.get("status")
));
Это выглядит длиннее, чем JPQL select new ..., но выигрывает в динамике: дальше вы точно так же добавляете Predicate, orderBy, пагинацию. И получаете результат, который идеально ложится в read‑model подход из прошлого дня: список не тащит лишний граф entity и не создаёт случайных побочных эффектов.
7. Сортировка и пагинация в Criteria
Когда фильтры динамические, сортировка обычно тоже становится динамической: пользователю хочется «по дате», «по статусу», «по номеру заказа». В Criteria сортировка — это часть CriteriaQuery, а лимит/offset — это часть выполнения (TypedQuery). Это удобно разделяет ответственность: CriteriaQuery описывает, что нужно прочитать, а TypedQuery — как именно это выполнить в рамках конкретного вызова.
Сортировка задаётся через orderBy и cb.asc/cb.desc:
// Сортировка — часть CriteriaQuery (то есть "форма" запроса)
cq.orderBy(cb.desc(order.get("createdAt"))); // последние сначала
А пагинация задаётся уже на TypedQuery:
import jakarta.persistence.TypedQuery;
// Пагинация — часть выполнения запроса (не часть CriteriaQuery)
TypedQuery<OrderRow> q = entityManager.createQuery(cq);
q.setFirstResult(offset); // с какой строки (offset)
q.setMaxResults(limit); // сколько строк (limit)
Очень важно, что Criteria не освобождает вас от дисциплины SQL‑наблюдаемости. Когда вы меняете сортировку и фильтры, SQL меняется. И если вы выбираете сортировку по полю без индекса (или по вычисляемому выражению), PostgreSQL вполне честно скажет вам: «Окей, я отсортирую, но ты потом не удивляйся». В рамках курса мы не уходим в оптимизацию планов, но привычка одна: включили sql-trace, посмотрели форму SQL, сравнили с ожиданиями.
8. Размещение Criteria‑кода в проекте
Самая частая архитектурная ошибка с Criteria — положить его прямо в сервис, рядом с бизнес‑логикой. В итоге сервис превращается в «и бизнес, и SQL‑конструктор, и сортировка, и пагинация». Через пару недель такой метод выглядит как сериал “Следствие ведут Criteria’сты”, где в каждой серии новый if. Гораздо спокойнее держать Criteria в отдельном query‑классе, в пакете ...query, как и предлагает структура Commerce Persistence Lab.
Например, для заказов это может быть что-то вроде com.example.commerce.orders.query.OrderSearchQuery. Сервис говорит: «Найди заказы по фильтру», а query‑класс внутри себя собирает Criteria и возвращает DTO или список entity.
Скелет такого класса обычно очень короткий и честный: конструктор с EntityManager, метод поиска, внутри которого три фазы — собрать query, применить предикаты, выполнить.
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Repository;
@Repository // Помечаем компонент как read/query-репозиторий (не обязательно Spring Data)
public class OrderSearchQuery {
private final EntityManager entityManager;
public OrderSearchQuery(EntityManager entityManager) {
// EntityManager приходит из JPA-контекста (инъекция зависимостей)
this.entityManager = entityManager;
}
}
Такое размещение делает две вещи. Во‑первых, сервис остаётся читаемым, и вы не мешаете бизнес‑код с построением SQL‑формы. Во‑вторых, Criteria‑код становится легче тестировать как read‑компонент: вы можете писать интеграционные тесты на конкретный запрос, смотреть SQL и проверять, что динамика действительно работает так, как вы задумали.
9. Типичные ошибки при работе с Criteria API
Ошибка №1: переписать на Criteria вообще всё, даже простые фиксированные запросы.
Criteria полезен там, где форма where реально динамическая. Если у вас запрос «активные товары по статусу, сортировка по имени» и он никогда не меняется, строковый JPQL обычно проще читать и поддерживать. Часто выигрывает правило «самое простое, что остаётся прозрачным»: Criteria стоит доставать, когда он снимает реальную боль, а не потому что “так солиднее”.
Ошибка №2: смешать бизнес‑логику и сборку запросов в одном огромном методе сервиса.
Когда в сервисе одновременно живёт «поменять статус заказа», «проверить правила перехода статуса» и «собрать критерий на 8 фильтров», вы получаете код, где сложность не складывается, а перемножается. Вы перестаёте видеть, где именно формируется read‑модель и какие данные реально нужны. Лучше держать Criteria в query‑классе внутри ...query, а сервису оставлять оркестрацию сценария.
Ошибка №3: использовать root.get("fieldName") без дисциплины имён и получить ошибку в рантайме.
В Criteria поля часто указываются строками. Опечатка вида "createdAt" → "createAt" не будет поймана компилятором, и вы узнаете о ней в самый подходящий момент — в рантайме (обычно на демо). Спасает простая дисциплина: аккуратные имена полей, рефакторинг через IDE и минимум “магических строк”. Да, у JPA есть metamodel для типобезопасности, но мы здесь не превращаем лекцию в отдельный курс по metamodel‑генерации.
Ошибка №4: ожидать, что Criteria «сам исправит производительность» и перестать смотреть SQL.
Criteria не является оптимизатором. Он может даже сделать запрос менее очевидным для разработчика, если вы перестали понимать, что именно строите. В deep‑dive курсе это особенно опасно: вы должны уметь открыть SQL trace и сказать «ага, вот такой join, вот такие условия, вот такой order by». Поэтому после любого заметного изменения Criteria‑кода привычка одна: включили sql-trace, посмотрели generated SQL, убедились, что форма запроса совпадает с идеей.
Ошибка №5: возвращать managed entity там, где нужен список для чтения, и потом удивляться “лишней работе Hibernate”.
Если Criteria возвращает PurchaseOrder как entity, эти объекты попадут в persistence context и будут managed. В read‑сценариях это может быть лишним: вы получите потенциальный overhead на dirty checking и риски случайных lazy‑инициализаций, если кто-то “просто залогирует заказ целиком”. Для backoffice‑таблиц чаще удобнее проекция (OrderRow) — и Criteria умеет это делать через cb.construct(...), что мы сегодня показали.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ