Опирается на правила: R-JOOQ-FLT-1R-JOOQ-FLT-6 и R-JOOQ-FLT-X1R-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, sberIdandIfNotNull.
  • Optional collection: statusIn, customerIdsandIfNotEmpty.
  • Boolean flag: includeArchivedandIfTrue.

Эта утилита — статическая, потому что без зависимостей. 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_atplaced_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).