Как работают с реляционными СУБД в Java-приложениях

Для взаимодействий Java-приложений с реляционной БД существует несколько способов:

  • Нативные запросы, с помощью JDBC, Spring JDBC Template и т. д.;

  • JPQL/HQL;

  • Criteria API;

  • Spring Data репозитории.

Каждый из них даёт свой уровень абстракции и свой уровень производительности. Нативные запросы дают наибольшую производительность и возможность использовать все возможности СУБД, но их дольше писать и сложнее маппить результат. JPQL/HQL требует затрат на парсинг запроса, его конвертацию в SQL, урезает дополнительные возможности СУБД, но проще в написании, упрощает JOIN-ы, даёт возможность менее болезненной смены СУБД в будущем, поддерживает дополнительные возможности, например, полиморфизм. Criteria API позволяет делать всё то же самое, но программно, без написания запроса на JPQL/HQL. Spring Data репозитории дают больше всего накладных расходов, зато позволяют генерировать JPQL/HQL по названию метода. В последнем способе возможности наиболее ограниченные, зато этого достаточно для написания относительно простых запросов за минимальное время разработчика.

Сегодня мы говорим про Criteria API. Когда же стоит использовать именно этот подход?

  • Нужна независимость от СУБД

  • Запрос достаточно сложный, чтобы написать его названием метода репозитория Spring Data

  • Нужно динамически перестраивать запрос в зависимости от входных параметров (самое ценное преимущество)

В статье мы не будем рассказывать обо всех функциях Criteria API, поговорим только про основные абстракции, рассмотрим интересные примеры, покажем, где что искать для своих особых потребностей.

Для улучшения понимания Criteria API начнём с основных абстракций, которыми приходится манипулировать при работе с ней.

Абстракции/классы Criteria API

Строительные блоки запросов:

Класс JPA

Брат-близнец Hibernate

Задачи

Selection<T>

JpaSelection<T>

Определяет то, что можно вернуть в результате запроса (но используется далеко не только для этого)

Expression<T>

JpaExpression<T>

Выражение запроса, используется везде: в выборке, фильтрации, группировке, HAVING-выражение, сортировке и т. д. Наследуется от Selection<T>, добавляя методы isNull, isNotNull, in, as (каст к указанному типу) и другие

Predicate

Просто Expression<Boolean>, с дополнительными методами (отрицание себя, например)

Order

Объект для указания сортировки, по сути, Expression вместе с ASC/DESC

С их помощью мы можем строить разные части запроса, например все столбцы из SELECT-выражения SQL это набор Selection-ов. Когда мы указываем название столбца, литерал (например, константную строку или число), это Expression. Также Expression являются арифметические операции, простые и агрегатные функции, вообщем всё, что является выражением. Predicate это просто выражение, которое возвращает значение типа Boolean, он используется внутри выражений WHERE, HAVING и других. Создаются такие блоки в основном методами CriteriaBuilder, например CriteriaBuilder.and(Predicate...), CriteriaBuilder.desc(Expression), CriteriaBuilder.sum(Expression<? extends Number>).

Пути к атрибутам, сущностям, embedded-классам:

Класс JPA

Брат-близнец Hibernate

Задачи

Path<T>

JpaPath<T>

Представляет путь к атрибуту (простому или составному) из сущности, является выражением, т. е. наследник Expression<T>

From<X, Z>

JpaFrom<X, Z>

Представляет собой объект (чаще всего сущность), которая входит в выражение FROM запроса. Служит как фабрика для JOIN-ов и т. д. (поэтому есть два параметра типа: откуда и куда джойним)

Root<X>

JpaRoot<X>

Тип из выражения FROM, всегда сущность, наследник From<X, X>

Эти объекты используются для доступа к атрибутам, сущностям и т. д. Для начала мы обычно получаем объект Root<X>, используя метод AbstractQuery.from(Class<X>). После этого через Path.get(attributeName) (или другие) мы можем обращаться к полям сущности или к другой сущности по цепочке. Объект From<X, Z> можно использовать для JOIN и получать с помощью этого другие объекты From<Z, Y>. JOIN в Criteria API как и в JPQL возможен только по полю сущности, поэтому вам скорее всего потребуются двусторонние связи между сущностями.

Классы для запросов:

Класс JPA

Брат-близнец Hibernate

Задачи

CriteriaBuilder

HibernateCriteriaBuilder

Основной класс для построения запросов, сложных выборок, предикатов и т. д.

CriteriaQuery<T>

JpaCriteriaQuery<T>

Содержит функции для создания запросов SELECT на верхнем уровне, позволяет указать: столбцы/выражения результата (select/multiselect), фильтр (where), группировку (groupBy), фильтр групп (having) сортировку (orderBy), уникальность выборки (distinct) и т. д.

AbstractQuery<T>

JpaSelectCriteria<T>

Содержит общее между запросами SELECT и подзапросами. Например: from, getSelection, getGroupList и т. д.

CriteriaUpdate<T>

JpaCriteriaUpdate<T>

Содержит функции для команд UPDATE

CriteriaDelete<T>

JpaCriteriaDelete<T>

Содержит функции для команд DELETE

CommonAbstractCriteria

JpaCriteriaBase

Общее для выборок, обновлений, удалений

Примеры запросов с Criteria API

Для примера напишем небольшое приложение для обработки заказов. Ссылка на репозиторий в конце статьи, схема БД представлена ниже.

Схема БД для примеров
Схема БД для примеров
Схема БД для примеров

Пример запроса, который выводит заказы, отправленные в указанный магазин, с расчётом общей стоимости заказа и сортировкой по убыванию времени создания заказа.

Реализация запроса для получения отправленных в магазин заказов
public List<OrderSentInStoreProjection> findSentInStoreOrdersByStoreId(UUID storeId) {
    // Приводим EntityManager к Session
    final var cb = entityManager.unwrap(Session.class).getCriteriaBuilder();
    final var query = cb.createQuery(OrderSentInStoreProjection.class);
    final var order = query.from(Order.class);
    // Классы с названием сущности и "_" в конце сгенерированы с помощью
    // hibernate-jpamodelgen и содержат константы с названиями всех полей сущности
    final var orderStatusHistory = order.join(Order_.HISTORY_RECORDS, JoinType.INNER);
    final var orderItem = order.join(Order_.ITEMS, JoinType.INNER);
    final var product = orderItem.join(OrderItem_.PRODUCT, JoinType.INNE
    query
            .select(cb.construct(
                    OrderSentInStoreProjection.class,
                    order.get(Order_.ID),
                    order.get(Order_.CREATED_AT),
                    cb.sum(
                            // Метод HibernateCriteriaBuilder (умножение)
                            cb.prod(product.get(Product_.PRICE), orderItem.get(OrderItem_.QUANTITY))
                    )
            ))
            .where(
                    cb.and(
                            // Оборачиваем Order.Status.SENT_TO_STORE в cb.literal, чтобы передавать
                            // 'SENT_TO_STORE' не JDBC-параметром (?), а константой
                            cb.equal(
                                    orderStatusHistory.get(OrderStatusHistory_.STATUS),
                                    cb.literal(Order.Status.SENT_TO_STORE)
                            ),
                            cb.equal(order.get(Order_.STORE_ID), storeId)
                  
            )
            .groupBy(order.get(Order_.ID), order.get(Order_.CREATED_AT))
            .orderBy(cb.desc(order.get(Order_.CREATED_AT)
    return entityManager.createQuery(query).getResultList();
}

В запросе выше мы создали типизированный запрос (для возвращения результата создали составной Selection с помощью cb.construct), но можно сделать запрос, который возвращает кортежи (tuple-ы), в таком случае нам придётся указать псевдонимы для всех столбцов и самостоятельно смаппить кортеж в проекцию (класс созданный для переноса данных запроса или запросов).

Для демонстрации работы с tuple-ами напишем запрос на получение статистики по магазинам, количеству заказов в каждом по их статусам. Строки будут отсортированы в порядке убывания количества всех выполненных, отменённых и отклонённых заказов.

Реализация запроса для получения статистики по магазинам
public List<OrderStoreStatisticProjection> findStoreStatistic(BigDecimal lowerBound, BigDecimal upperBound) {
    final var cb = entityManager.unwrap(Session.class).getCriteriaBuilder();
    final var query = cb.createTupleQuery();
    final var order = query.from(Order.class);
    final var orderItem = order.join(Order_.ITEMS, JoinType.INNER);
    final var product = orderItem.join(OrderItem_.PRODUCT, JoinType.INNER);
    // Вынесем выражение подсчёта количества уникальных заказов со статусом в отдельный метод,
    // а выражения подсчёта количества с каждым статусом вынесем в отдельную переменную,
    // так как они понадобятся в нескольких местах
    final var completedCount = countDistinctOrderByStatus(cb, order, Order.Status.COMPLETED);
    final var canceledCount = countDistinctOrderByStatus(cb, order, Order.Status.CANCELED);
    final var rejectedCount = countDistinctOrderByStatus(cb, order, Order.Status.REJECTED);
    // Выражение подсчёта общей стоимости заказа тоже вынесем в отдельную переменную
    final var totalOrderPrice = cb.sum(
            cb.<BigDecimal>prod(product.get(Product_.PRICE), orderItem.get(OrderItem_.QUANTITY))
    );

    query.multiselect(
                    // Для каждого столбца указываем явно тип и псевдоним, 
                    // чтобы по нему потом достать значение  из кортежа
                    order.get(Order_.STORE_ID).as(UUID.class).alias("storeId"),
                    completedCount.as(Long.class).alias("completed"),
                    canceledCount.as(Long.class).alias("canceled"),
                    rejectedCount.as(Long.class).alias("rejected")
            )
            .groupBy(order.get(Order_.STORE_ID))
            .having(
                    cb.and(
                            cb.greaterThan(
                                    totalOrderPrice,
                                    lowerBound
                            ),
                            cb.lessThan(
                                    totalOrderPrice,
                                    upperBound
                            )
                    )
            )
            // Сортируем магазины по убыванию количества завершённых заказов
            .orderBy(cb.desc(cb.sum(cb.sum(completedCount, canceledCount), rejectedCount)));

    return entityManager.createQuery(query).getResultList().stream()
            .map(this::tupleToStatisticProjection)
            .toList();
}

private Expression<Long> countDistinctOrderByStatus(
        CriteriaBuilder cb,
        Root<Order> order,
        Order.Status status
) {
    // Так записываем COUNT(DISTINCT CASE WHEN order.status = ? THEN order.id END)
    return cb.countDistinct(
            cb.selectCase()
                    .when(
                            cb.equal(order.get(Order_.STATUS), cb.literal(status)),
                            order.get(Order_.ID)
                    )
    );
}

private OrderStoreStatisticProjection tupleToStatisticProjection(Tuple tuple) {
    return new OrderStoreStatisticProjection(
            // Извлекаем значения из кортежа, кстати, есть и другие способы,
            // например, по номеру столбца
            tuple.get("storeId", UUID.class),
            tuple.get("completed", Long.class),
            tuple.get("canceled", Long.class),
            tuple.get("rejected", Long.class)
    );
}

Мы можем увидеть, что использование Tuple требует больше кода, так как нам нужно вручную маппить возвращаемый результат. Но этот подход может оказаться полезнее, когда мы хотим получить над маппингом больше контроля, например, заполнить не все поля проекции или добавить поля из запроса в существующие объекты (считаю не очень хороший подход, так как похоже на нарушение single responsibility проекций). В моём опыте был случай, когда в зависимости от входных параметров надо было сделать запрос разными способами: использовать COUNT или SUM. В одном случае для Hibernate возвращается строго Long, в другом Integer. После множества неудачных попыток привести одно к другому, поменять тип полей проекции, решил вручную маппить Tuple.

Как удобнее писать запросы с Criteria API

Для того чтобы удобнее работать с Criteria API и сделать код более поддерживаемым, напишу пару простых советов.

  • Specification<T> из Spring Data JPA предоставляет возможности по манипулированию запросом с использованием Criteria API. Во втором примере мы вынесли похожие выражения "COUNT(DISTINCT CASE WHEN status = ? THEN id END)" в отдельный метод. Это потребовало передавать в параметры CriteriaBuilder, Root. Интерфейс Specification<T> позволит избежать создания методов с кучей параметров, так как спецификация это лямбда, которая принимает на вход всё, что ей нужно, и делает из этого предикат. При этом спецификации можно использовать не только для WHERE-выражений запроса, а для всех, кроме SELECT, потому что по выборке сущности или проекции Spring Data решает сам.

  • Для получения атрибутов сущностей мы пользуемся методами get класса Path<X>, в который на вход передаётся строка с названием поля. Это не очень хорошо, так как не приведёт к ошибке компиляции, если мы изменим название поля сущности без изменений всех ссылок на него. Для этого часто с Criteria используют плагин hibernate-jpamodelgen, который генерирует метамодели по сущностям. Метамодели это классы, которые содержат константы с названиями всех полей соответствующей сущности.

Как добавить кастомные SQL-функции в Criteria API

Добавление кастомных функций производится с помощью реализации интерфейса FunctionContributor следующим образом (в примере реализовали его прямо в конфигурационном классе):

Регистрация кастомной SQL-функции
@Configuration
@EnableJpaAuditing(dateTimeProviderRef = "currentZonedDateTimeProvider")
public class JpaConfig implements FunctionContributor {
    public static final String BOOL_AND = "bool_and";

    // Бин для получения текущего времени типа ZonedDateTime
    // для сущностей с аудитом AuditingEntityListener.class и полем с аннотацией @CreatedDate
    @Bean
    DateTimeProvider currentZonedDateTimeProvider() {
        return () -> Optional.of(ZonedDateTime.now());
    }

    @Override
    public void contributeFunctions(FunctionContributions functionContributions) {
        // Регистрация функции bool_and в реестре функций
        functionContributions.getFunctionRegistry().register(
                BOOL_AND,
                // StandardSQLFunction это самый простой способ описать SQL-функцию,
                // нужно просто указать её название и возвращаемый тип,
                // без более продвинутых вещей, например валидации аргументов
                new StandardSQLFunction(
                        BOOL_AND,
                        StandardBasicTypes.BOOLEAN
                )
        );
    }
}

Для примера использования кастомной SQL-функции напишем запрос который возвращает заказы, в которых все товары принадлежат указанному списку категорий:

Пример использования кастомной SQL-функции
public List<OrderShortInfoProjection> findOrderWithProductInCategories(List<String> categoryNames) {
    final var cb = entityManager.getCriteriaBuilder();
    final var query = cb.createQuery(OrderShortInfoProjection.class);
    final var order = query.from(Order.class);
    final var orderItem = order.join(Order_.ITEMS);
    final var product = orderItem.join(OrderItem_.PRODUCT);
    final var category = product.join(Product_.CATEGORY);

    query
            .select(cb.construct(
                    OrderShortInfoProjection.class,
                    order.get(Order_.ID),
                    order.get(Order_.STORE_ID),
                    order.get(Order_.STATUS)
            ))
            // В postgres можно обращаться к любому столбцу таблицы, сгруппированной по PK
            .groupBy(order.get(Order_.ID))
            .having(cb.function(
                    // Указываем название, тип возвращаемого значения и аргументы
                    JpaConfig.BOOL_AND,
                    Boolean.class,
                    // Альтернативный вариант написания in
                    cb.in(category.get(Category_.NAME)).value(categoryNames)
            ));

    return entityManager.createQuery(query).getResultList();
}

В Hibernate есть довольно много функций, которые доступны из коробки. Их можно посмотреть в классе диалекта, в CommonFunctionFactory в Hibernate, чьи методы регистрации функций вызывает Dialect.

Использование обобщённых табличных выражений в Hibernate Criteria API

Использование обобщённых табличных выражений (CTE) не доступно в "чистой" Criteria API, для этого необходимо использовать брата-близнеца из Hibernate — HibernateCriteriaBuilder. Разберём какие есть абстракции для CTE в Criteria API.

Класс

Задачи

JpaCteContainer

Общий контракт для всего, что может включать в себя CTE: запросы, подзапросы, команды обновления, вставки и т. д.

JpaCteCriteria<T>

Класс, который представляет CTE

JpaCteCriteriaAttribute

Атрибут (столбец, выражение), который будет доступен из CTE

JpaCteCriteria<T> можно использовать в выражении FROM у запросов и подзапросов (то есть наследников JpaSelectCriteria<T>). Проблему с JOIN CTE с другой таблицей или с другим CTE можно решить с помощью добавления в WHERE-выражение того, что обычно добавляется после ON как условие JOIN-а.

Напишем пример, где используем рекурсивный CTE, обычный CTE так, что обычный ссылается на рекурсивный. Суть запроса такая: выберем всю краткую информацию (id, статус, id магазина) о заказе и его полную цену для таких заказов, в которых был товар из подкатегории (включая дочерние подкатегории и других потомков) категории с указанным именем. На чистом SQL запрос будет выглядеть примерно так:

Запрос с CTE на чистом SQL
WITH RECURSIVE
    all_category AS (SELECT fc.id, fc.name
                       FROM category fc
                                JOIN category fp ON fc.parent_id = fp.id
                       WHERE fp.name = 'Напитки'
                       UNION ALL
                       SELECT c.id, c.name
                       FROM category c
                                JOIN all_category ac ON ac.id = c.parent_id),
    all_order AS (SELECT DISTINCT o.id
                   FROM "order" o
                            JOIN order_item oi ON o.id = oi.order_id
                            JOIN product p ON oi.product_id = p.id
                            JOIN all_category ac ON p.category_id = ac.id)
SELECT o.id, o.status, o.store_id, SUM(oi.quantity * p.price)
FROM "order" o
         JOIN all_order ao ON o.id = ao.id
         JOIN order_item oi ON ao.id = oi.order_id
         JOIN product p ON oi.product_id = p.id
GROUP BY o.id;

Теперь как выглядит код для его написания с Criteria API:

Реализация запроса для получения информации о заказах с товарами из подкатегорий выбранной категории
public List<OrderWithTotalPriceProjection> findOrderWithProductCategory(String categoryName) {
    final var cb = entityManager.unwrap(Session.class).getCriteriaBuilder();
    final var query = cb.createQuery(OrderWithTotalPriceProjection.class);
    // Создаём рекурсивный CTE all_category. Для его создания нужен базовый запрос и
    // функция создания рекурсивного запроса из прошлого запроса. Создаём каждый запрос в отдельном методе
    final var allCategoryCte = query.withRecursiveUnionAll(
            getAllCategoryCteBaseQuery(categoryName, cb),
            getAllCategoryRecursiveQueryProducer(cb)
    );
    // Создаём query CTE all_order в отдельном методе
    final var allOrderCteQuery = getAllOrderCteQuery(cb, allCategoryCte);
    // Создаём объект CTE all_order из query
    final var allOrderCte = query.with(allOrderCteQuery);
    // Не нашёл способа делать JOIN с CTE, поэтому делаем "декартово" произведение, а условие ON пишем в WHERE
    // Для БД оба способа эквивалентны и не должны отличаться по производительности
    final var allOrderRoot = query.from(allOrderCte);
    final var order = query.from(Order.class);
    final var orderItem = order.join(Order_.ITEMS);
    final var product = orderItem.join(OrderItem_.PRODUCT);
    query
            .select(cb.construct(
                    OrderWithTotalPriceProjection.class,
                    // Пример использования составного Selection с помощью вложенного cb.construct
                    cb.construct(
                            OrderShortInfoProjection.class,
                            order.get(Order_.ID),
                            order.get(Order_.STORE_ID),
                            order.get(Order_.STATUS)
                    ),
                    cb.sum(cb.prod(orderItem.get(OrderItem_.QUANTITY), product.get(Product_.PRICE)))
            ))
            // То, что обычно в WHERE
            .where(cb.equal(
                    order.get(Order_.ID),
                    allOrderRoot.get(Order_.ID)
            ))
            .groupBy(order);
    return entityManager.createQuery(query).getResultList();
}

private JpaCriteriaQuery<Tuple> getAllCategoryCteBaseQuery(String categoryName, HibernateCriteriaBuilder cb) {
    final var allCategoryCteBaseQuery = cb.createTupleQuery();
    final var allCategoryCteBaseCategory = allCategoryCteBaseQuery.from(Category.class);
    // JOIN таблицы на саму себя по связи parent
    final var allCategoryCteBaseParentCategory = allCategoryCteBaseCategory.join(Category_.PARENT);
    // Выбираем id и name той category, у которой родитель с именем categoryName
    return allCategoryCteBaseQuery
            .multiselect(
                    allCategoryCteBaseCategory.get(Category_.ID).as(UUID.class).alias(Category_.ID),
                    allCategoryCteBaseCategory.get(Category_.NAME).as(String.class).alias(Category_.NAME)
            )
            .where(cb.equal(allCategoryCteBaseParentCategory.get(Category_.NAME), categoryName));
}
private JpaCriteriaQuery<Tuple> getAllOrderCteQuery(
        HibernateCriteriaBuilder cb,
        JpaCteCriteria<Tuple> allCategoryCte
) {
    final var allOrderCteQuery = cb.createTupleQuery();
    final var allOrderCteOrder = allOrderCteQuery.from(Order.class);
    final var allOrderCteOrderItem = allOrderCteOrder.join(Order_.ITEMS);
    final var allOrderCteProduct = allOrderCteOrderItem.join(OrderItem_.PRODUCT);
    // Опять же заменяем JOIN на "декартово" с WHERE
    final var allOrderCteAllCategory = allOrderCteQuery.from(allCategoryCte);
    return allOrderCteQuery
            .distinct(true)
            .multiselect(
                    allOrderCteOrder.get(Order_.ID).as(UUID.class).alias(Order_.ID)
            )
            .where(cb.equal(
                    // Условие ON при JOIN категории с продуктом
                    allOrderCteProduct.get(Product_.CATEGORY).get(Category_.ID),
                    allOrderCteAllCategory.get(Category_.ID)
            ));
}

private Function<JpaCteCriteria<Tuple>, AbstractQuery<Tuple>> getAllCategoryRecursiveQueryProducer(
        HibernateCriteriaBuilder cb
) {
    return previousCte -> {
        final var allCategoryCteRecursiveQuery = cb.createTupleQuery();
        // Опять же заменяем JOIN на "декартово" с WHERE
        final var allCategoryCteRecursiveCategory = allCategoryCteRecursiveQuery.from(Category.class);
        final var allCategoryCteRecursivePreviousCte = allCategoryCteRecursiveQuery.from(previousCte);
        allCategoryCteRecursiveQuery
                .multiselect(
                        allCategoryCteRecursiveCategory.get(Category_.ID).as(UUID.class).alias(Category_.ID),
                        allCategoryCteRecursiveCategory.get(Category_.NAME).as(String.class).alias(Category_.NAME)
                )
                .where(cb.equal(
                        // id предыдущей (выше по иерархии) категории должно являться id родителя новой
                        allCategoryCteRecursivePreviousCte.get(Category_.ID),
                        allCategoryCteRecursiveCategory.get(Category_.PARENT).get(Category_.ID)
                ));
        return allCategoryCteRecursiveQuery;
    };
}

Кстати говоря, довольно много времени я пытался сделать CTE на основе типизированных запросов, а не Tuple-запросов. Столкнулся с исключением при создании AnonymousTupleType из-за разницы в количестве псевдонимов и Selection-ов. Если у вас получилось, напишите в комментарии.

Оконные функции с Hibernate Criteria API (incubating)

Оконные функции доступны опять же только в Hibernate. Использовать их достаточно просто, как обычные функции, за исключением передачи объекта окна. Разберём какие есть классы для оконных функций:

Класс

Задачи

JpaWindow

Представление окна

JpaWindowFrame

Описывает фрейм окна: тип FrameKind и выражение Expression, которое ограничивает его

Оконную функцию можно создать с помощью HibernateCriteriaBuilder.windowFunction, передав название функции, тип возвращаемого значения, окно и аргументы. Окно можно создать с помощью HibernateCriteriaBuilder.createWindow(). Параметры окна можно задать методами JpaWindow: партиции — partitionBy(Expression...), сортировка — orderBy(Order...), фрейм — frameRows(JpaWindowFrame, JpaWindowFrame), frameRange, frameGroups и исключение фрейма. Параметры фрейма можно задать методами HibernateCriteriaBuilder.

Напишем пример с простым использованием оконных функций. Запрос будет считать сумму заказов за день внутри заданного периода дней, для каждого дня выводить, какой процент стоимости от общей суммы он содержит, и насколько стоимость заказов за этот день отличается от суммы за прошлый день.

Пример использования оконных функций
public List<OrderDayStatisticProjection> findOrderDayStatistic(LocalDate startDate, LocalDate endDate) {
    final var cb = entityManager.unwrap(Session.class).getCriteriaBuilder();
    final var query = cb.createQuery(OrderDayStatisticProjection.class);

    // CTE можно назначать псевдонимы
    final var dayOrder = query.from(query.with("dayOrder", getDayOrderCteQuery(cb)));
    // Порядок сортировки результата вынесем в отдельную переменную
    final var sortingOrder = cb.desc(dayOrder.get(OrderDayStatisticProjection.DAY));

    query
            .select(cb.construct(
                    OrderDayStatisticProjection.class,
                    dayOrder.get(OrderDayStatisticProjection.DAY),
                    dayOrder.get(OrderDayStatisticProjection.TOTAL_AMOUNT),
                    cb.prod(
                            // Это деление в CriteriaBuilder
                            cb.quot(
                                    dayOrder.get(OrderDayStatisticProjection.TOTAL_AMOUNT),
                                    cb.windowFunction(
                                            "sum",
                                            BigDecimal.class,
                                            cb.createWindow(),
                                            dayOrder.get(OrderDayStatisticProjection.TOTAL_AMOUNT)
                                    )
                            ),
                            cb.literal(100)
                    ),
                    cb.diff(
                            dayOrder.get(OrderDayStatisticProjection.TOTAL_AMOUNT),
                            // Оконная функция lead возвращает значение из следующей
                            // строки партиции (в данном случае партиция одна, 
                            // отсортированная по убыванию времени)
                            cb.windowFunction(
                                    "lead",
                                    BigDecimal.class,
                                    cb.createWindow()
                                            .orderBy(sortingOrder),
                                    dayOrder.get(OrderDayStatisticProjection.TOTAL_AMOUNT)
                            )
                    )
            ))
            .where(cb.between(
                    dayOrder.get(OrderDayStatisticProjection.DAY),
                    startDate,
                    endDate
            ))
            .orderBy(sortingOrder);

    return entityManager.createQuery(query).getResultList();
}

private JpaCriteriaQuery<Tuple> getDayOrderCteQuery(HibernateCriteriaBuilder cb) {
    final var dayOrderCteQuery = cb.createTupleQuery();
    final var dayOrderCteOrder = dayOrderCteQuery.from(Order.class);
    final var dayOrderCteOrderItem = dayOrderCteOrder.join(Order_.ITEMS);
    final var dayOrderCteProduct = dayOrderCteOrderItem.join(OrderItem_.PRODUCT);

    dayOrderCteQuery
            .multiselect(
                    // Приводим время к дате (в текущей временной зоне)
                    dayOrderCteOrder.get(Order_.CREATED_AT).cast(LocalDate.class)
                            .alias(OrderDayStatisticProjection.DAY),
                    cb.sum(cb.prod(
                            dayOrderCteOrderItem.get(OrderItem_.QUANTITY),
                            dayOrderCteProduct.get(Product_.PRICE)
                    )).as(BigDecimal.class).alias(OrderDayStatisticProjection.TOTAL_AMOUNT)
            )
            .groupBy(dayOrderCteOrder.get(Order_.CREATED_AT).cast(LocalDate.class));
    return dayOrderCteQuery;
}

Заключение

Мы рассмотрели большинство возможностей Criteria API, включая специфичные для Hibernate. Остались ещё функции по работе с множествами и массивами, специфичные для JPQL/HQL возможности, подзапросы (хотя с ними всё довольно просто, если понимать абстракции), мутирующие запросы INSERT/UPDATE/DELETE (вряд ли вы будете использовать Criteria API для них), возможность подстановки нативного SQL. Также можно было бы привести ещё очень много интересных примеров, в которых попытаться по полной раскрыть возможности Criteria API. Пишите комментарии, если бы хотели увидеть статью на эти темы.

Ссылка на репозиторий с примерами: https://github.com/denis5726/criteria-article

Комментарии (10)


  1. qrdl
    27.08.2025 07:29

    Почти ультимативный обзор Criteria API с продвинутыми возможностями Hibernate

    https://gramota.ru/poisk?query=ультимативный


    1. denis5726 Автор
      27.08.2025 07:29

      Ну вообще вот. Хотя возможно здесь слово «гайд», поэтому так смысл меняется. Но не я первый придумал так говорить


  1. Diagnost_E
    27.08.2025 07:29

    Вы серьезно? Criteria API?,

    чтобы потом тратить часы в попытках понять, что вы там пытались населектить?

    заем вы учите джунов плохому, они же поверят :)

    HQL/JPQL + слой репозиториев (Spring/Jakarta) по вкусу и никакого треша в коде

    Criteria API нужен ТОЛЬКО для динамической генерации запросов, т.е. крайне редко, если у вас ее нет, а в обычном бизнес коде ее чаще всего нет, то нечего огород городить


    1. denis5726 Автор
      27.08.2025 07:29

      Как раз в статье и написано, что самым ощутимым плюсом Criteria является возможность генерировать запросы. Для этого я в начале и включил раздел с небольшим сравнением разных способов работы с БД. Нигде в статье не заявляется, что это лучший способ работы. Это специфический инструмент. Когда возникнет необходимость динамической генерации, тогда хочешь или нет, но придётся с ним поработать.


      1. Diagnost_E
        27.08.2025 07:29

        У вас в примерах нет динамической генерации, вы завернули обычные запросы в Criteria API, поэтому незачет.

        Динамическая - это когда вы заранее не знаете, какой запрос нужно выполнить, например, список полей, условий выборки и сортировки приходит с UI (CRM система со стандартными формами на разные сущности с возможностью гибкой настройки пользователем).

        А у вас простая статика.


        1. denis5726 Автор
          27.08.2025 07:29

          Действительно, возможно стоило бы добавить пример динамической генерации. Примеры здесь я приводил для демонстрации возможностей Criteria. Как сделать запрос динамически это уже вопрос программирования на Java. Ну будешь предикаты в where добавлять из списка, который сгенерирован в зависимости от входных параметров, мне кажется это уже не про какие-то возможности Criteria. Поэтому и не подумал добавить сюда такие примеры


          1. atomicus
            27.08.2025 07:29

            Действительно, возможно стоило бы добавить пример динамической генерации.

            Получается не ультимативный всё-таки гайд раз не хватает примеров :)


            1. denis5726 Автор
              27.08.2025 07:29

              Конечно нет, поэтому и «почти ультимативный». В конце статьи я даже написал, что ещё стоило бы добавить до ультимативности) В том числе и интересные примеры, в числе которых будет и пример с динамической генерацией. Под это думал вторую статью отвести, но возможно и эту дополню. В запасе есть много ещё чего. Есть крутой пример с динамической сортировкой по нескольким столбцам методом плавающей границы. Думаю, куда бы запихать всё это добро


    1. RamChandra
      27.08.2025 07:29

      Действительно.. У нас в проекте были динамические фильтры, поэтому пришлось применить это.. Но пришлось писать свой кастомный BaseRepository основаннный на Specifications. Зато все другие методы всех доугих репозиториев получились весьма лаконичными. Да, это было сложно, но почему мы справились, а создатели этого нет?


      1. denis5726 Автор
        27.08.2025 07:29

        Это не сложно, мы тоже так делали, но это больше про Spring Data, чем про саму Criteria. Поэтому здесь нет такого примера