База данных · Пагинация

Пагинация

Отдать десять тысяч строк одним ответом нельзя ни клиенту, ни памяти воркера. Разрезать выборку можно двумя способами, и они не взаимозаменяемы: смещение удобно, курсор корректен. Ниже — чем они отличаются на живых данных, и разбор каждого метода: что принимает, что возвращает, что делает на краях.

Пакет flytachi/winter-ppaСмещение Paginator::repo · arrayКурсор Paginator::cursorСтраницы Wrapper::paginator

Что такое пагинация

Выдача результата частями: клиент получает окно и способ запросить следующее. Окно задаётся двумя числами — сколько строк и откуда начинать, — а «откуда» бывает двух видов, и в этом вся разница.

Смещение — «пропусти 40, дай 20». Это LIMIT/OFFSET, номера страниц, почти все админки.

Курсор — «дай 20 после вот этой позиции». Позиция — значение колонки последней отданной строки, упакованное в непрозрачный токен.

Приходит вместе со слоем БД

Пагинация живёт в flytachi/winter-ppa — там же, где собирается SQL: курсор превращается в WHERE и ORDER BY, а число страниц требует COUNT. Отдельной установки не нужно.

Единственное, что работает без базы, — Wrapper::paginator() со списком: он режет массив и соединения не открывает.

Смещение или курсор

Разница выглядит вкусовой ровно до момента, когда данные меняются между двумя запросами:

text
Страница 1: id 12, 11, 10, 9   ← пользователь читает
           ↓ кто-то добавил запись
Смещение:   OFFSET 4 → 9, 8, 7, 6      ← id 9 показан дважды
Курсор:     после id 9 → 8, 7, 6, 5    ← ничего не задвоилось и не пропало

Вставка сдвигает все смещения, поэтому строка на границе показывается второй раз, а при удалении — пропадает незамеченной. Курсор привязан к значению, а не к номеру.

Вторая разница — цена. OFFSET 100000 заставляет базу пройти и отбросить сто тысяч строк; курсор превращается в WHERE id < :значение и берёт индекс.

Смещение Курсор
Прыжок на произвольную страницу да нет, только вперёд и назад
Общее число страниц да, через COUNT нет
Устойчивость к вставкам и удалениям нет да
Стоимость глубокой страницы растёт линейно постоянная
Что показать в интерфейсе «стр. 7 из 42» «дальше» / «назад»

Где что применяют

Задача Инструмент Почему
Таблица в админке с номерами страниц Wrapper::paginator() нужны pages, previous, next
Лента в приложении, бесконечная прокрутка Paginator::cursor() пользователь не должен видеть повторы
Публичный API со списками Paginator::cursor() клиент хранит токен, а не номер
Экспорт, обход всей таблицы Paginator::cursor() смещение на глубине становится дорогим
Данные уже в памяти Paginator::array() или Wrapper соединение не нужно
Нужен и номер страницы, и устойчивость номера но с оговоркой: на живых данных границы плывут

Справочник методов

Paginator

Возвращает PaginationResult — конверт из meta и data. Все три метода статические.

repo()

php
public static function repo(
  RepositoryViewInterface $repo,
  int $size,
  int $offset = 0,
  ?string $entityClassName = null,
  ?callable $mapper = null,
): PaginationResult

Страница по смещению из репозитория.

Аргумент Тип По умолчанию Что делает
$repo RepositoryViewInterface репозиторий с уже наложенными условиями
$size int строк на страницу
$offset int 0 сколько строк пропустить с начала
$entityClassName ?string null класс для гидрации вместо сущности репозитория
$mapper ?callable null преобразование каждой строки страницы

$repo — не имя таблицы, а собранный запрос: условия, джойны и сортировка ставятся до пагинации, и она их не меняет.

php
Paginator::repo(
  PostRepository::instance('p')
      ->where(Qb::eq('p.status', 'published'))
      ->orderBy('p.created_at DESC'),
  size: 20,
);

$size — только положительное. 0 или отрицательное — не пустая страница, а ошибка:

text
ValueError: Size must be a positive integer (>= 1), got: 0.

$offset может уйти за конец — это не ошибка. Данные будут пустыми, а total по-прежнему честным, и по нему клиент поймёт, что промахнулся:

json
{ "meta": { "offset": 99, "size": 5, "total": 9 }, "data": [] }

$entityClassName подменяет класс, в который гидрируются строки. Полезно, когда одна таблица отдаётся в разных формах — но подменяется только класс, не набор колонок:

php
Paginator::repo($repo, size: 2, entityClassName: Slim::class);
// у Slim объявлены id и title, но в строке пришёл ещё views —
// Deprecated: Creation of dynamic property Slim::$views

Чтобы сузить набор колонок, сужайте select() у репозитория; $entityClassName для этого не предназначен.

$mapper получает уже гидрированный объект (сущность репозитория либо $entityClassName), а не массив строки. Возвращённое значение занимает место элемента:

php
$page = Paginator::repo($repo, size: 2, mapper: fn(Post $p) => $p->title);
$page->data;   // ["t1", "t2"]

Вызывается он только на строках страницы — остальные не гидрируются вовсе.

Возвращает PaginationResult с PaginationMeta (offset, size, total).

`total` стоит второго запроса

Чтобы заполнить total, выполняется COUNT(*) по тем же условиям — то есть два обращения к базе на каждую страницу. На большой таблице COUNT нередко дороже самой выборки.

Если общее число не показывается в интерфейсе, не платите за него: берите cursor() либо запрашивайте size + 1 строку и смотрите, пришла ли лишняя — этого достаточно, чтобы нарисовать кнопку «дальше».

array()

php
public static function array(
  array $items,
  int $size,
  int $offset = 0,
  ?callable $mapper = null,
): PaginationResult

То же самое для готового списка. Соединение не открывается.

Аргумент Тип По умолчанию Что делает
$items array исходный список целиком
$size int строк на страницу; < 1ValueError
$offset int 0 сколько пропустить
$mapper ?callable null преобразование элементов страницы

$items передаётся весь: total — это его длина, поэтому метод знает общее число без запросов. Обратная сторона очевидна — список уже должен быть в памяти.

Возвращает PaginationResult с той же PaginationMeta, что и repo(), — формы совпадают, поэтому источник можно поменять, не трогая клиента.

php
$page = Paginator::array($rowsFromApi, size: 10, offset: 20);

Нужен, когда данные пришли не из базы — из внешнего API, из кеша, из файла, — а отдавать их надо в том же конверте, что и всё остальное.

cursor()

php
public static function cursor(
  RepositoryViewInterface $repo,
  int $size,
  CursorKey $key,
  ?string $cursor = null,
  ?string $entityClassName = null,
  ?callable $mapper = null,
): PaginationResult

Страница «после позиции».

Аргумент Тип По умолчанию Что делает
$repo RepositoryViewInterface репозиторий с условиями
$size int строк на страницу
$key CursorKey по какой колонке идти и в какую сторону
$cursor ?string null токен из предыдущего ответа; null — первая страница
$entityClassName ?string null гидрация, как в repo()
$mapper ?callable null преобразование строк, как в repo()

$key задаёт и позицию, и порядок: из него строится ORDER BY, из него же берётся значение для WHERE. Свой orderBy() на репозитории здесь не нужен — он не согласован с условием «после позиции», и страницы начнут перекрываться.

$cursor — то, что клиент вернул из meta.cursorNext или meta.cursorPrev. null означает «с начала», и это единственный способ начать: номера страниц у курсора нет.

main/FeedController.php
$key = new CursorKey('id', Sort::Desc);

$page = Paginator::cursor(
  PostRepository::instance(),
  size: 20,
  key: $key,
  cursor: $request->query('cursor'),
);

return ResponseEntity::ok($page);

Возвращает PaginationResult с PaginationMetaCursor (size, cursorPrev, cursorNext). Бросает InvalidCursorException, если токен испорчен или выпущен под другой ключ.

На краях токены равны null — по ним и рисуются кнопки:

json
// первая страница
{ "meta": { "size": 4, "cursorPrev": null, "cursorNext": "eyJzIjoi…" }, "data": [  ] }

// последняя
{ "meta": { "size": 5, "cursorPrev": "eyJzIjoi…", "cursorNext": null }, "data": [  ] }

Wrapper

Тот же обход по смещению, но мета описана страницами, а не смещением. Это то, что нужно интерфейсу с номерами: клиенту не приходится делить total на size и считать соседей самому.

paginator()

php
public static function paginator(
  RepositoryViewInterface|array $repo,
  int $limit,
  int $page = 1,
  ?string $entityClassName = null,
  ?callable $mapper = null,
): WrapResult
Аргумент Тип По умолчанию Что делает
$repo RepositoryViewInterface|array репозиторий или список в памяти
$limit int размер страницы; < 1ValueError
$page int 1 номер страницы, считая с единицы
$entityClassName ?string null гидрация; для массива игнорируется
$mapper ?callable null преобразование строк страницы

$repo принимает оба вида источника, и это главное отличие от Paginator. С массивом метод не открывает соединения — режет список на месте:

php
Wrapper::paginator(range(1, 25), limit: 10, page: 2);
// {"meta":{"current":2,"size":10,"total":25,"pages":3,"previous":1,"next":3},
//  "data":[11,12,13,14,15,16,17,18,19,20]}

$page считается с единицы, а не с нуля: page: 1 — первая страница, смещение 0. Внутри оно и вычисляется как limit × (page − 1).

$entityClassName для массива игнорируется молча — гидрировать нечего, элементы отдаются как есть:

php
Wrapper::paginator([1, 2, 3, 4], limit: 2, entityClassName: Slim::class);
// data: [1, 2] — никакой гидрации не произошло

Возвращает WrapResult с WrapMeta. Поля меты считаются так:

php
$result = Wrapper::paginator(PostRepository::instance(), limit: 5, page: 2);
json
{
"meta": {
  "current": 2,      // запрошенная страница
  "size": 5,         // размер страницы
  "total": 12,       // всего строк — тот самый COUNT
  "pages": 3,        // ceil(total / size)
  "previous": 1,     // current − 1, либо null на первой
  "next": 3          // current + 1, если pages > current, иначе null
},
"data": [ { "id": 6 }, { "id": 7 }, { "id": 8 }, { "id": 9 }, { "id": 10 } ]
}

previous и next равны null на краях, поэтому клиент рисует стрелки прямо по ним, без собственной арифметики.

Мета — это арифметика, а не проверка

Номер страницы не ограничивается сверху. Запрос девятой страницы там, где их три, вернёт пустые данные — и previous: 8, то есть ссылку на страницу, которой тоже нет:

{"meta":{"current":9,"size":5,"total":9,"pages":2,"previous":8,"next":null},"data":[]}

Проверять номер должен тот, кто его принял. Обычная защита — сравнить current с pages и отдать 404, либо привести номер к диапазону до вызова.

Пустой источник даёт pages: 0, а не одну пустую страницу — страниц действительно нет:

json
{"meta":{"current":1,"size":5,"total":0,"pages":0,"previous":null,"next":null},"data":[]}

Когда брать `Paginator`, а не `Wrapper`

Wrapper платит за pages тем же COUNT, что и repo(). Если номера страниц в интерфейсе не нужны — нужна только кнопка «ещё», — Paginator::repo() дешевле, а cursor() ещё и корректнее.


CursorKey

Описывает позицию: по какой колонке идти, в какую сторону и чем разрешать ничью.

php
new CursorKey(
  string $column,
  Sort $direction = Sort::Desc,
  ?CursorKey $tiebreaker = null,
  ?string $alias = null,
)
Аргумент Тип По умолчанию Что делает
$column string колонка, задающая порядок; попадает и в ORDER BY, и в WHERE
$direction Sort Sort::Desc Sort::Asc или Sort::Desc
$tiebreaker ?CursorKey null следующий ключ, когда значения совпали
$alias ?string null имя, под которым колонка приходит в результате

$column — это то, по чему сравнивают. Колонка должна быть в выборке: из неё берётся значение для токена. Для джойнов пишут её с алиасом таблицы (p.created_at).

$direction задаёт и порядок, и смысл сравнения: Desc — «строки меньше значения», Asc — «больше». При движении назад направление инвертируется автоматически.

$alias нужен, когда в SELECT колонка переименована (p.created_at AS posted_at): сравнивать надо по p.created_at, а читать значение — из posted_at.

Тай-брейкер: зачем он обязателен

Курсор — это «строки после этого значения». Если строк с одинаковым значением больше, чем размер страницы, граница перестаёт быть однозначной: база вправе вернуть их в любом порядке, и часть потеряется или повторится.

php
// плохо: у сотни постов одинаковый created_at
$key = new CursorKey('created_at', Sort::Desc);

// хорошо: ничья разрешается уникальным ключом
$key = new CursorKey('created_at', Sort::Desc,
  tiebreaker: new CursorKey('id', Sort::Desc));

С цепочкой в токен попадают оба значения, и сравнение идёт по паре:

text
ключ: views DESC, затем id DESC
стр. 1 → (id 8, views 2), (id 5, views 2), (id 2, views 2)
токен  → {"s":"bfe46c74","v":[2,2],"d":"f"}
стр. 2 → (id 7, views 1), (id 4, views 1), (id 1, views 1)

Правило: последний ключ цепочки должен быть уникальным. Обычно это первичный ключ.

Служебные методы

Метод Что возвращает
CursorKey::compose(...$keys) цепочку из нескольких ключей — тот же результат, что вложенные tiebreaker
flatten() цепочку списком: [["title","ASC","title"], ["id","ASC","id"]]
signature() восьмисимвольную подпись цепочки: fc46c39d
effectiveAlias() имя, под которым колонка приходит в результате

signature() — то, чем токен привязан к ключу: она лежит внутри курсора и сверяется при чтении. Поменяли сортировку — старые токены перестают подходить и дают ошибку вместо страницы, собранной по другому порядку.


Формы ответа

Все конверты реализуют JsonSerializable, поэтому возвращаются из контроллера как есть.

PaginationResult

Поле Тип Что это
meta PaginationMeta|PaginationMetaCursor описание окна
data array строки страницы после $mapper, если он был

PaginationMeta

Отдают repo() и array().

Поле Тип Что это
offset int сколько строк пропущено — ровно то, что передали
size int запрошенный размер страницы, а не число пришедших строк
total int сколько строк удовлетворяет условиям

size — это запрос, а не факт: на последней странице строк придёт меньше. Считать пришедшие нужно по count($result->data).

PaginationMetaCursor

Отдаёт cursor(). Числа total здесь нет — курсор его не вычисляет, в этом и экономия.

Поле Тип Что это
size int запрошенный размер страницы
cursorPrev ?string токен предыдущей страницы; null на первой
cursorNext ?string токен следующей; null на последней

WrapMeta

Отдаёт Wrapper::paginator().

Поле Тип Что это
current int запрошенный номер страницы
size int размер страницы
total int всего строк
pages int ceil(total / size); 0 для пустого источника
previous ?int current − 1, либо null на первой
next ?int current + 1, если есть куда, иначе null

Что внутри курсора

Токен непрозрачен для клиента, но не зашифрован — это base64 от небольшого JSON:

text
eyJzIjoiZGI4MTRhYWMiLCJ2IjpbOV0sImQiOiJmIn0=
  ↓ base64_decode
{"s":"db814aac","v":[9],"d":"f"}
 s — подпись ключа   v — значения позиции   d — направление (f — вперёд, b — назад)

Курсор — не секрет и не право доступа

Значения позиции прочитает любой, кто получил токен. Не кладите в ключ курсора то, чего клиенту видеть не следует, и не считайте владение токеном разрешением: условия доступа задаются в репозитории, до пагинации.


Ошибки

Исключение Когда Сообщение
ValueError размер страницы меньше единицы Size must be a positive integer (>= 1), got: 0.
InvalidCursorException токен испорчен или обрезан Cursor payload is not valid JSON.
InvalidCursorException токен выпущен под другой ключ Cursor signature mismatch — the cursor was issued under a different key shape.

Второй случай — обычная жизнь: ссылку скопировали не целиком. Третий — вы поменяли сортировку, а у клиента остался старый токен.

php
try {
  $page = Paginator::cursor($repo, size: 20, key: $key, cursor: $request->query('cursor'));
} catch (InvalidCursorException) {
  // токен не наш или испорчен — показываем первую страницу
  $page = Paginator::cursor($repo, size: 20, key: $key);
}

Отдельное исключение здесь не педантизм: молча начать с начала — значит показать пользователю первую страницу там, где он ждал продолжения, и не оставить следа в логах. Пакет сообщает о проблеме; начать заново или ответить 400 — решает приложение.

Дальше