Опирается на правила:
R-JOOQ-MS-1…R-JOOQ-MS-4иR-JOOQ-MS-X1…R-JOOQ-MS-X2из jOOQ Style Guide → раздел 5. Multiset для nested-fetch.
Важно знать
multiset()— стандартный способ забратьOrderвместе со спискомTicketодним SQL-запросом. Заменяет N+1 и lazy-load из JPA.- Все alias-keys (
"tickets","insurances") — константы в одном файлеSelectMultisetAliasKeys. Magic-string в.as("...")запрещён.- Извлечение nested-результата — через
RecordMappingUtils.getPojoList(record, KEY, X.class), не через прямойrecord.get(KEY, Result.class).- Multiset вкладывается в multiset для 2+ уровней иерархии (
Order → Ticket → Insurance).- Multiset на тысячах parent'ов — медленно. В batch-find делаем два отдельных запроса: parent-IDs → children
WHERE parent_id IN (...)+ ручная зашивка.- Lazy-fetch вложенных коллекций (отдельный запрос из маппера) — запрещён. N+1 = регресс производительности.
multiset() — jOOQ-функция, которая встраивает подзапрос-список прямо в основной запрос. В результате одной транзакции PG возвращает строку Order + JSON-массив Ticket'ов как одно поле. На уровне Java это Result<TicketRecord> внутри OrderRecord. Это решает классическую боль ORM «один запрос на parent, N запросов на children»: jOOQ умеет складывать nested-коллекции в plan PostgreSQL'а как array_agg/json_agg, и PG отдаёт всё одним результатом.
Один запрос вместо N+1
Базовый паттерн — заказ с его билетами:
import static org.jooq.impl.DSL.*;
import static ru.example.adapter.out.postgres.SelectMultisetAliasKeys.*;
return dslContext
.select(
orders.asterisk(),
multiset(
selectFrom(tickets).where(tickets.ORDER_ID.eq(orders.ID))
).as(TICKETS)
)
.from(orders)
.where(orders.ID.eq(orderId))
.fetchOptional();
PG сгенерирует один запрос примерно такого вида:
SELECT orders.*, (
SELECT array_agg(t)
FROM (SELECT * FROM tickets WHERE order_id = orders.id) t
) AS tickets
FROM orders
WHERE orders.id = ?
Никаких N+1. Сравните с lazy-fetch:
// ПЛОХО — N+1
List<OrdersPojo> orders = dslContext.selectFrom(orders).fetchInto(OrdersPojo.class);
for (OrdersPojo o : orders) {
List<TicketsPojo> tickets = dslContext
.selectFrom(tickets).where(tickets.ORDER_ID.eq(o.getId()))
.fetchInto(TicketsPojo.class); // ← запрос в цикле
o.setTickets(tickets);
}
На 100 заказах это 101 запрос. На 1000 — 1001. Под нагрузкой это сразу видно в метриках. R-JOOQ-MS-X2 ловит этот антипаттерн на ревью.
Alias-keys — константы, не magic strings
R-JOOQ-MS-1: каждый .as("...")-ключ — константа в одном месте.
// persistence/.../postgres/SelectMultisetAliasKeys.java
public final class SelectMultisetAliasKeys {
public static final String TICKETS = "tickets";
public static final String INSURANCES = "insurances";
public static final String RECEIPTS = "receipts";
public static final String ITEMS = "items";
private SelectMultisetAliasKeys() {}
}
Зачем:
- Безопасность извлечения. В маппере мы пишем
record.get(TICKETS, ...)— IDE подскажет существующие ключи. Если бы былоrecord.get("tickets", ...)строкой, опечатка"tikets"(нетc) дала быnull-result в рантайме без warning'а. - Поиск использований. Найти все запросы, которые собирают
tickets, — простой Find Usages по константе. - Единый список того, что вообще есть в системе. Когда entity растёт, видно «о, у нас теперь 8 вложенных коллекций» — это сигнал, что агрегат разрастается.
R-JOOQ-MS-X1 ловит на ревью: .as("tickets") напрямую в запросе — это magic-string, нарушение.
Извлечение через RecordMappingUtils
R-JOOQ-MS-2: nested-результат вытаскиваем через утилиту, не приведением вручную.
// ХОРОШО
List<TicketsPojo> tickets = RecordMappingUtils.getPojoList(record, TICKETS, TicketsPojo.class);
Result<?> insuranceRecords = RecordMappingUtils.getRecordList(record, INSURANCES);
// ПЛОХО
@SuppressWarnings("unchecked")
Result<Record> ticketRecords = (Result<Record>) record.get(TICKETS, Result.class);
List<TicketsPojo> tickets = ticketRecords.into(TicketsPojo.class);
Утилита внутри делает примерно то же, что и (Result<?>) record.get(...), но:
- Скрывает unchecked cast — компилятор не ругается, ревью читается чище.
- Бросает осмысленный exception, если ключ отсутствует — а не
NullPointerExceptionчерез 5 строк. - Если завтра jOOQ поменяет API для multiset-извлечения, правка будет в одном месте.
Multiset вкладывается в multiset
R-JOOQ-MS-3: для иерархии Order → Ticket → Insurance — двухуровневый nested:
return dslContext
.select(
orders.asterisk(),
multiset(
select(
tickets.asterisk(),
multiset(
selectFrom(insurances).where(insurances.TICKET_ID.eq(tickets.ID))
).as(INSURANCES)
)
.from(tickets)
.where(tickets.ORDER_ID.eq(orders.ID))
).as(TICKETS)
)
.from(orders)
.where(orders.ID.eq(orderId))
.fetchOptional();
PG агрегирует всё в одном запросе: для каждого Order собирает массив Ticket-ов; для каждого Ticket — массив Insurance. Результат прилетает одной строкой с двухуровневым nested.
На уровне маппера разворачиваем тоже двухуровнево:
public Order toDomain(OrdersPojo order, List<TicketsPojo> ticketsPojos,
Map<Long, List<InsurancesPojo>> insurancesByTicket) {
List<Ticket> tickets = ticketsPojos.stream()
.map(t -> ticketMapper.toDomain(t, insurancesByTicket.get(t.getId())))
.toList();
return new Order(/* ... */, tickets);
}
Подробнее про assemble-логику — в Mapping record ↔ domain.
Когда multiset не подходит — batch-fetch
R-JOOQ-MS-4: при findAll на тысячах parent'ов multiset перестаёт быть эффективным.
Почему: PG соберёт один массив на каждый parent, размер result-row станет огромным. Транспорт от БД к приложению — основной bottleneck при больших выборках; multiset-result со встроенными массивами увеличивает его в разы.
Альтернатива — два отдельных запроса:
public PaginationView<Order> findAll(OrderFilter filter, int page, int size, SelectMode mode) {
// 1. parents — без multiset, узкий запрос
Result<OrdersRecord> orderRecords = dslContext
.selectFrom(orders)
.where(conditionBuilder.build(filter))
.orderBy(toSortFields(filter.sort()))
.limit(size).offset((long) page * size)
.pipe(applyLock(mode))
.fetch();
if (orderRecords.isEmpty()) {
return PaginationView.empty(page, size);
}
// 2. children — отдельный запрос с IN (...)
Set<Long> orderIds = orderRecords.stream().map(OrdersRecord::getId).collect(toSet());
Map<Long, List<TicketsPojo>> ticketsByOrderId = dslContext
.selectFrom(tickets)
.where(tickets.ORDER_ID.in(orderIds))
.fetchInto(TicketsPojo.class)
.stream()
.collect(groupingBy(TicketsPojo::getOrderId));
// 3. зашивка в маппере
List<Order> result = orderRecords.stream()
.map(o -> mapper.assembleAggregate(o.into(OrdersPojo.class),
ticketsByOrderId.getOrDefault(o.getId(), List.of())))
.toList();
long total = dslContext.fetchCount(orders, conditionBuilder.build(filter));
return new PaginationView<>(result, total, result.size(), page, size);
}
Два запроса с IN (...) обычно быстрее одного multiset на большом списке. Граница — нет жёсткого числа, на практике берут multiset до 200–500 parent'ов на страницу, дальше — batch-fetch. Точная граница определяется бенчмарком на проде.
findById(id) — всегда multiset, parent один.
Куда дальше
- jOOQ Style Guide → раздел 5. Multiset — нормативные формулировки.
- Маппинг record ↔ domain —
assembleAggregateи unwrap multiset-результата. - Построение запросов — про
select(...)vsselectFrom(...). - Filter builders — как организовать WHERE для multiset-запросов.