Опирается на правила:
R-JOOQ-QRY-1…R-JOOQ-QRY-7иR-JOOQ-QRY-X1…R-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-X1 | DSL: selectFrom(...) |
condition("col = " + value) | R-JOOQ-QRY-X2 | .where(COL.eq(value)) |
record.setX().store() | R-JOOQ-QRY-X3 | update(table).set(field, value).where(...) |
.fetchOne() на возможном null | R-JOOQ-QRY-X4 | .fetchOptional() |
.fetchCount() > 0 | R-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.