JavaRush /Курсы /Hibernate deep-dive /Query-репозитории: Specifi...

Query-репозитории: Specification + paging

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

1. Query-репозитории вместо findAll()

Со стандартными CRUD-методами уже видно, что короткое имя репозиторного метода вообще не гарантирует простой семантики: save() решает, идти в persist или merge, getReferenceById() может вообще не читать данные сразу, delete бывает lifecycle- и bulk-подобным. На query-side магия не исчезает — она просто прячется уже не в state-модели сущности, а в форме SQL, ширине результата и цене count-запроса.

Если вы когда-нибудь писали админку или backoffice (а наш Commerce Persistence Lab как раз про это), то знаете боль: сначала нужен список товаров «просто показать», потом — «показать только активные», потом — «поиск по SKU», потом — «сортировка», потом — «страницы», потом — «чтобы не тормозило». И вот в этот момент findAll() начинает звучать как план из серии «я буду питаться воздухом и солнечным светом» — благородно, но долго не протянешь.

Проблема findAll() не в том, что он плох. Проблема в том, что он безличный. Он ничего не говорит о целях чтения. А в persistence layer цель чтения определяет всё: ширину SELECT, форму JOIN, наличие/отсутствие lazy-инициализаций, стоимость count-запроса, и даже flush-поведение (потому что любой query в транзакции может триггернуть flush в режиме AUTO).

В нашем проекте типичный «список товаров» для админки — это не «дать мне Product со всем графом». Это обычно небольшой набор колонок: sku, name, status, иногда цена. А «карточка товара» — это уже другой read use case: там может понадобиться ProductDetails, категории, и так далее. Ровно поэтому в прошлых днях мы постоянно повторяли мысль: entity — это write-model, а read-model часто должен быть тоньше.

Удобный mental model: представьте, что репозиторий — это не «кладовщик, который тащит вам весь склад», а «официант». Хороший официант уточняет, что вы хотите: кофе, обед или банкет. Плохой официант приносит вам всё меню, кухню и повара, потому что «ну вы же клиент». В ORM-мире «принести всё» часто означает лишний SQL, лишние данные и лишние проблемы.

Чтобы не строить каждый раз запрос заново вручную, Spring Data даёт нам набор инструментов под query-слой: JpaSpecificationExecutor для динамических фильтров, projections для тонкой модели результата и paging (Pageable) для списков. Давайте разбирать их по одному, но держать в голове одну цель: контракт репозитория должен быть честным про форму чтения.

2. Specification: динамические фильтры

Когда вы впервые добавляете фильтры к списку, рука сама тянется к коду вида «если фильтр задан — добавим условие». И это нормально… ровно до тех пор, пока фильтров не становится много. Потом код превращается в макаронину: вроде вкусно, но разбирать вилкой неудобно. Specification — это способ сказать: «фильтр — это объект, а не кусок случайного if».

Технически Specification<T> — это обёртка вокруг Criteria API: функция, которая на вход получает root, query, criteriaBuilder, а на выход отдаёт Predicate. То есть буквально «кусочек WHERE», который можно комбинировать с другими кусочками через and()/or(). И это удобно именно для backoffice-поиска: сегодня фильтр по статусу и тексту, завтра добавили фильтр по категории, послезавтра по цене, и при этом не переписываем всё с нуля.

Начинается всё с того, что репозиторий должен уметь исполнять спецификации.

package com.example.commerce.catalog.repository;

import com.example.commerce.catalog.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;

public interface ProductRepository
        extends JpaRepository<Product, Long>, JpaSpecificationExecutor<Product> {

    // JpaSpecificationExecutor добавляет findAll(spec) и findAll(spec, pageable)
    // То есть репозиторий начинает "понимать" динамические фильтры.
}

С этого момента у вас появляется возможность писать findAll(spec) и findAll(spec, pageable) — то есть передавать фильтр как объект.

Теперь сделаем маленькую «библиотеку фильтров» под наш Product. Обычно её кладут в catalog.query или рядом (в зависимости от того, как вы у себя организуете код), но сейчас важнее сама техника.

package com.example.commerce.catalog.query;

import com.example.commerce.catalog.entity.Product;
import com.example.commerce.catalog.entity.ProductStatus;
import org.springframework.data.jpa.domain.Specification;

public final class ProductSpecifications {

    public static Specification<Product> hasStatus(ProductStatus status) {
        // Важно: это НЕ выполнение запроса, а только "кусочек WHERE"
        // Репозиторий позже превратит это в SQL.
        return (root, query, cb) ->
                // status = :status
                cb.equal(root.get("status"), status);
    }
}

Обратите внимание на важную практическую деталь: Specification — это не «выполнить запрос». Это «описать условие». SQL появится только когда репозиторий реально выполнит query. И вот тут у Hibernate включается вся его знакомая механика: если вы сидите в транзакции, если flush mode AUTO, если у вас есть pending changes — перед выполнением query может произойти flush, чтобы запрос видел консистентные данные.

Теперь добавим текстовый фильтр. В админке обычно ищут «по кусочку SKU» или «по кусочку имени». Мы сделаем простой вариант LIKE, без отдельного курса по полнотекстовому поиску (нам его здесь не надо).

package com.example.commerce.catalog.query;

import com.example.commerce.catalog.entity.Product;
import org.springframework.data.jpa.domain.Specification;

public final class ProductSpecifications {

    public static Specification<Product> nameContains(String text) {
        return (root, query, cb) ->
                // Простейший вариант: lower(name) like '%text%'
                // Для продакшена часто добавляют нормализацию/экранирование и отдельные индексы,
                // но для админского "кусочного" поиска этого обычно достаточно.
                cb.like(
                        cb.lower(root.get("name")),
                        "%" + text.toLowerCase() + "%"
                );
    }
}

Комбинировать фильтры можно так (и это выглядит заметно аккуратнее, чем цепочка if в сервисе):

import com.example.commerce.catalog.entity.Product;
import com.example.commerce.catalog.entity.ProductStatus;
import com.example.commerce.catalog.query.ProductSpecifications;
import org.springframework.data.jpa.domain.Specification;

Specification<Product> spec = Specification.where(ProductSpecifications.hasStatus(ProductStatus.ACTIVE))
        .and(ProductSpecifications.nameContains("usb"));

И теперь репозиторий может выполнить запрос:

import com.example.commerce.catalog.entity.Product;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

Page<Product> page = productRepository.findAll(spec, pageable);

И вот здесь мы подходим к важному месту: findAll(spec, pageable) возвращает entity. Это нормально, но для списков часто не идеально. С entity вы получаете managed-объекты, которые потенциально могут участвовать в dirty checking (даже если вы их не меняете) и могут тянуть за собой граф. В модуле про projections мы как раз будем делать результат тоньше.

Есть ещё одна практическая тонкость: спецификации удобны, пока вы держите их простыми. Если вы начинаете в спецификациях делать сложные join’ы и подзапросы ради отчётов, это часто означает, что вы приближаетесь к границе «может быть, лучше native SQL». Но в админском поиске по сущности (наш обычный backoffice use case) спецификации — очень рабочий инструмент.

3. Projections: читаем только нужное

Списки в админке — это место, где очень легко «случайно» начать читать слишком много данных. Особенно когда кажется, что «ну это же ORM, он разберётся». ORM разберётся… и честно принесёт вам то, что вы попросили, а вы потом честно будете разбираться, почему это грузится долго. Projections — это способ заранее поставить рамку: «для этого чтения мне нужны только эти поля».

В Spring Data есть два самых практичных типа проекций: interface-based и class-based (DTO/record). Оба варианта позволяют вернуть не Product, а «снимок» нужных полей. Такой результат обычно не становится managed entity, не участвует в dirty checking и не провоцирует lazy loading по навигации графа (потому что графа как сущности уже нет).

Начнём с самого простого: интерфейс для строки списка товаров.

package com.example.commerce.catalog.query;

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

public interface ProductSummary {
    String getSku();
    String getName();
    ProductStatus getStatus();
}

Теперь можно попросить репозиторий отдавать Page<ProductSummary> вместо Page<Product>. Для derived queries это выглядит удивительно «как будто так и было задумано»:

package com.example.commerce.catalog.repository;

import com.example.commerce.catalog.entity.Product;
import com.example.commerce.catalog.entity.ProductStatus;
import com.example.commerce.catalog.query.ProductSummary;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ProductRepository extends JpaRepository<Product, Long> {

    Page<ProductSummary> findByStatus(ProductStatus status, Pageable pageable);
}

Здесь важная мысль не в синтаксисе, а в контракте. Сигнатура уже говорит вам: «это метод для чтения списка, возвращает summary, работает постранично». Вызывающий код не тащит в себя Product и не получает искушение случайно начать дергать product.getDetails().getWarrantyMonths() в цикле.

Теперь про class-based projection. В Java 25 вполне приятно использовать record как DTO-результат:

package com.example.commerce.catalog.query;

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

public record ProductSummaryRow(
        String sku,
        String name,
        ProductStatus status
) { }

Для record-проекций часто удобно использовать явный @Query, с constructor expression, чтобы результат был максимально предсказуемым. Тогда вы буквально управляете тем, что окажется в SELECT.

import com.example.commerce.catalog.query.ProductSummaryRow;
import com.example.commerce.catalog.entity.ProductStatus;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.Query;

@Query("""
        select new com.example.commerce.catalog.query.ProductSummaryRow(
            p.sku, p.name, p.status
        )
        from Product p
        where p.status = :status
        """)
Page<ProductSummaryRow> loadRowsByStatus(ProductStatus status, Pageable pageable);

Интерфейс-проекция проще и чаще «просто работает» для типичных derived queries. DTO/record-проекция даёт больше контроля и обычно лучше читается на code review, потому что запрос явно показывает, что читаем.

Нужно ещё одно важное предупреждение, которое часто ломает ожидания новичка: projection не является «волшебным антипаттерном от всех бед». Она уменьшает ширину результата, но если вы начнёте в projection вытаскивать вложенные куски графа (например, «а давайте ещё категории»), то SQL может стать join-heavy. Иногда это нормально, иногда нет. Главное — снова держать принцип курса: смотрим на SQL и понимаем цену запроса, а не верим в магию аннотаций.

Ниже маленькая таблица, чтобы в голове не смешивались сущности и проекции:

Что возвращаем из репозитория Что это по смыслу Плюс Минус
Product write-модель, managed (если в транзакции) удобно менять и сохранять через dirty checking легко случайно притащить граф/overfetch/dirty checking overhead
ProductSummary read-модель (тонкая) быстрый старт, короткая сигнатура, читаемый контракт меньше контроля над формой запроса, нужно следить за nested полями
ProductSummaryRow read-модель (тонкая) максимальная предсказуемость, удобно для отчётных списков чаще нужен @Query, чуть больше кода

4. Paging: Page и Slice

Постраничное чтение — это одна из самых полезных привычек в backend-коде, потому что оно одновременно решает две проблемы: вы не тащите в память тысячи строк, и вы не заставляете базу формировать огромный result set «на всякий случай». Pageable в Spring Data — это как договор с базой: «дай мне ровно 20 строк, начиная с такой-то позиции, и отсортируй вот так».

В коде paging выглядит почти скучно, и это хорошо. Скучный код — часто самый надёжный.

import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;

Pageable pageable = PageRequest.of(
        0, 20,
        Sort.by("sku").ascending()
);

Теперь любой paging-метод репозитория может вернуть Page<T>. Но у Page есть особенность: чтобы посчитать totalElements и totalPages, Spring Data обычно выполняет второй запросcount(*). То есть «страница» часто означает два SQL-запроса: один за данными, другой за количеством.

Это поведение в целом логично, но иногда дорого. Поэтому стоит различать Page и Slice. Slice — это «дай мне ещё одну порцию данных и скажи, есть ли следующая». Он обычно не требует count-запроса, поэтому дешевле, если вам не нужно знать «всего страниц 123».

Небольшая табличка для ориентировки:

Возвращаемый тип Что умеет Цена
Page<T> данные + общее количество + общее число страниц часто 2 запроса (content + count)
Slice<T> данные + hasNext() обычно 1 запрос (без count)

В админке часто нужен именно Page, потому что UI любит рисовать «страницы 1…10…последняя». Но в некоторых сценариях (например, «покажи последние события» или «лента») Slice может быть удобнее.

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

Поэтому хорошая привычка: сортировать по чему-то устойчивому. Для Product таким кандидатом часто становится sku (у нас он уникален) или id. В Commerce Persistence Lab мы как раз держим уникальные ограничения на ключевые business keys (sku, email, orderNumber), и это помогает в таких вещах.

И ещё одна инженерная мысль, которую стоит держать рядом: сортировка и фильтры — это не только про Java-код. Это ещё и про индексы. Если вы сортируете по sku и фильтруете по status, логично иметь индекс под такие запросы. Мы подробно обсуждали индексы в mapping-модуле, так что сейчас просто связываем точки: paging — это не магия Spring Data, это соглашение «limit/offset + order by» на стороне SQL.

5. Сценарий: spec + projection + page

Давайте теперь соберём всё в один понятный сценарий из нашего домена: «админка каталога показывает список товаров, умеет фильтровать по статусу и по тексту, и делает это постранично, возвращая не entity, а summary-представление». Это очень типичный read use case, и он идеально показывает, зачем нам Specification + projection + paging одновременно.

Начнём с простого объекта критериев. В Java 25 для такого удобно использовать record. Он не делает никакой логики, просто переносит параметры.

package com.example.commerce.catalog.query;

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

public record ProductSearchCriteria(
        // По какому статусу фильтруем (если null — фильтра по статусу нет)
        ProductStatus status,
        // Текст для поиска (если null/blank — фильтра по тексту нет)
        String text
) { }

Теперь собираем Specification из критериев. Смысл здесь в том, чтобы if’ы жили в одном месте и не размазывались по сервису.

import com.example.commerce.catalog.entity.Product;
import org.springframework.data.jpa.domain.Specification;

public final class ProductSpecFactory {

    public static Specification<Product> from(ProductSearchCriteria c) {
        // Пустая спецификация, к которой дальше "приклеиваем" условия
        Specification<Product> spec = Specification.where(null);

        // Добавляем фильтры только если параметры реально заданы
        if (c.status() != null) spec = spec.and(ProductSpecifications.hasStatus(c.status()));
        if (c.text() != null && !c.text().isBlank()) spec = spec.and(ProductSpecifications.nameContains(c.text()));

        return spec;
    }
}

Да, тут есть if. Но это «контролируемый if», который собирает объект фильтра. Он не превращает бизнес-сервис в условный суп.

Теперь ключевой момент: как получить projection + page на основании specification. Если мы просто сделаем findAll(spec, pageable), мы получим entity. А нам хочется тонкий результат. Для этого в Spring Data удобно использовать fluent query API через findBy(spec, queryFunction).

import com.example.commerce.catalog.entity.Product;
import com.example.commerce.catalog.query.ProductSummary;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

Page<ProductSummary> page = productRepository.findBy(
        spec,
        q -> q.as(ProductSummary.class) // говорим: хотим projection, а не entity
              .page(pageable)           // говорим: хотим Page (обычно будет и content-query, и count-query)
);

В этом куске кода мне нравится то, что он честный. Мы явно говорим: «выполни фильтр spec, но результат отдай как ProductSummary, и сделай это постранично». Это намного ближе к мысли «репозиторий проектируется от чтения», чем «ну верни entity, а я там потом map() сделаю».

Если визуально, весь пайплайн выглядит так:

flowchart TD
    A["ProductSearchCriteria"] --> B["Specification<Product>"]
    B --> C["Repository.findBy(spec, ...)"]
    C --> D["SQL SELECT ... LIMIT/OFFSET"]
    C --> E["SQL SELECT COUNT(*)"]
    D --> F["Page<ProductSummary>"]
    E --> F

Почему тут два SQL? Потому что мы получили Page, а Page обычно хочет общее количество.

Очень полезно хотя бы один раз увидеть, как это выглядит в SQL (приблизительно). Для PostgreSQL и типичного paging это будет что-то в духе:

-- content query
select p.sku, p.name, p.status
from product p
where p.status = 'ACTIVE'
  and lower(p.name) like '%usb%'
order by p.sku asc
limit 20 offset 0;

-- count query
select count(*)
from product p
where p.status = 'ACTIVE'
  and lower(p.name) like '%usb%';

Это именно та точка, где deep-dive мышление становится практичным. Вы больше не воспринимаете репозиторный метод как «вызов Java». Вы видите: ага, у нас два запроса, один с limit/offset, один count. И дальше уже инженерные вопросы: «нормально ли это для админки», «есть ли индекс», «нет ли лишних join’ов», «не делаем ли мы flush перед чтением», и так далее. Вы начинаете разговаривать с системой на её родном языке — SQL. И здесь же хорошо видно, почему один универсальный ProductRepository быстро начинает скрывать два разных режима работы: write-side говорит языком managed-сущностей, а query-side — языком filters, projections и page-contract.

И ещё маленькая ремарка про transaction boundary. Чтение списка в админке обычно живёт в @Transactional(readOnly = true). Во-первых, так проще держать предсказуемость Hibernate-сессии. Во-вторых, это снижает риск «случайных» flush’ей и write-side эффектов (хотя совсем это не отменяет, если вы внутри транзакции всё же меняете managed-entity). То есть read use case и репозиторные запросы лучше делать «на чистых руках»: читаем — значит читаем.

6. Типичные ошибки: Specification, projection, paging

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

Ошибка №1: делать универсальный метод findAll() и потом фильтровать в Java.
Это выглядит как «я же программист, я сейчас stream().filter() сделаю». Но это почти всегда перенос тяжёлой работы из базы в приложение, плюс вы всё равно сначала тащите данные из БД. В итоге база делает бесполезный SELECT *, сеть таскает лишнее, JVM греется, а вы получаете «медленно, но зато на Java». Правильнее фильтровать на стороне SQL через Specification или явный query.

Ошибка №2: возвращать entity для списков “потому что так проще”, а потом случайно провоцировать граф и dirty checking.
Вы загрузили Page<Product>, потом где-то в UI-слое или сервисе начали форматировать вывод и «случайно» полезли в ленивую связь или поменяли поле (даже неосознанно). Hibernate честно начал работать: lazy догрузил, dirty checking увидел изменения, flush отправил UPDATE. И вот вы уже лечите «почему список товаров внезапно делает лишние запросы». Для списка лучше сразу вернуть projection и не давать коду соблазна обращаться к сущности как к универсальной модели.

Ошибка №3: не учитывать, что Page почти всегда означает count(*), и удивляться двойному SQL.
Очень частая реакция: «почему у меня два запроса на одну страницу?». Потому что Page обещает totalElements. Если total не нужен — Slice может быть дешевле. Если total нужен — тогда два запроса нормальны, просто вы должны помнить об этом при анализе SQL и при оценке стоимости сценария.

Ошибка №4: paging без устойчивой сортировки и “прыгающие страницы”.
Сегодня у вас товар на первой странице, завтра — на второй, и пользователи уверены, что «он исчез». Обычно причина — сортировка по неуникальному полю или отсутствие сортировки. Paging без order by почти всегда превращается в лотерею: база не обязана возвращать строки в одном и том же порядке.

Ошибка №5: в Specification добавлять сложные join’ы без осознания, что вы меняете форму запроса.
Сама по себе спецификация — это «кусочек WHERE», но она может втянуть в запрос join, distinct, группировки, а дальше count query становится сложнее и дороже. Это не «запрещено», но это тот случай, когда нужно особенно дисциплинированно смотреть SQL и понимать, что ваш «маленький фильтр по категории» на самом деле превращается в join по link entity.

1
Задача
Hibernate deep-dive, 27 уровень, 3 лекция
Недоступна
Проекция и пагинация для списка
Проекция и пагинация для списка
1
Задача
Hibernate deep-dive, 27 уровень, 3 лекция
Недоступна
`Specification` + projection + page
`Specification` + projection + page
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ