1. Матриця вибору: сенс
Якщо чесно, найтиповіша проблема розробника в коді з активним використанням Hibernate не в тому, що він «не знає анотацій». Проблема в тому, що він знає занадто багато кнопок, а вибирає за звичкою: «усюди entity», «усюди JOIN FETCH», «усюди save()», «усюди native SQL» — потрібно підкреслити, зайве закомітити й молитися. Матриця вибору — це спосіб повернути собі спокій: ви не вгадуєте, а ставите кілька правильних запитань і отримуєте логічну відповідь.
Осі аудиту вже показують, де болить. Але знайдений сигнал марний, якщо далі лікування вибирається за рефлексом: побачили багато SQL — поставили JOIN FETCH, побачили detached-дані — підсунули merge(), побачили масове оновлення — закрутили save() у циклі. Потрібна матриця, яка спочатку розмежовує read і write, а вже потім звужує інструмент.
Уявіть, що ви прийшли на кухню. Вам потрібно нарізати салат. На столі лежать бензопила, сокира, скальпель, ножиці й нормальний кухонний ніж. Формально салат можна зробити майже всім із цього списку. Але є відчуття, що частина інструментів не призначена для щоденної роботи. У Hibernate те саме: StatelessSession або bulk update — це не «погані» інструменти, вони просто не для кожного сценарію. І навпаки, entity loading — не «зло», він просто дорогий там, де потрібен легкий запит на читання.
У межах Commerce Persistence Lab це особливо видно: у нас є каталог, замовлення, склад, аудит. Одні сценарії схожі на «таблицю в адмінці», інші — на «детальну картку замовлення», треті — на «масове переоцінювання каталогу». Помилка починається рівно в момент, коли ми беремо один стиль і намагаємося ним розв’язати все, ніби Hibernate — швейцарський ніж, а не набір різних інструментів.
2. Read vs write: розвилка
Найкорисніша «первинна розвилка» — не JOIN FETCH vs EntityGraph, а набагато простіше: ми зараз читаємо чи пишемо? Звучить банально, але саме на цьому етапі найчастіше зʼявляються дорогі помилки на кшталт «прочитав сутність для списку й випадково потягнув managed-граф» або «хотів масово оновити 50k рядків, а зробив цикл по entity».
Нижче — спрощена схема, якої достатньо, щоб мозок перестав панікувати й почав думати як інженер, а не як шаман із бубном (у бубон можна, але пізніше — після роботи).
flowchart TD
A["Сценарій"] --> B{"Читання чи запис?"}
B -->|Читання| C{"Список / деталі / звіт?"}
C -->|Список| D["Projection (DTO / interface)"]
C -->|Деталі| E["Entity loading + fetch-plan"]
C -->|Звіт| F["Projection або native SQL"]
B -->|Запис| G{"Один агрегат чи багато рядків?"}
G -->|Один агрегат| H["find + mutate (managed)"]
G -->|Багато рядків| I["bulk update/delete або StatelessSession"]
Зверніть увагу на мораль діаграми: інструменти не «змагаються» один з одним, вони займають різні клітинки. Projection не конкурує з optimistic locking, тому що це різні сценарії. Bulk update не конкурує з EntityGraph, тому що один — про масові записи, а другий — про форму читання.
Швидкий тест: чи потрібна вам managed entity
Є просте внутрішнє запитання, яке майже завжди допомагає: «Я збираюся змінювати стан об’єкта в цій операції?». Якщо відповідь «ні, я просто показую список/звіт/вітрину», то managed entity вам, найімовірніше, не потрібна. А якщо managed entity не потрібна, то й багато пов’язаних проблем (dirty checking, випадкові UPDATE, графи, lazy-пастки) ви навіть не кличете на вечірку.
Якщо відповідь «так, я змінюю статус замовлення, перейменовую товар, резервую залишок», то managed entity стає майже неминучою: вона дає вам нормальну unit of work-модель, версіонування, коректний flush, каскади (якщо вони доречні) і взагалі весь ORM-комфорт, заради якого ви це все й почали.
У Commerce Persistence Lab це виглядає так: список товарів для адмінки — зазвичай read-сценарій, картка товару — read-сценарій, але детальний, а зміна ціни товару або резервування залишку — write-сценарії, де managed сутність цілком доречна.
Тобто матриця потрібна не для гарної схеми на стіні. Потім ці самі розвилки спливають у найзвичайнішому коді: entity там, де потрібен list-row DTO; saveAndFlush() там, де вистачило б managed update; bulk там, де продовжують мислити managed-об’єктами.
3. Read-сценарії: список, картка, звіт
Про читання в ORM часто думають так: «ну це ж просто findAll() і потім .map(...)». І ось тут Hibernate починає тихо плакати в куточку, тому що «просто» перетворюється на N+1, на надлишкові графи, на випадкову ініціалізацію колекцій і на читання цілого світу заради двох колонок. Тому в матриці вибору важливо одразу розрізняти форму результату: список, деталі або звіт.
Список — це зазвичай табличний формат: багато рядків, але мало колонок, і майже ніколи не потрібен увесь граф. Картка — це зазвичай «одна сутність + кілька пов’язаних даних», причому зв’язки мають бути вибрані свідомо. Звіт — це часто агрегати, GROUP BY, суми, статистика, де ORM-мова іноді починає «опиратися» і чесніше перейти на native SQL або хоча б на спеціалізовану проєкцію.
Список/таблиця: projection як базовий кандидат
Для списків (особливо backoffice-таблиць) projection — це майже ідеальний варіант за замовчуванням. Вона не тягне managed-граф, не створює зайвий dirty checking, не провокує lazy-навігацію випадково і, що важливо, робить контракт читання явним: ось рівно ці поля ми віддаємо, і нічого «само» не підтягнеться, навіть якщо десь є @ManyToOne(fetch = EAGER) (який ми, звісно, не любимо).
Почнемо з простого DTO на record — це дуже дружній формат для списку.
import com.example.commerce.catalog.entity.ProductStatus;
public record ProductListRow(
Long id,
String sku,
String name,
ProductStatus status
) {}
Тепер репозиторій, який віддає «рядки для таблиці», а не сутності:
import com.example.commerce.catalog.dto.ProductListRow;
import com.example.commerce.catalog.entity.Product;
import com.example.commerce.catalog.entity.ProductStatus;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
import java.util.List;
// Read-репозиторій: тут ми повертаємо проєкцію, а не managed-сутність
public interface ProductQueryRepository extends Repository<Product, Long> {
// JPQL-конструктор: формуємо DTO одразу в запиті, щоб не тягнути entity-граф
@Query("""
select new com.example.commerce.catalog.dto.ProductListRow(p.id, p.sku, p.name, p.status)
from Product p
where p.status = :status
""")
List<ProductListRow> findListRows(ProductStatus status);
}
Зверніть увагу на маленьку, але важливу «архітектурну педантичність»: ми не зобов’язані змішувати write-репозиторій (JpaRepository<Product, Long>) і read-запити в один інтерфейс. У проєкті ми й раніше рухалися до розділення ...repository і ...query. Матриця вибору чудово живе в такій структурі: список — це query-шар, а не entity-операція.
Картка/деталі: entity loading + сценарний fetch-plan
Картка товару або детальні дані замовлення — це сценарії, де entity loading уже виправдане. Не тому, що «так простіше», а тому, що вам може знадобитися навігація по зв’язках, логіка доменної моделі, lazy-поля за запитом, а іноді й подальша мутація в тій самій транзакції (наприклад, «відкрили замовлення і відразу змінили статус» — не найкращий UX, але як навчальний приклад годиться).
Ключовий момент: коли ви обираєте entity loading для detail-use case, ви автоматично обираєте ще одну річ — fetch plan. Тобто ви вирішуєте, які асоціації мають бути завантажені в межах цього сценарію. Не «назавжди в анотаціях», а саме під use case.
Найпростіший варіант: EntityGraph для детального замовлення.
import com.example.commerce.orders.entity.PurchaseOrder;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.repository.Repository;
import java.util.Optional;
public interface OrderDetailsRepository extends Repository<PurchaseOrder, Long> {
// ВАЖЛИВО: це сценарний fetch-plan — для цього методу гарантуємо customer і items
@EntityGraph(attributePaths = {"customer", "items"})
Optional<PurchaseOrder> findDetailedById(Long id);
}
Такий метод не обіцяє, що «усюди в замовлення завжди будуть items», він обіцяє конкретне: у цьому сценарії ми завантажуємо customer і items. Це як замовити піцу «із сиром і грибами», а не купувати одразу холодильник піци «про всяк випадок».
Reporting-like читання: native SQL як чесна межа
Звітні запити часто вимагають агрегатів і групувань, а іноді й досить «пласких» результатів. Можна змусити JPQL виражати це, можна побудувати Criteria, але іноді простіше й чесніше написати SQL. Це нормально. ORM — інструмент, а не релігія.
Для прикладу: «скільки штук кожного SKU було продано» (умовний sales summary). Зробимо інтерфейс-проєкцію, щоб не тягнути Object[].
public interface SalesSummaryRow {
String getSku();
long getTotalQty();
}
І native query у репозиторії:
import com.example.commerce.orders.entity.OrderItem;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
import java.util.List;
public interface SalesReportRepository extends Repository<OrderItem, Long> {
// Native SQL доречний для агрегатів: плаский результат, GROUP BY, sum(...)
// Повертаємо інтерфейс-проєкцію, щоб не працювати з Object[]
@Query(value = """
select p.sku as sku, sum(oi.quantity) as totalQty
from order_item oi
join product p on p.id = oi.product_id
group by p.sku
""", nativeQuery = true)
List<SalesSummaryRow> findSalesSummary();
}
Тут важливий не сам SQL, а місце в матриці: звітний read-сценарій — це кандидат на native SQL, тому що він «плаский», агрегатний і не потребує managed-сутностей. Це допомагає не тягнути ORM-механіку туди, де вона не приносить користі.
4. Toolbox для fetch-plan
Коли ми вирішили «так, entity потрібна», матриця вибору перемикається на наступний рівень: як завантажити необхідні зв’язки без N+1 і без надмірного завантаження. Тут дуже легко звалитися в крайність: або «все EAGER», або «все JOIN FETCH», або «я взагалі нічого не буду завантажувати, хай lazy сам розбирається». Зрілою відповіддю зазвичай є середина: ви обираєте інструмент під форму сценарію.
Є три типові інструменти, які найчастіше покривають 80% задач: JOIN FETCH, EntityGraph, batch fetching. Ми їх уже розбирали раніше, але зараз нам потрібне саме рішення-орієнтоване розуміння: коли який брати в матриці.
JOIN FETCH: швидко, потужно, але іноді «роздуває» результат
JOIN FETCH — це як пилосос: він справді вміє прибрати N+1, але якщо ви ввімкнете його на максимальну потужність і в маленькій кімнаті, він почне засмоктувати не тільки пил, а й килим, і кота, і вашу самооцінку. Проблема JOIN FETCH не в тому, що він поганий, а в тому, що він змінює форму результату й може роздувати result set, особливо на колекціях.
Для детального читання одного замовлення з items JOIN FETCH часто доречний:
import com.example.commerce.orders.entity.PurchaseOrder;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
import java.util.Optional;
public interface OrderFetchJoinRepository extends Repository<PurchaseOrder, Long> {
// JOIN FETCH прибирає N+1 ціною "роздування" результату: особливо обережно з колекціями
@Query("""
select po
from PurchaseOrder po
join fetch po.items
where po.id = :id
""")
Optional<PurchaseOrder> findByIdWithItems(Long id);
}
У матриці це виглядає так: detail-read, один root, одна колекція — JOIN FETCH часто хороший кандидат. А ось list-read + paging + fetch join на колекціях — це вже червоний прапорець (але докладні обмеження ми залишимо в голові як «перевірити», не перетворюючи лекцію на повтор попередніх днів).
EntityGraph: керований fetch-plan без переписування запитів
EntityGraph зручний, коли ви хочете «той самий запит», але з різним завантаженням асоціацій під різні сценарії. Він добре лягає у Spring Data, де методи читання вже описані, а ви додаєте до них сценарний fetch-plan. І часто це читається простіше, ніж JPQL із кількома join fetch.
Приклад «детальне замовлення: клієнт + позиції + продукт кожної позиції» можна виразити графом:
import com.example.commerce.orders.entity.PurchaseOrder;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.repository.Repository;
import java.util.Optional;
public interface OrderGraphRepository extends Repository<PurchaseOrder, Long> {
@EntityGraph(attributePaths = {"customer", "items", "items.product"})
Optional<PurchaseOrder> findById(Long id);
}
У матриці це хороший кандидат, коли вам потрібна entity, але хочеться тримати запит «звичайним» і керувати завантаженням як налаштуванням сценарію. Це схоже на перемикач фар: машина та сама, але режим освітлення інший.
Batch fetching: «нехай буде кілька запитів, але розумних»
Batch fetching — це компроміс: ви приймаєте, що буде кілька SQL-запитів, але хочете, щоб вони були групованими, а не N+1. Це особливо доречно, коли JOIN FETCH зробить один величезний і важкий запит (наприклад, повторення кореневих рядків, ширина результату), а вам достатньо кількох «secondary selects», але не по одному на кожен рядок.
На рівні mapping це може виглядати так (приклад для PurchaseOrder.items):
import org.hibernate.annotations.BatchSize;
import jakarta.persistence.OneToMany;
import jakarta.persistence.FetchType;
import java.util.ArrayList;
import java.util.List;
class PurchaseOrder {
// BatchSize допомагає згрупувати lazy-завантаження колекцій (не N+1, а "пакетами")
@BatchSize(size = 20)
// LAZY залишаємо, щоб граф не "вибухав" завжди; дозавантажуємо свідомо за сценарієм
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> items = new ArrayList<>();
}
У матриці це часто виглядає так: «ми читаємо список замовлень, і іноді нам потрібно пройтися по items, але JOIN FETCH занадто дорогий». Тоді batch fetching — нормальний, керований компроміс: не магія, а свідома ціна.
5. Write: find + mutate
У write-сценаріях головна пастка не у fetch-планах, а в стилі оновлення. Найпередбачуваніший і найзрозуміліший патерн для «змінити одну сутність/агрегат» — це find + mutate всередині транзакції. Він звучить нудно, але саме нудні рішення найчастіше виявляються найнадійнішими (на відміну від «красивого універсального merge всього графа одразу»).
Приклад із каталогу: перейменувати товар. Жодного save() «про всяк випадок» не потрібно, якщо сутність managed.
import com.example.commerce.catalog.entity.Product;
import com.example.commerce.catalog.repository.ProductRepository;
import org.springframework.transaction.annotation.Transactional;
@Transactional // Транзакція потрібна, щоб сутність стала managed і спрацював dirty checking
public class ProductService {
private final ProductRepository productRepository;
public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
public void renameProduct(long productId, String newName) {
// Завантажуємо managed-сутність і змінюємо стан — Hibernate сам зробить UPDATE на flush/commit
Product product = productRepository.findById(productId).orElseThrow();
product.setName(newName);
}
}
У матриці це саме той випадок: write-use case, один агрегат, зрозуміла транзакція, Hibernate робить dirty checking і відправляє UPDATE там, де має. Ви не робите «зайвих рухів» і не створюєте зайвих SELECT/flush, які потім доведеться діагностувати так, ніби це баг ORM.
6. Масові зміни: bulk і синхронізація
Коли змінюються багато рядків за одним і тим самим правилом, entity-цикл майже завжди програє. Причина проста: entity-цикл платить за матеріалізацію об’єктів, snapshots, dirty checking, можливо каскади — і все це заради операції, яка за змістом є «однією SQL-командою для багатьох рядків». Bulk update/delete — це спосіб чесно сказати: «мені не потрібен об’єктний світ, мені потрібно змінити набір рядків».
Саме тут виграш у продуктивності одразу тягне за собою питання коректності: що тепер є правдою для вже завантажених managed-об’єктів?
Приклад: масово змінити статус усім товарам каталогу (умовна адміністративна операція).
import com.example.commerce.catalog.entity.Product;
import com.example.commerce.catalog.entity.ProductStatus;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
public interface ProductBulkRepository extends Repository<Product, Long> {
// Bulk іде напряму в БД і не синхронізує вже завантажені managed-сутності
@Modifying
@Query("""
update Product p
set p.status = :status
where p.deleted = false
""")
int changeStatusForCatalog(ProductStatus status);
}
Ключовий сенс для матриці: bulk — це не «прискорювач взагалі всього», це інструмент для сценарію «багато рядків, одне правило». А далі одразу вмикається наступне запитання матриці: «а що з persistence context?», тому що bulk обходить managed-модель.
Синхронізація після bulk
Після bulk-операції важливо не продовжувати жити так, ніби нічого не сталося. Bulk змінює БД напряму, а вже завантажені managed-об’єкти залишаються зі старим станом. Канонічний протокол тут такий: bulk змінює рядки, а не managed-об’єкти. Тому після нього або очищуємо контекст і читаємо заново, або тримаємо bulk в окремому unit of work. clearAutomatically і flushAutomatically бувають зручні як зручний варіант, але спочатку краще тримати в голові саме цей причинно-наслідковий зв’язок.
І ще один практичний момент: якщо перед bulk у цій самій транзакції у вас уже накопичилися зміни managed-сутностей, спочатку окремо вирішіть питання їхнього flush. Інакше ви змішуєте дві моделі роботи й потім дивуєтеся послідовності SQL.
Якщо хочеться показати це явно на сервісі, можна зробити так:
import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public class CatalogAdminService {
private final ProductBulkRepository bulkRepository;
private final EntityManager entityManager;
public CatalogAdminService(ProductBulkRepository bulkRepository, EntityManager entityManager) {
this.bulkRepository = bulkRepository;
this.entityManager = entityManager;
}
public int hideAllProducts() {
int updated = bulkRepository.changeStatusForCatalog(ProductStatus.HIDDEN);
// Після bulk старі managed-об'єкти більше не можна вважати правдою
entityManager.clear();
return updated;
}
}
Якщо після цього сценарію потрібно знову дивитися на ті самі товари, робимо нове читання вже після clear(). Не розраховуємо, що старі посилання магічно оновилися.
StatelessSession: важкі сценарії
StatelessSession у Hibernate — це як вантажівка: вона чудова, коли потрібно перевезти багато, але дивно їздити на ній у магазин за хлібом. Вона вимикає звичні речі (persistence context, first-level cache, автоматичний dirty checking) і тим самим стає корисною для спеціальних high-volume сценаріїв, де звичайна managed-модель занадто дорога.
Наприклад, масове вставлення InventorySnapshot у лабораторних сценаріях перевірки продуктивності (ідея схематична, без деталей інфраструктури):
import jakarta.persistence.EntityManager;
import org.hibernate.Session;
import org.hibernate.StatelessSession;
public class SnapshotImportService {
private final EntityManager entityManager;
public SnapshotImportService(EntityManager entityManager) {
this.entityManager = entityManager;
}
public void insertSnapshot(Object snapshotEntity) {
Session session = entityManager.unwrap(Session.class);
try (StatelessSession ss = session.getSessionFactory().openStatelessSession()) {
ss.insert(snapshotEntity);
}
}
}
У матриці це потрапляє в клітинку «дуже великий обсяг, простий конвеєр, не потрібна managed-магія». І дуже важливо пам’ятати другу половину фрази: не потрібна managed-магія. Якщо вона потрібна — StatelessSession перетворюється з вантажівки на проблему.
7. Зведена матриця вибору
Хочеться, щоб після лекції у вас залишилася не «купа знань», а маленька опора для мозку. Нижче — компактна шпаргалка-матриця. Вона не замінює мислення, але допомагає не впадати в крайнощі й швидко звузити вибір до 1–2 кандидатів, які потім перевіряються по SQL, статистиці та тестах.
| Сценарій використання в Commerce Persistence Lab | Перший кандидат | Чому це зазвичай правильно | Коли перемикатися на інший інструмент |
|---|---|---|---|
| Список товарів (таблиця в адмінці) | Projection (DTO/record) | Вузький результат, немає керованого графа, менший ризик N+1 і випадкових оновлень | Потрібні «деталі»/зв’язки прямо в списку → EntityGraph/batch fetching; потрібен агрегатний звіт → native SQL |
| Картка товару (Product + ProductDetails) | Entity + fetch-plan | Це detail-read, де entity-модель зручна, але важливо керувати завантаженням | Якщо дані суворо пласкі → projection; якщо звіт → native SQL |
| Деталі замовлення (PurchaseOrder + items) | EntityGraph або JOIN FETCH | Потрібен граф під один root; можна прибрати N+1 керовано | Для списків із пагінацією краще не робити fetch join колекцій; іноді краще batch fetching |
| Змінити один товар/замовлення | find + mutate | Передбачувана unit of work, dirty checking, мінімум сюрпризів | Якщо прийшов detached-граф і хочеться «склеїти все одразу» → обережно й свідомо, але найчастіше все одно краще find + mutate |
| Масова зміна каталогу (repricing/status) | bulk update/delete | Один SQL на багато рядків замість дорогого entity-циклу | Якщо після bulk потрібно продовжувати роботу з entity в тій самій транзакції — обов’язковий план синхронізації (clear/re-read) |
| Великий імпорт/очищення історичних даних | StatelessSession / bulk | Мінімум накладних витрат, контроль обсягу | Якщо потрібна managed-логіка, каскади, версія, складні зв’язки — повернутися до звичайної моделі + batching |
| Звіт: продажі/агрегати | native SQL + projection | Чесна форма запиту, прозорість, не тягнемо ORM-граф | Якщо запит простий і лягає в JPQL — можна залишитися в JPQL |
Якщо хочеться «стиснути» матрицю в один псевдокод, то він виглядає приблизно так (і так, це спеціально проста формула — ви не зобов’язані робити з неї філософію):
якщо read:
якщо список → projection
якщо деталі → entity + fetch-plan
якщо звіт → projection або native SQL
якщо write:
якщо один агрегат → find + mutate
якщо багато рядків → bulk update/delete
якщо дуже великий обсяг pipeline → StatelessSession (вузько)
8. Типові помилки під час вибору підходу
Помилки у виборі інструменту зазвичай виглядають не як «код не компілюється», а як «все працює, але чомусь боляче». Болить у проді, болить під навантаженням, болить на підтримці, а іноді боляче й даним (це найнеприємніше). Тому краще пам’ятати кілька типових «зривів матриці» й заздалегідь упізнавати їх у коді, як знайомих персонажів у серіалі: «о, це знову ви».
Помилка №1: повертати entity в будь-якому read-сценарії «тому що так звично».
Майже завжди це призводить до того, що список перетворюється на міні-граф сутностей, а потім хтось випадково торкається getCustomer() у стрімі, і ви отримуєте N+1. У матриці для списку default — projection, а entity — це свідомий виняток.
Помилка №2: лікувати все JOIN FETCH, навіть коли сценарій — paging-список.
JOIN FETCH чудово допомагає на детальному читанні, але в списках із колекціями може роздувати результат і ламати очікування. Матриця спеціально розділяє «detail read» і «list read»: для списку частіше простіше й дешевше projection або batch fetching.
Помилка №3: робити bulk update і продовжувати читати/змінювати вже завантажені managed-об’єкти, ніби нічого не сталося.
Bulk — це операція «повз» persistence context. Тому після bulk або контекст очищується, або сценарій будується так, щоб не було ілюзій «я вже читав товар, отже він оновився сам». Матриця не забороняє bulk, вона вимагає дисципліни після вибору bulk.
Помилка №4: намагатися merge() як універсальний “save всього, що прийшло”.
merge() може бути корисний, але як універсальний стиль він майже завжди робить поведінку дорогою й непередбачуваною: зайві SELECT, неочікувані side effects на графі, каскади, дублікати дітей. Для «звичайної зміни» матриця віддає перевагу find + mutate, тому що це керований сценарій.
Помилка №5: використовувати native SQL «про всяк випадок», щоб не думати про ORM.
Native SQL — нормальний інструмент, але він має з’являтися там, де дає виграш у ясності або виразності (агрегати, звіти, складні join-форми). Якщо задача — звичайний список товарів, native SQL часто лише ускладнить підтримку. Матриця спочатку пропонує projection/JPQL, а native — як чесну межу, а не як дефолт.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ