Опирается на правила: R-JOOQ-MS-1R-JOOQ-MS-4 и R-JOOQ-MS-X1R-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(...) vs selectFrom(...).
  • Filter builders — как организовать WHERE для multiset-запросов.