Опирается на правила:
R-JOOQ-FLT-1…R-JOOQ-FLT-6иR-JOOQ-FLT-X1…R-JOOQ-FLT-X2из jOOQ Style Guide → раздел 6. Filter-builders.
Важно знать
- Сложные WHERE выносим в
<X>FilterConditionBuilder(Spring@Component), а не оставляем if-цепочкой в репозитории.<X>Filter— иммутабельный record вcore/domain/repository/filter/. Поля nullable: null = «не фильтруем по этому полю».- Условие начинается с
Condition c = noCondition()— нейтральный элемент (1=1), если ни один if не сработает.- Чейнинг — через утилиту
FilterConditionHelper.andIfNotNull / andIfNotEmpty / andIfTrue.- EXISTS для cross-table фильтров (заказ имеет платёж) —
R-JOOQ-FLT-5, не JOIN.<X>FilterConditionBuilder— единственное место, где<X>Filterвстречается с jOOQ-таблицами. Граница domain ↔ persistence.
В типичном API «список заказов» приходит 5–15 опциональных параметров: статус, дата от, дата до, customer, sber-id, фильтр по сумме… Если писать if (filter.status() != null) c = c.and(orders.STATUS.eq(filter.status())) подряд, репозиторий быстро становится нечитаемым. Filter builder — паттерн, который вытаскивает эту логику в отдельный компонент и оставляет в репозитории одну строку conditionBuilder.build(filter).
Filter как иммутабельный record в core
R-JOOQ-FLT-2: фильтр — domain-тип. Лежит в core/, не зависит от jOOQ.
// core/domain/repository/filter/OrderFilter.java
public record OrderFilter(
Long id, // null = не фильтруем
Set<OrderStatus> statusIn, // null/empty = не фильтруем
Set<Long> customerIds,
OffsetDateTime createdFrom,
OffsetDateTime createdTo,
String sberId, // EXISTS-подзапрос
Sort<OrderSortField> sort // отдельная штука, см. R-JOOQ-QRY-6
) {}
Почему так:
- Иммутабельность. Filter передаётся между слоями, мутации недопустимы.
- Nullable-поля.
nullнесёт смысл «по этому полю не фильтруем». Если бы поле былоOptional<X>, было быOptional.empty()— это можно, но только в record-полях domain не используемOptionalпо конвенции (Optional — для return-типов, не для полей). Поэтому nullable + явная семантика «null = ignore». - Domain-типы внутри.
Set<OrderStatus>,OffsetDateTime— никаких jOOQ-enums, никаких persistence-типов. Фильтр осмыслен и без jOOQ.
FilterConditionBuilder — Spring-компонент
R-JOOQ-FLT-1: сборщик условия — @Component, потому что нужны зависимости (DSLContext для EXISTS-подзапросов).
@Component
@RequiredArgsConstructor
public class OrderFilterConditionBuilder {
private final DSLContext dslContext; // для exists(...)
public Condition build(OrderFilter filter) {
Condition c = noCondition();
c = andIfNotNull(c, filter.id(), orders.ID::eq);
c = andIfNotEmpty(c, filter.statusIn(), s -> orders.STATUS.in(s));
c = andIfNotEmpty(c, filter.customerIds(), orders.CUSTOMER_ID::in);
c = andIfNotNull(c, filter.createdFrom(), orders.CREATED_AT::ge);
c = andIfNotNull(c, filter.createdTo(), orders.CREATED_AT::le);
c = andIfNotNull(c, filter.sberId(), this::buildSberExists);
return c;
}
private Condition buildSberExists(String sberId) {
return exists(selectOne()
.from(payment)
.where(payment.ORDER_ID.eq(orders.ID).and(payment.SBER_ID.eq(sberId))));
}
}
Это не статическая утилита (OrderFilterUtils.build(...)):
- Подзапросы EXISTS опираются на
DSLContext— статика не может иметь DI. - Spring-bean можно подменить в тестах через
@MockitoBean, если нужно проверить, что репозиторий правильно собирает условие из фильтра. - Spring-bean регистрируется как зависимость репозитория через
@RequiredArgsConstructor— конструктор остаётся явным.
noCondition() — нейтральный элемент
R-JOOQ-FLT-3: всегда стартуем с Condition c = noCondition().
noCondition() — это 1=1 в SQL, нейтральный элемент для AND. Если ни один if не сработает, WHERE будет WHERE 1=1, PG оптимизирует это как «без WHERE». Это позволяет писать линейно:
Condition c = noCondition();
c = andIfNotNull(c, filter.id(), orders.ID::eq);
c = andIfNotEmpty(c, filter.statusIn(), s -> orders.STATUS.in(s));
return c;
Без noCondition пришлось бы либо инициализировать первой проверкой (читается криво), либо вводить if (c == null) c = ...; else c = c.and(...); (boilerplate на каждом фильтре).
FilterConditionHelper — andIf-цепочка
R-JOOQ-FLT-4: чейнинг через утилиту в одном месте.
public final class FilterConditionHelper {
public static <T> Condition andIfNotNull(Condition c, T value,
Function<T, Condition> mapper) {
return value == null ? c : c.and(mapper.apply(value));
}
public static <T> Condition andIfNotEmpty(Condition c, Collection<T> coll,
Function<Collection<T>, Condition> mapper) {
return (coll == null || coll.isEmpty()) ? c : c.and(mapper.apply(coll));
}
public static Condition andIfTrue(Condition c, boolean cond,
Supplier<Condition> mapper) {
return cond ? c.and(mapper.get()) : c;
}
private FilterConditionHelper() {}
}
andIfNotNull / andIfNotEmpty покрывают 95% случаев:
- Optional single-value:
id,createdFrom,sberId→andIfNotNull. - Optional collection:
statusIn,customerIds→andIfNotEmpty. - Boolean flag:
includeArchived→andIfTrue.
Эта утилита — статическая, потому что без зависимостей. FilterConditionHelper один на проект, не на агрегат.
EXISTS для cross-table
R-JOOQ-FLT-5: «заказ имеет платёж с конкретным sberId» — через EXISTS, не JOIN.
c = andIfNotNull(c, filter.sberId(), sid ->
exists(selectOne().from(payment)
.where(payment.ORDER_ID.eq(orders.ID).and(payment.SBER_ID.eq(sid)))));
Почему не JOIN:
- JOIN тянет колонки
paymentв основной запрос, а потом ещё нужноGROUP BY orders.ID, чтобы не дублировать заказы (если у одного заказа несколько платежей). - EXISTS — semi-join, PG останавливается на первом найденном
payment. Быстрее на больших таблицах. - В коде понятнее интент: «нам нужно знать, что платёж есть», а не «нам нужно достать платежи и сгруппировать».
См. также R-JOOQ-QRY-7 в Построении запросов — то же правило в контексте обычных запросов.
Единственное место знания
R-JOOQ-FLT-6: OrderFilterConditionBuilder — единственное место в коде, где OrderFilter встречается с orders.STATUS, orders.CREATED_AT, и прочими jOOQ-полями.
Что это даёт:
- Граница domain ↔ persistence. Domain знает
OrderStatus, persistence знаетorders.STATUS. Конверсия между ними — здесь, и больше нигде. - Меняется схема — правка в одном месте. Переименовали
created_at→placed_atв миграции? jOOQ-codegen обновитorders.CREATED_ATнаorders.PLACED_AT, компилятор найдёт сломанное место в одном файле — вOrderFilterConditionBuilder. - Тестируется изолированно. Можно проверить ConditionBuilder unit-тестом против настоящего PG (через Testcontainers), не дергая репозиторий.
Что запрещено
R-JOOQ-FLT-X1: inline if-цепочки в репозитории.
// ПЛОХО
public PaginationView<Order> findAll(OrderFilter filter, ...) {
Condition c = noCondition();
if (filter.id() != null) c = c.and(orders.ID.eq(filter.id()));
if (filter.statusIn() != null && !filter.statusIn().isEmpty()) c = c.and(orders.STATUS.in(filter.statusIn()));
if (filter.createdFrom() != null) c = c.and(orders.CREATED_AT.ge(filter.createdFrom()));
if (filter.createdTo() != null) c = c.and(orders.CREATED_AT.le(filter.createdTo()));
if (filter.sberId() != null) c = c.and(exists(...));
// на третьем поле уже нечитаемо
return dslContext.selectFrom(orders).where(c)...;
}
R-JOOQ-FLT-X2: SQL-конкатенация через String. Это R-JOOQ-QRY-X1/X2 в другом обличии — type-safety обход + SQL-injection. См. Построение запросов.
Куда дальше
- jOOQ Style Guide → раздел 6. Filter-builders — нормативные формулировки.
- Построение запросов — про DSL и bind-параметры.
- Repository pattern в jOOQ — как
conditionBuilderинжектируется в репозиторий. - Пагинация — как Filter ходит в
findAll(filter, page, size).