💾 Розділ 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 нативно.

Пов’язані теми