Що таке 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
- JPQL завжди йде в базу даних: На відміну від
find(), JPQL-запит не може бути розв’язаний виключно в оперативній пам’яті. Він завжди транслюється у фізичний SQLSELECT. - Гарантія ідентичності при отриманні результату: Коли СУБД повертає рядки, Hibernate звіряє їхні первинні ключі з
entitiesByKey. Якщо об’єкт із таким ID вже завантажений у сесію якManaged, сирі дані з БД відкидаються, а застосунок отримує посилання на вже існуючий об’єкт. - 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 у базу?
Відповідь:
- SQL
SELECTфізично виконається в базу даних (JPQL ніколи не пропускає звернення до СУБД). - Перед виконанням запиту спрацює
Auto-Flush, і Hibernate відправитьUPDATE users SET name = 'Нове'у БД. - Об’єкт, повернений із
getResultList(), матиме ім’я “Нове”. Більше того, це буде суворо той самий Java-екземпляр у Heap (assert user == resultUser;) завдяки Identity Map кешу першого рівня L1.
2. Чим відрізняється JOIN від JOIN FETCH у JPQL?
Відповідь:
- Звичайний
JOIN(наприклад,SELECT o FROM Order o JOIN o.items i) використовується лише для фільтрації або агрегації. Пов’язані дочірні сутностіitemsне ініціалізуються і залишаються лінивими проксі. При зверненні доo.getItems()виникне проблема $N+1$. JOIN FETCH(наприклад,SELECT o FROM Order o JOIN FETCH o.items i) вказує Hibernate не тільки зробити SQLJOIN, але й одразу заповнити дочірню колекціюitemsоб’єктами в пам’яті за один єдиний SQL-запит, вирішуючи проблему $N+1$.
3. Чому масовий запит entityManager.createQuery("DELETE FROM User u WHERE u.status = 'BANNED'").executeUpdate() не викликає колбеки @PreRemove і ламає кеш L1?
Відповідь:
- Масові запити DML (
UPDATEтаDELETEу JPQL) транслюються безпосередньо в один єдиний SQL-запит до СУБД, повністю оминаючи контекст персистентності. - Сутності не завантажуються в пам’ять JVM, тому колбеки життєвого циклу (
@PreRemove,@PostRemove) не виконуються. - Якщо зачеплені сутності вже знаходилися в кеші першого рівня L1, вони залишаться там у застарілому стані, створюючи ризик розсинхронізації пам’яті та бази даних.
4. Чому запит SELECT COUNT(u) FROM User u повертає Long, а не Integer?
Відповідь:
- Специфікація JPA жорстко фіксує типи результатів агрегатних функцій:
COUNTзавжди повертаєjava.lang.Long(щоб запобігти переповненню при розмірі таблиці понад 2.14 млрд рядків).AVGзавжди повертаєjava.lang.Double.SUMповертає тип агрегованого поля (Longдля цілочисельних,BigDecimalдля дробових).
- Спроба привести результат до
(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 нативно.