💾 Раздел 16 · Вопрос #26

Что такое JPQL и чем он отличается от SQL

В классическом SQL запрос пишется к физическим таблицам базы данных. В JPQL запрос пишется к классам Java-сущностей:

JPQL (Java Persistence Query Language) — это объектно-ориентированный декларативный язык запросов, определенный спецификацией JPA. В отличие от стандартного SQL, оперирующего таблицами, строками и физическими колонками реляционной СУБД, JPQL работает с Java-сущностями, их полями и объектными ассоциациями, абстрагируя приложение от диалекта конкретной базы данных.


🟢 Junior Level

Главная разница: Объекты против Таблиц

В классическом SQL запрос пишется к физическим таблицам базы данных. В JPQL запрос пишется к классам Java-сущностей:

// SQL (Зависит от схемы БД и имен таблиц/колонок):
// SELECT * FROM app_users WHERE user_age >= 18;

// JPQL (Оперирует классами Entity и полями в Java Heap):
TypedQuery<User> query = entityManager.createQuery(
    "SELECT u FROM User u WHERE u.age >= :minAge", 
    User.class
);
query.setParameter("minAge", 18);
List<User> adults = query.getResultList();

Сравнительная таблица: JPQL vs SQL

Характеристика JPQL (Java Persistence) SQL (Structured Query Language)
Единица адресации Класс сущности (User) Физическая таблица (app_users)
Поля и свойства Свойства Java (u.birthDate) Колонки таблицы (birth_date)
Навигация по связям Точечная нотация (o.user.address.city) Явные конструкции JOIN ... ON ...
Переносимость Независим от диалекта (DB-Agnostic) Привязан к синтаксису конкретной СУБД
Полиморфизм Поддерживает полиморфные запросы по иерархии Требует ручных UNION / CASE
Регистрозависимость Ключевые слова регистронезависимы, но имена классов и полей чувствительны к регистру Зависит от настроек СУБД

Базовые примеры синтаксиса JPQL

// 1. Фильтрация и параметры
String jpql1 = "SELECT u FROM User u WHERE u.email = :email";

// 2. Объектный JOIN по имени связи (без условия ON!)
String jpql2 = "SELECT o FROM Order o JOIN o.items i WHERE i.price > 1000";

// 3. Агрегатные функции
String jpql3 = "SELECT COUNT(u), AVG(u.age) FROM User u WHERE u.active = true";

// 4. Безопасная параметризация (Защита от SQL Injection)
User user = entityManager.createQuery(jpql1, User.class)
    .setParameter("email", "alex@mail.com") // Обязательно экранируется провайдером!
    .getSingleResult();

🟡 Middle Level

Продвинутые возможности JPQL

1. DTO-проекции через конструктор (SELECT new ...)

Позволяет извлекать только необходимые колонки напрямую в плоский Java Record/DTO, не материализуя тяжелые Managed сущности в кэше L1:

public record UserSummaryDto(Long id, String fullName, String email) {}

List<UserSummaryDto> dtos = entityManager.createQuery("""
    SELECT new com.example.dto.UserSummaryDto(u.id, u.name, u.email)
    FROM User u
    WHERE u.active = true
    """, UserSummaryDto.class).getResultList();

2. Полиморфные запросы и операторы TYPE и TREAT

Если сущность Payment имеет наследников CardPayment и CryptoPayment:

// Полиморфная выборка: вернет все типы платежей
List<Payment> all = entityManager.createQuery("FROM Payment", Payment.class).getResultList();

// Фильтрация по конкретному подклассу через TYPE:
List<Payment> cardsOnly = entityManager.createQuery(
    "SELECT p FROM Payment p WHERE TYPE(p) = CardPayment", 
    Payment.class
).getResultList();

// Приведение типа на лету (Downcasting) через TREAT:
List<Payment> visaPayments = entityManager.createQuery(
    "SELECT p FROM Payment p WHERE TREAT(p AS CardPayment).cardNumber LIKE '4%'", 
    Payment.class
).getResultList();

Неявные (Implicit) против Явных (Explicit) JOIN

Опасная ловушка новичков в JPQL — точечная навигация по полям связей:

// Неявный JOIN (Implicit Join):
List<Order> orders = entityManager.createQuery(
    "SELECT o FROM Order o WHERE o.user.address.city = 'Москва'", 
    Order.class
).getResultList();
  • Hibernate автоматически разворачивает выражение o.user.address.city в два внутренних INNER JOIN.
  • Проблема: Неявные джойны всегда транслируются в INNER JOIN. Если у заказа user == null или у пользователя address == null, такой заказ молча выпадет из выборки.
  • Рекомендация: Всегда использовать явные JOIN (LEFT JOIN / INNER JOIN), чтобы полностью контролировать семантику выборки.

🔴 Senior Level

Внутренний пайплайн компиляции JPQL в Hibernate 6

В Hibernate 6 подсистема выполнения запросов была полностью переписана на базе нового движка SQM (Semantic Query Model):

                  АРХИТЕКТУРА ТРАНСЛЯЦИИ ЗАПРОСОВ В HIBERNATE 6
                  
 [ JPQL / HQL Строка ]
          │
          ├──> 1. Лексический/Синтаксический анализ (Antlr 4)
          ▼
 [ SQM AST (Semantic Query Model) ] ──> Валидация против Metamodel JPA
          │
          ├──> 2. SqlAstTranslator (с учетом Dialect СУБД)
          ▼
 [ SQL AST ] 
          │
          ├──> 3. Генерация финального SQL-текста
          ▼
 [ Нативный SQL: SELECT t0.id... FROM app_users t0 ... ] ──> Драйвер JDBC

Взаимодействие JPQL с PersistenceContext и умный Auto-Flush

  1. JPQL всегда идет в базу данных: В отличие от find(), JPQL-запрос не может быть разрешен исключительно в оперативной памяти. Он всегда транслируется в физический SQL SELECT.
  2. Гарантия идентичности при получении результата: Когда СУБД возвращает строки, Hibernate сверяет их первичные ключи с entitiesByKey. Если объект с таким ID уже загружен в сессию как Managed, сырые данные из БД отбрасываются, а приложение получает ссылку на уже существующий объект.
  3. Smart Auto-Flush по пространствам запросов (Query Spaces): Перед выполнением JPQL Hibernate проверяет, какие таблицы модифицировались в текущей сессии. Если измененные таблицы пересекаются с таблицами из JPQL, Hibernate автоматически сбрасывает (flush) только эти сущности перед выполнением SELECT.

Революция Hibernate 6: Оконные функции и CTE прямо в HQL/JPQL

Исторически для сложных аналитических отчетов приходилось переходить на нативный SQL (createNativeQuery). В Hibernate 6 HQL/JPQL официально поддерживает современные SQL-стандарты:

// Оконные функции (Window Functions) прямо в JPQL:
List<Object[]> rankedOrders = entityManager.createQuery("""
    SELECT o.id, o.amount, 
           ROW_NUMBER() OVER (PARTITION BY o.user.id ORDER BY o.amount DESC) 
    FROM Order o
    """, Object[].class).getResultList();

// Обобщенные табличные выражения (Common Table Expressions / CTE):
List<User> topUsers = entityManager.createQuery("""
    WITH UserTotals AS (
        SELECT o.user.id as userId, SUM(o.amount) as total
        FROM Order o
        GROUP BY o.user.id
    )
    SELECT u FROM User u JOIN UserTotals ut ON u.id = ut.userId
    WHERE ut.total > 100000
    """, User.class).getResultList();

4 Tricky Questions

1. Мы загрузили User(id=1), изменили его имя в памяти user.setName("Новое"), а затем выполнили JPQL SELECT u FROM User u WHERE u.id = 1. Какое имя вернет объект из JPQL, и выполнится ли SQL SELECT в базу?

Ответ:

  1. SQL SELECT физически выполнится в базу данных (JPQL никогда не пропускает обращение к СУБД).
  2. Перед выполнением запроса сработает Auto-Flush, и Hibernate отправит UPDATE users SET name = 'Новое' в БД.
  3. Объект, возвращенный из getResultList(), будет иметь имя “Новое”. Более того, это будет строго тот же самый Java-экземпляр в Heap (assert user == resultUser;) благодаря Identity Map кэша первого уровня L1.

2. Чем отличается JOIN от JOIN FETCH в JPQL?

Ответ:

  1. Обычный JOIN (например, SELECT o FROM Order o JOIN o.items i) используется только для фильтрации или агрегации. Связанные дочерние сущности items не инициализируются и остаются ленивыми прокси. При обращении к o.getItems() возникнет проблема $N+1$.
  2. JOIN FETCH (например, SELECT o FROM Order o JOIN FETCH o.items i) указывает Hibernate не только сделать SQL JOIN, но и сразу заполнить дочернюю коллекцию items объектами в памяти за один единственный SQL-запрос, решая проблему $N+1$.

3. Почему массовый запрос entityManager.createQuery("DELETE FROM User u WHERE u.status = 'BANNED'").executeUpdate() не вызывает колбэки @PreRemove и ломает кэш L1?

Ответ:

  1. Массовые запросы DML (UPDATE и DELETE в JPQL) транслируются напрямую в один единственный SQL-запрос к СУБД, полностью минуя контекст постоянства.
  2. Сущности не загружаются в память JVM, поэтому колбэки жизненного цикла (@PreRemove, @PostRemove) не выполняются.
  3. Если затронутые сущности уже находились в кэше первого уровня L1, они останутся там в устаревшем состоянии, создавая риск рассинхронизации памяти и базы данных.

4. Почему запрос SELECT COUNT(u) FROM User u возвращает Long, а не Integer?

Ответ:

  1. Спецификация JPA жестко фиксирует типы результатов агрегатных функций:
    • COUNT всегда возвращает java.lang.Long (чтобы предотвратить переполнение при размере таблицы более 2.14 млрд строк).
    • AVG всегда возвращает java.lang.Double.
    • SUM возвращает тип агрегируемого поля (Long для целочисленных, BigDecimal для дробных).
  2. Попытка привести результат к (Integer) query.getSingleResult() завершится ClassCastException.

🎯 Шпаргалка для интервью

30-секундный ответ (Elevator Pitch)

«JPQL — это объектный язык запросов JPA, работающий с сущностями и их свойствами, а не с физическими таблицами и колонками СУБД. Главное отличие от SQL: запросы независимы от диалекта СУБД, поддерживают точечную навигацию по связям, полиморфизм по иерархии наследования (TYPE, TREAT) и DTO-конструкторы (SELECT new ...). При этом JPQL всегда выполняет физический SQL-запрос в БД, синхронизируя результат с кэшем L1. В современном Hibernate 6 HQL/JPQL получил полную поддержку оконных функций (OVER / PARTITION BY) и CTE (WITH ...), практически устранив необходимость в нативном SQL для аналитических выборок».

Ключевые метрики для Production-мониторинга

  • Длительность фазы парсинга SQM / JPQL: компиляция сложных запросов кэшируется в QueryPlanCache, низкий Hit Ratio кэша планов перегружает CPU.
  • Количество неявных JOIN (Implicit Cross Joins): выявляет скрытые деградации планов выполнения в СУБД.

Красные флаги (Чего говорить НЕЛЬЗЯ)

  • ❌ «JPQL берет данные из кэша L1 и не делает SQL SELECT, если сущность уже загружена» — JPQL всегда обращается к базе данных через физический SQL.
  • ❌ «Использую имена таблиц базы данных в JPQL запросах» — JPQL принимает только имена Java-классов сущностей.
  • ❌ «Склеиваю параметры через конкатенацию строк в JPQL» — Прямая уязвимость к SQL Injection, параметры обязаны биндиться через :param.
  • ❌ «JPQL не умеет делать оконные функции и CTE, поэтому я всегда пишу Native Query» — В Hibernate 6 HQL и JPQL поддерживают оконные функции и CTE нативно.

Связанные темы