1. Роль native SQL рядом с JPQL/HQL
До сих пор мы меняли способ описывать запрос, оставаясь внутри entity-модели: сначала JPQL/HQL, потом programmatic where через Criteria и Specification. Но бывает другая боль: форма чтения уже сама табличная — агрегаты, group by, vendor-specific функции, wide joins, отчётные строки.
Если вы только-только привыкли к JPQL, то идея «писать SQL руками» может ощущаться как откат в каменный век. Но на практике это не откат, а смена передачи: иногда вы едете по городу (JPQL), иногда вам нужен внедорожник (native SQL). Hibernate не запрещает вам SQL — он честно говорит: «Окей, если тебе нужно говорить языком таблиц и колонок — говори, я выполню это в рамках транзакции».
В самом простом виде native SQL — это запрос, который вы передаёте Hibernate как SQL-строку, без этапа «перевода из HQL/JPQL». То есть вы пишете не:
select o from PurchaseOrder o join o.customer c ...
а:
select o.id, o.order_number, c.email
from purchase_order o
join customer c on c.id = o.customer_id
...
И тут важно уловить ключевой смысл слова «честная граница». JPQL/HQL пытается быть объектным и зависеть от mapping-модели. Native SQL напрямую зависит от физической схемы: имён таблиц, колонок, индексов, типов, функций конкретной БД. В нашем курсе это не абстрактно: у нас PostgreSQL, схема под Flyway, и мы по-настоящему видим, что именно уходит в БД.
Чтобы мозгу было проще, держите такую мини-схему:
flowchart TD
A[Service layer] --> B[EntityManager]
B --> C{Какой запрос?}
C -->|JPQL/HQL| D[Hibernate переводит в SQL]
C -->|Native SQL| E[SQL уже готов, без перевода]
D --> F[JDBC]
E --> F[JDBC]
F --> G[(PostgreSQL)]
И JPQL, и native SQL в итоге доходят до PostgreSQL. Разница в том, кто отвечает за «форму SQL» — Hibernate или вы.
2. Когда native SQL — хороший выбор
Native SQL часто воспринимают как «последнюю надежду, когда Hibernate бесит». Но в нормальной инженерной жизни это должно быть не эмоциональным решением, а осознанным выбором. Здесь полезно думать не про красоту API, а про то, как выглядит правильная форма результата: какие колонки вам нужны, какие агрегаты, какая группировка, какая сортировка, сколько join’ов — и насколько это естественно описывается через entity-модель.
Ниже — несколько типовых ситуаций, где native SQL выглядит честнее и проще. Я не буду превращать это в “список всех возможных SQL-приёмов на планете”, но мы зафиксируем интуицию, чтобы вы не боялись этой границы.
| Сценарий чтения в Commerce Persistence Lab | Почему ORM-язык начинает сопротивляться | Почему SQL “естественнее” |
|---|---|---|
| Отчёт «сколько заказов в каждом статусе» | Нужно group by, хочется ровно два поля результата, entity-граф не нужен | SQL коротко выражает агрегат и не тащит сущности |
| Отчёт «топ клиентов по сумме заказов за период» | Join + агрегация + фильтры по диапазону дат, результат — не сущность | SQL напрямую описывает табличный результат |
| Сложные выборки “как в отчётах”: wide join, много вычисляемых колонок | JPQL становится длинным, трудно читаемым и не всегда поддерживает нужные функции | SQL позволяет явно управлять формой результата |
| DB-specific функции (PostgreSQL): date_trunc, ILIKE, иногда оконные функции | JPQL стандартизирован, а vendor-функции требуют костылей | В SQL это выглядит нормально и предсказуемо |
Поэтому примеры ниже нарочно отчётные. На обычном поиске заказов по статусу и email native SQL тоже сработает, но его сильная сторона там видна хуже: в таких кейсах он скорее показывает цену прямого контроля, чем выступает default-инструментом.
Главная идея: native SQL чаще всего нужен именно для read-model, особенно когда результат — “строки отчёта”, а не объектный граф. Это очень хорошо сочетается с тем, что мы делали вчера (проекции): вместо загрузки managed-entity вы читаете узкий результат.
И ещё один важный момент: в рамках курса мы не делаем из native SQL религию. Мы не «переписываем всё на SQL». Мы учимся видеть момент, когда ORM перестаёт помогать, и тогда честно выбираем другой инструмент.
3. Цена native SQL
Когда вы пишете native SQL, вы покупаете контроль — но, как и в любой покупке, есть чек. И чек иногда неприятнее, чем кажется на первой радостной секунде «ура, я написал SQL, и оно работает».
Во‑первых, запрос привязывается к схеме. В JPQL вы пишете Product и p.status, а в SQL пишете product и status. Если завтра вы переименовали колонку, поменяли naming strategy или разнесли таблицу — запрос сломается, и IDE не поможет вам рефакторингом так, как помогла бы с Java-кодом.
Во‑вторых, результат нужно маппить. В JPQL/Criteria вы можете вернуть DTO-конструкторную проекцию очень аккуратно. В native SQL вы чаще получаете Object[] или скалярные значения, и вы сами отвечаете за преобразование типов. Это не страшно, но требует дисциплины: count(*) в PostgreSQL легко прилетает как BigInteger, а не как Long, и если вы попытаетесь сделать (Long) row[1], то Java честно скажет вам “ClassCastException” и будет права.
В‑третьих, падает переносимость между базами. В нашем курсе это не проблема, потому что baseline — PostgreSQL, и мы не играем в «а давайте то же самое на Oracle». Но в реальных проектах это может быть фактором.
В‑четвёртых, native SQL не отменяет Hibernate-реальность. Запрос всё равно выполняется внутри transaction boundary, всё равно может триггерить flush, всё равно подчиняется вашей настройке open-in-view=false, и всё равно должен быть понятен по SQL trace. То есть это не «параллельная вселенная» — это просто другой вход в ту же систему.
Именно поэтому хорошая практика — держать native SQL локально и аккуратно: в .../query пакете, с читаемым текстовым блоком SQL, с понятным названием метода, который возвращает DTO (а не «что-нибудь»).
4. Где хранить native SQL в проекте
В Commerce Persistence Lab у нас архитектура package-by-feature, и это прямо помогает держать native SQL под контролем. Если начать раскидывать SQL-строки по сервисам, то через неделю вы получите «кладбище строковых констант», а потом будете бояться трогать любой запрос, потому что «вдруг сломается отчёт, который непонятно кто использует».
Нормальный путь — завести в фиче отдельный query-компонент. Например, для отчётов по заказам:
- com.example.commerce.orders.dto — маленькие DTO/records под результаты,
- com.example.commerce.orders.query — классы, которые выполняют запросы и возвращают DTO.
Пример каркаса (без Lombok — помним про правила проекта):
package com.example.commerce.orders.query;
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Repository;
@Repository
public class OrderReportQueryRepository {
// EntityManager — наша точка входа в JPA, через неё выполняем native queries
private final EntityManager entityManager;
public OrderReportQueryRepository(EntityManager entityManager) {
// Внедряем EntityManager через конструктор, чтобы репозиторий был тестопригодным
this.entityManager = entityManager;
}
}
Почему это хорошо? Потому что сервисный слой остаётся «про бизнес-сценарий», а query-слой — «про форму чтения». И native SQL как раз относится ко второму.
5. Рецепт native SQL: scalar + DTO
Самый дружелюбный способ начать с native SQL — это не пытаться сразу маппить результат в entity (это часто приводит к путанице), а сделать скалярный результат: то есть получить пару колонок, а затем превратить их в маленький DTO. Это ровно то, что обычно нужно для отчётов и админских списков.
DTO для результата: считаем заказы по статусам
Сначала создадим простейший record под результат:
package com.example.commerce.orders.dto;
/**
* Одна строка отчёта: статус заказа и количество заказов в этом статусе.
*/
public record OrderStatusCountRow(String status, long count) { }
Теперь пишем native SQL. Предположим, таблица заказов называется purchase_order, а колонка статуса — status. (В реальном проекте имена зависят от ваших миграций и naming strategy, но идея одна: в SQL мы говорим именами схемы, а не полями Java-класса.)
package com.example.commerce.orders.query;
import com.example.commerce.orders.dto.OrderStatusCountRow;
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public class OrderReportQueryRepository {
private final EntityManager entityManager;
public OrderReportQueryRepository(EntityManager entityManager) {
this.entityManager = entityManager;
}
public List<OrderStatusCountRow> countOrdersByStatus() {
// SQL удобнее держать в Text block: читается почти как в psql
String sql = """
select o.status, count(*) as cnt
from purchase_order o
group by o.status
order by o.status
""";
// createNativeQuery возвращает "сырые" строки: обычно это Object[] по колонкам
List<Object[]> rows = entityManager.createNativeQuery(sql).getResultList();
return rows.stream()
.map(r -> {
// r[0] — статус (строка), r[1] — агрегат count(*)
// Агрегаты лучше приводить через Number: JDBC-драйверы могут вернуть разные числовые типы
return new OrderStatusCountRow((String) r[0], ((Number) r[1]).longValue());
})
.toList();
}
}
Обратите внимание на маленький, но очень важный приём: мы приводим r[1] не к Long, а к Number. Это позволяет пережить разные JDBC-типы, которые драйвер может вернуть для count(*). Для новичка это выглядит как «зачем так сложно», но на практике это один из самых частых источников внезапных падений в runtime.
Вызываем query из сервиса (и держим транзакцию в голове)
Чтобы это было похоже на нормальное приложение, обернём чтение в сервис, который явно показывает границу транзакции. На read-case обычно уместно readOnly=true.
package com.example.commerce.orders.service;
import com.example.commerce.orders.dto.OrderStatusCountRow;
import com.example.commerce.orders.query.OrderReportQueryRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class OrderReportService {
private final OrderReportQueryRepository queryRepository;
public OrderReportService(OrderReportQueryRepository queryRepository) {
this.queryRepository = queryRepository;
}
@Transactional(readOnly = true)
public List<OrderStatusCountRow> getOrderStatusStats() {
// Сервис описывает бизнес-операцию, а не детали SQL: просто возвращаем DTO-результат
return queryRepository.countOrdersByStatus();
}
}
Важно: даже если вы возвращаете DTO, транзакция всё равно полезна. Она даёт предсказуемое поведение EntityManager/Session и не позволяет случайно «уехать» в detached‑мир, где кто-то ожидает ленивую загрузку (хотя в нашем случае мы как раз специально её избегаем).
6. Join и агрегаты в native SQL
Когда вы впервые берёте native SQL в руки, очень хочется применить его «для всего подряд». Но лучше делать наоборот: брать SQL там, где он действительно в своей стихии. Самый натуральный пример — отчёт по заказам, где нужны вычисляемые поля: количество позиций, сумма по позициям, email клиента, дата последнего изменения и так далее.
Представим задачу из backoffice: мы хотим получить список заказов и для каждого заказа посчитать, сколько в нём позиций и какова сумма по позициям. Да, часть этих данных может быть уже хранится в PurchaseOrder.totalAmount, но именно в лабораторном проекте полезно показать, что отчёт можно собрать и из order_item — чтобы почувствовать, как ORM и SQL отличаются по подходу.
DTO под “строку отчёта”
package com.example.commerce.orders.dto;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
/**
* Одна "строка отчёта" по заказу (не entity!).
* Тут ровно те поля, которые нужны на экране/в выгрузке.
*/
public record OrderSummaryRow(
long orderId,
String orderNumber,
String customerEmail,
long itemsCount,
BigDecimal itemsTotal,
OffsetDateTime createdAt
) { }
6.2. Native SQL-запрос
package com.example.commerce.orders.query;
import com.example.commerce.orders.dto.OrderSummaryRow;
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Repository;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.List;
@Repository
public class OrderSummaryQueryRepository {
private final EntityManager entityManager;
public OrderSummaryQueryRepository(EntityManager entityManager) {
this.entityManager = entityManager;
}
public List<OrderSummaryRow> findOrderSummaries() {
// Здесь "форма результата" описана прямо в SELECT: join + агрегаты + сортировка
String sql = """
select
o.id,
o.order_number,
c.email,
count(oi.id) as items_count,
coalesce(sum(oi.price_at_purchase_amount * oi.quantity), 0) as items_total,
o.created_at
from purchase_order o
join customer c on c.id = o.customer_id
left join order_item oi on oi.order_id = o.id
group by o.id, o.order_number, c.email, o.created_at
order by o.created_at desc
""";
List<Object[]> rows = entityManager.createNativeQuery(sql).getResultList();
return rows.stream()
.map(r -> new OrderSummaryRow(
// id и агрегаты — через Number, чтобы не зависеть от конкретного JDBC-типа
((Number) r[0]).longValue(),
(String) r[1],
(String) r[2],
((Number) r[3]).longValue(),
// sum(...) в PostgreSQL обычно возвращается как BigDecimal — это ожидаемо для денег
(BigDecimal) r[4],
// Важно следить за тем, какой тип дат возвращает драйвер (OffsetDateTime/ZonedDateTime/Timestamp)
(OffsetDateTime) r[5]
))
.toList();
}
}
Да, здесь есть детали, которые зависят от вашей схемы. Я намеренно показываю мысль, а не «универсальный SQL на все времена». В нашем проекте Money и сумма могут храниться по-другому (например, amount + currency). Но на уровне идеи всё честно: SQL видит таблицы и колонки, и вы можете построить отчётную строку прямо там, где она рождается естественно — в select с join и group by.
Ещё одна важная деталь: я использовал coalesce(sum(...), 0), чтобы сумма не стала null, если у заказа нет позиций (например, заказ только создан и ещё пуст). В JPQL вы тоже можете сделать что-то похожее, но в SQL это обычно читается проще и привычнее.
7. Native SQL и persistence context
Есть распространённая иллюзия: «если запрос native, значит Hibernate тут ни при чём». Это неверно. Hibernate тут очень при чём — просто он не переводит запрос, а исполняет его.
7.1. Flush перед запросом и “внезапные INSERT/UPDATE”
Мы уже разбирали flush: Hibernate может отправить изменения в БД до выполнения запроса, чтобы запрос увидел корректные данные. Это относится и к JPQL, и к Criteria, и к native SQL.
Представьте сцену (в стиле плохого ситкома): вы внутри транзакции поменяли статус заказа, но ещё не коммитнули. Потом запускаете отчётный native SQL, который считает заказы по статусам. Если Hibernate не сделает flush, то SQL увидит старые статусы, и результат будет “неправильным” по отношению к текущему unit of work. Поэтому Hibernate может решить: «Сначала синхронизируем контекст, потом читаем».
Практический вывод спокойный и прагматичный: если вы пишете отчётные native queries, старайтесь выполнять их в сценариях, где вы не смешиваете тяжёлую запись и отчётное чтение в одной транзакции без необходимости. В противном случае вы будете удивляться: «я же только читаю, откуда UPDATE?».
7.2. Возврат entity из native SQL
Иногда хочется сделать так: «я напишу SQL, но пусть Hibernate вернёт мне Product». Это возможно:
// Возвращаем entity-класс, а не DTO: это уже "ORM-режим", со всеми его нюансами
String sql = """
select *
from product
where status = :status
""";
List<?> products = entityManager
// Важно: вторым параметром передаём класс сущности, чтобы Hibernate попытался собрать entity
.createNativeQuery(sql, Product.class)
// Параметры всегда биндится, не конкатенируются
.setParameter("status", status)
.getResultList();
Но здесь начинаются нюансы, из-за которых новичку проще сразу держать правило: native SQL в этом дне мы используем как scalar/DTO-read. Если вы возвращаете entity, вы снова входите в мир managed-объектов, dirty checking, возможных lazy-связей и случайных N+1 уже после запроса. Плюс вам нужно аккуратно выбрать колонки так, чтобы Hibernate мог собрать entity корректно.
В большинстве отчётных сценариев entity вам не нужен. Вам нужна “строка отчёта” — и это как раз DTO.
8. Дисциплина для native SQL
Native SQL пугает не потому, что он сложный. Он пугает, потому что он легко превращается в хаос, если нет правил. И, честно говоря, хаос обычно появляется не в самой SQL-строке, а вокруг неё: где она лежит, как она тестируется, как она документирована, как она поддерживается.
Хорошая дисциплина для нашего курса выглядит так.
Сама SQL-строка должна быть написана как SQL, а не как «конкатенация пяти строк и трёх if’ов». В Java 25 текстовые блоки (""") — это подарок судьбы: можно писать запрос почти как в psql, с отступами и переносами. Это повышает читаемость в разы.
Параметры всегда должны биндинговаться через setParameter(...), а не через "... where status = '" + status + "'". Во‑первых, это защита от SQL-инъекций (даже если вы думаете, что «у нас тут внутренний сервис» — он всё равно будет внутренним ровно до первого инцидента). Во‑вторых, это делает SQL trace понятнее: вы видите параметры отдельно, а не “кашу” в строке.
Результат запроса должен иметь маленький DTO/record. Даже если вам кажется, что Object[] «и так норм», на практике DTO — это дешёвая страховка: вы фиксируете контракт результата в типах, и следующий читатель кода понимает, что за «пять колонок» вернулось.
И наконец, native SQL лучше держать в query-слое, а не в сервисе. Сервис должен говорить: «дай мне List<OrderSummaryRow>», а не «дай мне List<Object[]>, и я сам угадаю, что в нём лежит».
9. Типичные ошибки в native SQL
Ниже — те ошибки, которые встречаются почти всегда, когда человек впервые добавляет native SQL в Hibernate-проект. Не потому что люди глупые, а потому что мозг ещё живёт в модели “ORM всё сам”, а SQL требует чуть больше ручной дисциплины.
Ошибка №1: писать в SQL “как в JPQL” и путать имена полей с именами колонок.
Очень частый момент: в голове есть PurchaseOrder.createdAt, и рука пишет createdAt в SQL. Но SQL живёт в схеме: скорее всего, колонка называется created_at. В результате запрос просто не компилируется на стороне БД, и вы получаете ошибку “column does not exist”. Лечится просто: перед тем как писать SQL, мысленно переключитесь в режим “я говорю с таблицами”, а не с entity.
Ошибка №2: использовать select * и надеяться, что так “проще”.
select * кажется удобным, пока схема маленькая. Потом добавляется колонка, потом меняются типы, потом запрос внезапно начинает тащить лишние данные, и вы уже не понимаете, что именно вы читаете. В отчётных сценариях select * почти всегда вреден: вы хотите ровно те колонки, которые нужны для результата. Это делает и SQL понятнее, и mapping устойчивее.
Ошибка №3: приводить типы слишком “в лоб” и получать ClassCastException.
count(*) вернулся как BigInteger, sum(...) вернулся как BigDecimal, а вы ожидаете Long или Integer. Лечится простым правилом: агрегаты чаще приводите через Number, а суммы храните в BigDecimal, даже если “в UI всё равно long”.
Ошибка №4: рассчитывать, что результат native SQL — это managed entity-объекты (и что у них будет lazy-граф).
Если вы читаете Object[] или DTO, то это не managed entity. Это просто данные. У них нет lazy loading, dirty checking, persistence context и прочей “ORM-магии”. Это плюс, а не минус: DTO безопасно отдавать наружу, хранить, логировать, сравнивать, и они не начнут внезапно стрелять SQL при вызове toString().
Ошибка №5: удивляться flush’у перед запросом (“я же читаю, откуда UPDATE?”).
Flush — часть unit of work, и он может сработать перед запросом, чтобы запрос был корректным. Native SQL не отменяет flush-модель Hibernate. Если вы внутри транзакции меняете managed‑сущности, а потом запускаете native query — будьте готовы увидеть SQL на запись раньше, чем ожидали. В курсах по ORM это один из самых полезных моментов для взросления: SQL выполняется не “по настроению разработчика”, а по правилам консистентности.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ