JavaRush /Курсы /Hibernate deep-dive /Native SQL — граница...

Native SQL — граница ORM

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

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 выполняется не “по настроению разработчика”, а по правилам консистентности.

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