Опирается на правила: R-JOOQ-QRY-1R-JOOQ-QRY-7 и R-JOOQ-QRY-X1R-JOOQ-QRY-X6 из jOOQ Style Guide → раздел 4. Построение запросов.

Важно знать

  • Static imports DSL.* и SelectMultisetAliasKeys.* — в каждом файле репозитория. Запрос читается как SQL.
  • Plain SQL запрещён. dslContext.fetch("..."), condition("status = " + value) — SQL-injection и обход type-safety.
  • Bind-параметры — позиционные через DSL (.where(ID.eq(id))), не строки.
  • Выбор fetch-метода под цель: fetchOptional если null допустим, fetchOne если нет, fetchExists вместо fetchCount() > 0, fetchCount только для пагинации.
  • UPDATE — через update(...).set(...).where(...), не через record.setX().store() — оно скрывает что именно обновляется.
  • EXISTS-подзапросы для related-таблиц через DSL.exists(select(...).from(...)), не через JOIN + GROUP BY.
  • Сортировка — private List<SortField<?>> toSortFields(Sort<X>) хелпер, переключающий domain-enum на jOOQ-колонки.

jOOQ — это DSL, который выглядит как SQL: selectFrom(orders).where(orders.STATUS.eq(...)).fetch(). Это и есть его главное преимущество — запрос читается линейно, как было бы в plain SQL, но всё type-safe и без конкатенации строк. Раздел 4 гайда — про дисциплину работы с этим DSL. Раскрытие правил R-JOOQ-QRY-* ниже.

Static imports — обязательно

R-JOOQ-QRY-1: в каждом файле репозитория:

import static org.jooq.impl.DSL.*;
import static ru.example.adapter.out.postgres.SelectMultisetAliasKeys.*;

Без них код будет таким:

return dslContext
    .select(orders.ID, orders.STATUS, org.jooq.impl.DSL.multiset(
        org.jooq.impl.DSL.select(...).from(...)).as("tickets"))
    .from(orders)
    .where(orders.STATUS.eq(...))
    .fetch();

С static imports — таким:

return dslContext
    .select(orders.ID, orders.STATUS, multiset(
        select(...).from(...)).as(TICKETS))
    .from(orders)
    .where(orders.STATUS.eq(...))
    .fetch();

Второй вариант читается как plain SQL. Это и есть цель.

selectFrom vs select(...)

R-JOOQ-QRY-2: два паттерна, под две задачи.

selectFrom(TABLE) — когда нужны все колонки таблицы, дальше .fetchInto(Pojo.class):

return dslContext
    .selectFrom(orders)
    .where(orders.ID.eq(id))
    .fetchOptionalInto(OrdersPojo.class);

select(TABLE.FIELD1, TABLE.FIELD2, multiset(...)) — когда проектируем отдельные колонки или собираем nested-fetch через multiset:

return dslContext
    .select(
        orders.ID,
        orders.STATUS,
        multiset(buildItemsSelect()).as(ITEMS))
    .from(orders)
    .where(orders.ID.eq(id))
    .fetchOptional();

Не путать: select(orders.fields()) — это select всех полей (синоним selectFrom), но без явных flags для типизации Record. На практике используется реже.

Fetch-методы — под цель

R-JOOQ-QRY-3: выбор fetch-метода = заявка на то, какой результат вы ждёте. И, что важно, как jOOQ поведёт себя при отклонении от ожидаемого.

МетодКогдаЧто делает на отклонение
.fetchOptional() / .fetchOptionalInto(X.class)Single-row, отсутствие — нормальный кейсВозвращает Optional.empty()
.fetchOne()Single-row, отсутствие — багNoDataFoundException
.fetchOne() при >1 строкеTooManyRowsException
.fetch() / .fetchInto(X.class)Список (может быть пустым)Пустой Result<> / List<>
.fetchExists()Проверка существованияboolean
.fetchCount()Точное число (для пагинации)int

Самые частые ошибки — R-JOOQ-QRY-X4 (.fetchOne() на «нормальном» отсутствии) и R-JOOQ-QRY-X5 (.fetchCount() > 0 вместо .fetchExists()):

// ПЛОХО
Order order = dslContext.selectFrom(orders).where(orders.ID.eq(id)).fetchOne();
// если строки нет — NoDataFoundException, обрабатываем как 500

// ХОРОШО
Optional<Order> order = dslContext.selectFrom(orders).where(orders.ID.eq(id))
    .fetchOptionalInto(Order.class);
// если строки нет — Optional.empty(), handler сам решит как реагировать (404)
// ПЛОХО — на больших таблицах sequential scan
if (dslContext.selectFrom(orders).where(orders.CUSTOMER_ID.eq(cid)).fetchCount() > 0) {
    // ...
}

// ХОРОШО — PG оптимизатор сразу останавливается на первой найденной строке
if (dslContext.fetchExists(selectOne().from(orders).where(orders.CUSTOMER_ID.eq(cid)))) {
    // ...
}

Insert / Update / Delete — через DSL

R-JOOQ-QRY-4: пишем явно, не через record-mutate.

// INSERT
dslContext.insertInto(orders)
    .set(orders.CUSTOMER_ID, customerId)
    .set(orders.STATUS, OrderStatus.PENDING)
    .set(orders.CREATED_AT, clock.now())
    .execute();

// INSERT с возвратом auto-generated id
Long id = dslContext.insertInto(orders)
    .set(orders.CUSTOMER_ID, customerId)
    .returning(orders.ID)
    .fetchOne()
    .getId();

// UPDATE
dslContext.update(orders)
    .set(orders.STATUS, OrderStatus.CANCELLED)
    .set(orders.CANCELLED_AT, clock.now())
    .where(orders.ID.eq(orderId))
    .execute();

// DELETE
dslContext.deleteFrom(orders).where(orders.ID.eq(orderId)).execute();

R-JOOQ-QRY-X3: НЕ используем record.setX(...); record.store():

// ПЛОХО
OrdersRecord rec = dslContext.fetchOne(orders, orders.ID.eq(orderId));
rec.setStatus(OrderStatus.CANCELLED);
rec.store();

Что не так:

  • Не видно по коду, какие именно поля обновляются. Если позже добавите ещё rec.setSomething(...) — оно тоже улетит в UPDATE.
  • store() под капотом делает UPDATE по всем полям (или INSERT, если record новый), не только по изменённым. Это лишний write-overhead и риск перезаписать поле, которое параллельно обновил другой запрос.
  • В логах SQL непросто понять, что произошло.

update(table).set(field, value).where(...) — явно, аудит-friendly, без сюрпризов.

Bind-параметры — позиционные

R-JOOQ-QRY-5: всегда через DSL-операторы. Никогда — строкой.

// ХОРОШО — bind через PreparedStatement, type-safe
.where(orders.STATUS.eq(OrderStatus.PENDING))
.where(orders.CREATED_AT.between(from, to))
.where(orders.CUSTOMER_ID.in(customerIds))

// ПЛОХО
.where(condition("status = '" + status + "'"))     // SQL-injection
.where(condition("created_at > ?", from))           // bind есть, но обход type-safety

R-JOOQ-QRY-X1 и R-JOOQ-QRY-X2: plain SQL (dslContext.fetch("SELECT ...")) и condition("col = " + value) запрещены. Это:

  • SQL-injection на ровном месте. Любая строка из API, попавшая в конкатенацию, — дыра.
  • Type-safety обход. jOOQ помечает такие методы аннотацией @PlainSQL. Если в коде ревью видит @PlainSQL — это red flag.
  • Меняется схема → код не падает на компиляции. Колонку переименовали в миграции — condition("status = ...") всё ещё компилируется и упадёт только в рантайме.

Исключение, когда @PlainSQL оправдан: window-функции или сложные PG-специфичные конструкции (rollup, lateral join), которые jOOQ DSL не покрывает. В таких случаях — condition("col = ? AND ...", bind1) с bind-параметрами, никогда не конкатенация.

Сортировка — отдельный helper

R-JOOQ-QRY-6: domain знает Sort<OrderSortField> с полями enum CREATED_AT, TOTAL_AMOUNT. Маппер на jOOQ-колонки — private helper:

private List<SortField<?>> toSortFields(Sort<OrderSortField> sort) {
    return sort.fields().stream()
        .map(f -> switch (f.field()) {
            case CREATED_AT -> orders.CREATED_AT.sort(f.direction());
            case TOTAL_AMOUNT -> orders.TOTAL_AMOUNT.sort(f.direction());
            case STATUS -> orders.STATUS.sort(f.direction());
        })
        .toList();
}

switch (enum) с exhaustive-pattern — компилятор поймает, если в OrderSortField добавили значение, а в helper'е забыли. Это дешевле, чем рантайм-баг «сортировка по STATUS не работает».

EXISTS вместо JOIN + GROUP BY

R-JOOQ-QRY-7: проверка «у заказа есть платёж» — через EXISTS, не JOIN.

// ХОРОШО — оптимизатор PG может остановиться на первом совпадении
.where(exists(selectOne()
    .from(payment)
    .where(payment.ORDER_ID.eq(orders.ID).and(payment.STATUS.eq(PaymentStatus.PAID)))))

// ПЛОХО — JOIN тянет все колонки payment, потом GROUP BY orders.ID
.from(orders)
.leftJoin(payment).on(payment.ORDER_ID.eq(orders.ID))
.where(payment.STATUS.eq(PaymentStatus.PAID))
.groupBy(orders.ID)

EXISTS в jOOQ возвращает Condition, который вкладывается в where — это не отдельный запрос, а subquery, который PG развернёт в semi-join автоматически.

Что запрещено — сводка

АнтипаттернПравилоЧто взамен
dslContext.fetch("SELECT ...")R-JOOQ-QRY-X1DSL: selectFrom(...)
condition("col = " + value)R-JOOQ-QRY-X2.where(COL.eq(value))
record.setX().store()R-JOOQ-QRY-X3update(table).set(field, value).where(...)
.fetchOne() на возможном nullR-JOOQ-QRY-X4.fetchOptional()
.fetchCount() > 0R-JOOQ-QRY-X5.fetchExists()
.into(X.class) после .fetch() без type-safe проекцииR-JOOQ-QRY-X6.fetchInto(Pojo.class)

Куда дальше

  • jOOQ Style Guide → раздел 4. Построение запросов — нормативные формулировки.
  • Multiset для nested-fetch — как доставать вложенные коллекции одним запросом.
  • Filter builders — как организовать сложные WHERE.
  • Lock-режимы — про forUpdate() и SelectMode.