Опирается на правила: R-JOOQ-PAG-1R-JOOQ-PAG-5 и R-JOOQ-PAG-X1R-JOOQ-PAG-X2 из jOOQ Style Guide → раздел 8. Пагинация.

Важно знать

  • PaginationView<T> — record в core/domain/repository/ с полями items, total, count, page, size.
  • Offset-based: limit(size).offset(page * size) + отдельный fetchCount для total. Простая, но дорогая на больших offset'ах.
  • Cursor-based (keyset): WHERE created_at < ? + limit(size + 1) (для hasNext). Total не считаем — это часть контракта cursor-API.
  • Cursor на уровне API — opaque token (см. R-QRY-5 в REST API Style Guide). На уровне репозитория — composite-record (createdAt, id).
  • Total — через .fetchCount(), не через получение всех записей и .size().
  • fetchAll() без LIMIT в UI-эндпоинте — запрещён. На большой таблице — OOM или таймаут.
  • Page в репозитории 0-based, в публичном REST API — 1-based (см. R-QRY-4). Конвертация — на handler-уровне.

Пагинация выглядит просто, пока не наступаешь на грабли: offset на 100k+ медленный (PG читает и выкидывает первые 100k строк), count(*) на большой таблице — sequential scan, cursor без +1 не знает про hasNext. Раскрытие правил R-JOOQ-PAG-* ниже.

PaginationView — domain-record

R-JOOQ-PAG-1: возвращаемый тип — record в core/, не в persistence.

// core/domain/repository/PaginationView.java
public record PaginationView<T>(
    List<T> items,
    long total,
    int count,
    int page,
    int size
) {
    public int getTotalPages() {
        return size > 0 ? (int) Math.ceil((double) total / size) : 0;
    }

    public boolean hasNext() {
        return (long) (page + 1) * size < total;
    }

    public static <T> PaginationView<T> empty(int page, int size) {
        return new PaginationView<>(List.of(), 0L, 0, page, size);
    }
}

count vs total — частая путаница:

  • count — сколько элементов в текущей странице (≤ size). На последней неполной странице будет меньше.
  • total — сколько элементов всего в выборке (с применённым фильтром). Нужен для подсчёта totalPages.

Это domain-тип, потому что handler возвращает его в DTO (или мапит в REST-ответ). Persistence не знает про API-форму ответа.

Offset-based — простой случай

R-JOOQ-PAG-2: limit + offset + отдельный fetchCount.

@Override
public PaginationView<Order> findAll(OrderFilter filter, int page, int size, SelectMode mode) {
    Condition where = conditionBuilder.build(filter);

    // items
    List<Order> items = dslContext
        .selectFrom(orders)
        .where(where)
        .orderBy(toSortFields(filter.sort()))
        .pipe(applyLock(mode))
        .limit(size)
        .offset((long) page * size)
        .fetch()
        .map(record -> mapper.toDomain(record.into(OrdersPojo.class)));

    // total
    long total = dslContext.fetchCount(orders, where);

    return new PaginationView<>(items, total, items.size(), page, size);
}

Когда offset работает хорошо: «листание» каталога, страницы 1–50, размер 20–100. PG быстро выполняет LIMIT 20 OFFSET 200.

Когда плохо: страница 5000 (offset 100000). PG читает и выкидывает 100000 строк, чтобы вернуть 20. Это O(N) от offset'а. Если в API есть «прыгнуть на страницу 5000» — пагинация должна быть cursor-based.

Cursor-based (keyset) — для часто меняющихся данных

R-JOOQ-PAG-3: WHERE по предыдущему cursor + limit(size + 1).

public CursorView<Order> findFeed(OrderFilter filter, OrderCursor cursor, int size) {
    Condition where = conditionBuilder.build(filter);

    if (cursor != null) {
        // WHERE (created_at, id) < (cursor.created_at, cursor.id)
        where = where.and(
            orders.CREATED_AT.lt(cursor.createdAt())
                .or(orders.CREATED_AT.eq(cursor.createdAt())
                    .and(orders.ID.lt(cursor.id()))));
    }

    Result<OrdersRecord> records = dslContext
        .selectFrom(orders)
        .where(where)
        .orderBy(orders.CREATED_AT.desc(), orders.ID.desc())
        .limit(size + 1)                    // +1, чтобы понять hasNext
        .fetch();

    boolean hasNext = records.size() > size;
    if (hasNext) records.remove(records.size() - 1);  // отбросить лишнюю

    List<Order> items = records.stream()
        .map(r -> mapper.toDomain(r.into(OrdersPojo.class)))
        .toList();

    OrderCursor nextCursor = hasNext
        ? new OrderCursor(items.get(items.size() - 1).createdAt(), items.get(items.size() - 1).id())
        : null;

    return new CursorView<>(items, nextCursor);
}

Что важно:

  • Composite cursor. (createdAt, id) — потому что у двух заказов может совпасть createdAt с точностью до миллисекунды. Если бы cursor был только createdAt, мы бы пропускали или дублировали записи. ID — tie-breaker.
  • limit(size + 1) — единственный надёжный способ узнать, есть ли следующая страница, без отдельного запроса.
  • Нет total. Cursor-API не показывает «страница 5 из 1000». Только «следующая страница» / «лента закончилась». Это компромисс — total на больших фидах дорогой и не нужен пользователю.

Когда применять cursor:

  • Часто меняющиеся данные (ленты, уведомления, журнал событий). Offset тут даёт «прыгающую» страницу: между запросами что-то добавилось/удалилось, и страница 2 уже не «та».
  • Большая глубина листания (поиск, история). Cursor работает за O(log N) по индексу, offset — O(offset).
  • API не показывает «прыжки на страницу N» — обычно мобильные/web-ленты.

Cursor как opaque token

R-JOOQ-PAG-4: cursor на уровне API — base64-encoded непрозрачный токен. На уровне репозитория — типизированный record.

// API-слой
public record CursorToken(String value) {
    public OrderCursor decode() {
        String[] parts = new String(Base64.getDecoder().decode(value)).split("\\|");
        return new OrderCursor(OffsetDateTime.parse(parts[0]), Long.parseLong(parts[1]));
    }
    public static CursorToken encode(OrderCursor c) {
        String raw = c.createdAt() + "|" + c.id();
        return new CursorToken(Base64.getEncoder().encodeToString(raw.getBytes()));
    }
}

// Repository-слой
public record OrderCursor(OffsetDateTime createdAt, Long id) {}

Почему opaque на API: клиент не знает структуру cursor'а, он просто возвращает то, что прислал сервер. Это даёт нам свободу менять внутреннюю структуру cursor'а без breaking change в API. См. R-QRY-5 в REST API Style Guide.

fetchCount, не size() на всём

R-JOOQ-PAG-5: total — отдельным запросом через fetchCount.

// ХОРОШО
long total = dslContext.fetchCount(orders, where);  // SELECT count(*) FROM orders WHERE ...

// ПЛОХО
long total = dslContext.selectFrom(orders).where(where).fetch().size();  // тянем все записи!

На таблице с 10M строк второй вариант — OOM или таймаут даже на dev. Первый — PG считает count(*) через index или sequential scan; даже sequential scan — это просто проход без передачи данных в Java.

На очень больших таблицах sequential scan count(*) тоже медленный (минуты). Тогда:

  • Use cursor-based pagination — total вообще не нужен.
  • Approximate count — PG хранит оценку в pg_class.reltuples. Не точная, но мгновенная: SELECT reltuples::bigint FROM pg_class WHERE relname = 'orders'. Подходит для отображения «примерно 5M записей».

Что запрещено

R-JOOQ-PAG-X1: fetchAll() без LIMIT в UI-эндпоинте.

// ПЛОХО
public List<Order> findAllForExport() {
    return dslContext.selectFrom(orders).fetch().into(Order.class);
    // 10M строк → OOM или таймаут
}

Что вместо:

  • Для UI — пагинация (offset или cursor).
  • Для экспорта — streaming через dslContext.fetchStream(...) с явным Cursor<> и chunked-обработкой. Или background-задача, кладущая в файл.

R-JOOQ-PAG-X2: count(*) через query builder на горячем пути для больших таблиц.

// ПЛОХО на 50M строк
long total = dslContext.fetchCount(events, where);

// ХОРОШО (когда точность не критична)
long approximate = dslContext.fetchOne(
    "SELECT reltuples::bigint FROM pg_class WHERE relname = ?", "events"
).get(0, Long.class);

Либо переходим на cursor-pagination без total.

Куда дальше