Пагинация
Отдать десять тысяч строк одним ответом нельзя ни клиенту, ни памяти воркера. Разрезать выборку можно двумя способами, и они не взаимозаменяемы: смещение удобно, курсор корректен. Ниже — чем они отличаются на живых данных, и разбор каждого метода: что принимает, что возвращает, что делает на краях.
Что такое пагинация
Выдача результата частями: клиент получает окно и способ запросить следующее. Окно задаётся двумя числами — сколько строк и откуда начинать, — а «откуда» бывает двух видов, и в этом вся разница.
Смещение — «пропусти 40, дай 20». Это LIMIT/OFFSET, номера страниц, почти все
админки.
Курсор — «дай 20 после вот этой позиции». Позиция — значение колонки последней отданной строки, упакованное в непрозрачный токен.
Приходит вместе со слоем БД
Пагинация живёт в flytachi/winter-ppa — там же, где собирается SQL: курсор превращается
в WHERE и ORDER BY, а число страниц требует COUNT. Отдельной установки не нужно.
Единственное, что работает без базы, — Wrapper::paginator() со списком: он режет
массив и соединения не открывает.
Смещение или курсор
Разница выглядит вкусовой ровно до момента, когда данные меняются между двумя запросами:
Страница 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()
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 — не имя таблицы, а собранный запрос: условия, джойны и сортировка ставятся до
пагинации, и она их не меняет.
Paginator::repo(
PostRepository::instance('p')
->where(Qb::eq('p.status', 'published'))
->orderBy('p.created_at DESC'),
size: 20,
);$size — только положительное. 0 или отрицательное — не пустая страница, а ошибка:
ValueError: Size must be a positive integer (>= 1), got: 0.$offset может уйти за конец — это не ошибка. Данные будут пустыми, а total
по-прежнему честным, и по нему клиент поймёт, что промахнулся:
{ "meta": { "offset": 99, "size": 5, "total": 9 }, "data": [] }$entityClassName подменяет класс, в который гидрируются строки. Полезно, когда одна
таблица отдаётся в разных формах — но подменяется только класс, не набор колонок:
Paginator::repo($repo, size: 2, entityClassName: Slim::class);
// у Slim объявлены id и title, но в строке пришёл ещё views —
// Deprecated: Creation of dynamic property Slim::$viewsЧтобы сузить набор колонок, сужайте select() у репозитория; $entityClassName для этого
не предназначен.
$mapper получает уже гидрированный объект (сущность репозитория либо
$entityClassName), а не массив строки. Возвращённое значение занимает место элемента:
$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()
public static function array(
array $items,
int $size,
int $offset = 0,
?callable $mapper = null,
): PaginationResultТо же самое для готового списка. Соединение не открывается.
| Аргумент | Тип | По умолчанию | Что делает |
|---|---|---|---|
$items |
array |
— | исходный список целиком |
$size |
int |
— | строк на страницу; < 1 → ValueError |
$offset |
int |
0 |
сколько пропустить |
$mapper |
?callable |
null |
преобразование элементов страницы |
$items передаётся весь: total — это его длина, поэтому метод знает общее число
без запросов. Обратная сторона очевидна — список уже должен быть в памяти.
Возвращает PaginationResult с той же PaginationMeta, что и repo(), — формы
совпадают, поэтому источник можно поменять, не трогая клиента.
$page = Paginator::array($rowsFromApi, size: 10, offset: 20);Нужен, когда данные пришли не из базы — из внешнего API, из кеша, из файла, — а отдавать их надо в том же конверте, что и всё остальное.
cursor()
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
означает «с начала», и это единственный способ начать: номера страниц у курсора нет.
$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 — по ним и рисуются кнопки:
// первая страница
{ "meta": { "size": 4, "cursorPrev": null, "cursorNext": "eyJzIjoi…" }, "data": [ … ] }
// последняя
{ "meta": { "size": 5, "cursorPrev": "eyJzIjoi…", "cursorNext": null }, "data": [ … ] }Wrapper
Тот же обход по смещению, но мета описана страницами, а не смещением. Это то, что
нужно интерфейсу с номерами: клиенту не приходится делить total на size и считать
соседей самому.
paginator()
public static function paginator(
RepositoryViewInterface|array $repo,
int $limit,
int $page = 1,
?string $entityClassName = null,
?callable $mapper = null,
): WrapResult| Аргумент | Тип | По умолчанию | Что делает |
|---|---|---|---|
$repo |
RepositoryViewInterface|array |
— | репозиторий или список в памяти |
$limit |
int |
— | размер страницы; < 1 → ValueError |
$page |
int |
1 |
номер страницы, считая с единицы |
$entityClassName |
?string |
null |
гидрация; для массива игнорируется |
$mapper |
?callable |
null |
преобразование строк страницы |
$repo принимает оба вида источника, и это главное отличие от Paginator. С массивом
метод не открывает соединения — режет список на месте:
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 для массива игнорируется молча — гидрировать нечего, элементы
отдаются как есть:
Wrapper::paginator([1, 2, 3, 4], limit: 2, entityClassName: Slim::class);
// data: [1, 2] — никакой гидрации не произошлоВозвращает WrapResult с WrapMeta. Поля меты считаются так:
$result = Wrapper::paginator(PostRepository::instance(), limit: 5, page: 2);{
"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, а не одну пустую страницу — страниц действительно нет:
{"meta":{"current":1,"size":5,"total":0,"pages":0,"previous":null,"next":null},"data":[]}Когда брать `Paginator`, а не `Wrapper`
Wrapper платит за pages тем же COUNT, что и repo(). Если номера страниц в
интерфейсе не нужны — нужна только кнопка «ещё», — Paginator::repo() дешевле, а
cursor() ещё и корректнее.
CursorKey
Описывает позицию: по какой колонке идти, в какую сторону и чем разрешать ничью.
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.
Тай-брейкер: зачем он обязателен
Курсор — это «строки после этого значения». Если строк с одинаковым значением больше, чем размер страницы, граница перестаёт быть однозначной: база вправе вернуть их в любом порядке, и часть потеряется или повторится.
// плохо: у сотни постов одинаковый created_at
$key = new CursorKey('created_at', Sort::Desc);
// хорошо: ничья разрешается уникальным ключом
$key = new CursorKey('created_at', Sort::Desc,
tiebreaker: new CursorKey('id', Sort::Desc));С цепочкой в токен попадают оба значения, и сравнение идёт по паре:
ключ: 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:
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. |
Второй случай — обычная жизнь: ссылку скопировали не целиком. Третий — вы поменяли сортировку, а у клиента остался старый токен.
try {
$page = Paginator::cursor($repo, size: 20, key: $key, cursor: $request->query('cursor'));
} catch (InvalidCursorException) {
// токен не наш или испорчен — показываем первую страницу
$page = Paginator::cursor($repo, size: 20, key: $key);
}Отдельное исключение здесь не педантизм: молча начать с начала — значит показать
пользователю первую страницу там, где он ждал продолжения, и не оставить следа в логах.
Пакет сообщает о проблеме; начать заново или ответить 400 — решает приложение.
Дальше
- Репозитории — как собрать выборку, которую вы пагинируете
- Пул соединений — почему второй запрос за
COUNTстоит внимания - PPA — слой целиком