Пагинация
Отдать десять тысяч строк одним ответом нельзя ни клиенту, ни памяти воркера. Разрезать выборку можно двумя способами, и они не взаимозаменяемы: смещение удобно, курсор корректен. Ниже — чем они отличаются на живых данных, и разбор каждого метода: что принимает, что возвращает, что делает на краях.
Что такое пагинация и зачем
Проблема. Список растёт, а запрос за ним не меняется. Пока в таблице сто строк,
SELECT * выглядит безобидно; на десяти тысячах он съедает память воркера, а на
миллионе — кладёт процесс. Клиенту при этом весь объём не нужен: он показывает экран.
Решение. Отдавать окно и способ запросить следующее. Окно задаётся двумя числами — сколько строк и откуда начинать, — и вот это «откуда» бывает двух видов, в чём и вся разница.
Смещение — «пропусти 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 |
| Лента в приложении, бесконечная прокрутка | cursor() |
пользователь не должен видеть повторы |
| Публичный API со списками | cursor() |
клиент хранит токен, а не номер |
| Экспорт, обход всей таблицы | cursor() |
смещение на глубине становится дорогим |
| Список уже в памяти | array() или Wrapper::paginator() |
соединение не нужно |
| Смещение, но без номеров страниц | repo() |
дешевле Wrapper: нет арифметики страниц |
Практическое правило: номера страниц берут тогда, когда их видно пользователю. Если в
интерфейсе только кнопка «ещё», платить COUNT за общее число не за что — а если список
живой, курсор ещё и избавляет от повторов на границах.
Справочник
Paginator
Paginator — набор из трёх статических методов, каждый из которых собирает одну страницу
и возвращает PaginationResult: конверт из meta (описание окна) и
data (строки).
Класс ничего не хранит между вызовами и не требует создания: страница целиком описывается
аргументами. Различаются методы источником и способом задать позицию —
repo() берёт смещение из базы, array() — из готового списка,
cursor() идёт по позиции вместо смещения.
use Flytachi\Winter\Ppa\Pagination\Paginator;
$page = Paginator::repo(PostRepository::instance(), size: 20);
return ResponseEntity::ok($page);Ответ клиенту
{
"meta": { "offset": 0, "size": 20, "total": 137 },
"data": [ { "id": 137, "title": "…" }, … ]
}repo()
Собирает страницу по смещению из репозитория.
Самый прямой способ разрезать выборку: метод добавляет к вашему запросу LIMIT и OFFSET,
выполняет его, а вторым запросом считает, сколько строк удовлетворяет условиям всего. Это
второе число и позволяет клиенту нарисовать «страница 3 из 12» — за него же и платят
лишним обращением к базе.
Синтаксис
public static function repo(
RepositoryViewInterface $repo,
int $size,
int $offset = 0,
?string $entityClassName = null,
?callable $mapper = null,
): PaginationResultПараметры
$repo — не имя таблицы, а собранный запрос. Условия, соединения и сортировка ставятся на
репозитории до вызова, и пагинация их не трогает — она лишь дописывает окно.
$size — сколько строк вернуть. Только положительное число; ноль или отрицательное — не
пустая страница, а ошибка.
$offset — сколько строк пропустить от начала. По умолчанию 0. Значение за концом
выборки ошибкой не считается: данные придут пустыми, а total останется честным.
$entityClassName — класс, в объекты которого гидрируются строки, вместо сущности
репозитория. По умолчанию null — берётся сущность репозитория. Подменяется только
класс: набор колонок остаётся прежним.
$mapper — функция, через которую пропускается каждая строка страницы. По умолчанию
null — строки отдаются как есть. Получает уже гидрированный объект, а не массив, и
возвращённое значение занимает место элемента.
Возвращает
PaginationResult с метой PaginationMeta:
offset, size, total.
Ошибки
ValueError — если $size меньше единицы:
ValueError: Size must be a positive integer (>= 1), got: 0.Пример
// Условия и сортировка — на репозитории; пагинация только режет
Paginator::repo(
PostRepository::instance('p')
->where(Qb::eq('p.status', 'published'))
->orderBy('p.created_at DESC'),
size: 20,
);Смещение за концом выборки
Paginator::array(range(1, 9), size: 5, offset: 99);{ "meta": { "offset": 99, "size": 5, "total": 9 }, "data": [] }По total клиент и поймёт, что промахнулся: строк всего девять, а пропустить просили
девяносто девять.
Преобразование строк
$page = Paginator::repo($repo, size: 2, mapper: fn(Post $post) => $post->title);
$page->data; // ["Первый заголовок", "Второй заголовок"]Функция вызывается только на строках страницы — остальные не гидрируются вовсе.
`$entityClassName` не сужает набор колонок
Класс подменяется, запрос — нет. Если в объявленном классе меньше свойств, чем колонок в строке, лишние станут динамическими свойствами:
Paginator::repo($repo, size: 2, entityClassName: PostPreview::class);
// у PostPreview объявлены id и title, но в строке пришёл ещё views —
// Deprecated: Creation of dynamic property PostPreview::$viewsЧтобы вернуть меньше колонок, сужайте select() у репозитория; этот аргумент для другого.
`total` стоит второго запроса
Чтобы заполнить total, выполняется COUNT(*) по тем же условиям — то есть два
обращения к базе на каждую страницу. На большой таблице COUNT нередко дороже самой
выборки.
Если общее число не показывается в интерфейсе, не платите за него: берите
cursor() либо запрашивайте size + 1 строку и смотрите, пришла ли лишняя —
этого достаточно, чтобы нарисовать кнопку «дальше».
array()
Собирает страницу по смещению из готового списка.
То же самое, что repo(), но источник уже в памяти: соединение не открывается,
COUNT не выполняется. Нужен, когда данные пришли не из базы — из внешнего API, из кеша,
из файла, — а отдавать их надо в том же конверте, что и всё остальное, чтобы клиент не
различал источники.
Синтаксис
public static function array(
array $items,
int $size,
int $offset = 0,
?callable $mapper = null,
): PaginationResultПараметры
$items — исходный список целиком. Его длина и становится total, поэтому общее
число известно без запросов; обратная сторона очевидна — список уже должен быть в памяти.
$size — сколько элементов вернуть. Как и в repo(), только положительное.
$offset — сколько элементов пропустить. По умолчанию 0.
$mapper — функция преобразования элементов страницы. По умолчанию null. В отличие от
repo() получает элемент списка как есть — гидрировать здесь нечего.
Возвращает
PaginationResult с той же PaginationMeta, что и
repo() — формы совпадают, поэтому источник можно поменять, не трогая клиента.
Ошибки
ValueError — если $size меньше единицы.
Пример
$page = Paginator::array(range(1, 4), size: 2, mapper: fn(int $n) => $n * 10);Результат
{ "meta": { "offset": 0, "size": 2, "total": 4 }, "data": [10, 20] }cursor()
Собирает страницу «после позиции».
Вместо номера строки метод получает токен, в котором лежит значение сортировочной колонки
последней отданной строки. Из этого значения строится WHERE, а из описания ключа —
ORDER BY. Ни OFFSET, ни COUNT не выполняются: отсюда и устойчивость к вставкам, и
постоянная стоимость страницы независимо от глубины.
Плата — отсутствие номеров: перейти сразу на седьмую страницу нельзя, только вперёд и назад.
Синтаксис
public static function cursor(
RepositoryViewInterface $repo,
int $size,
CursorKey $key,
?string $cursor = null,
?string $entityClassName = null,
?callable $mapper = null,
): PaginationResultПараметры
$repo — репозиторий с уже наложенными условиями, как в repo().
$size — сколько строк вернуть; только положительное.
$key — CursorKey: по какой колонке идти и в какую сторону. Задаёт и
позицию, и порядок — из него строится ORDER BY, из него же берётся значение для
WHERE. Свой orderBy() на репозитории здесь не нужен: он не согласован с условием
«после позиции», и страницы начнут перекрываться.
$cursor — токен, который клиент вернул из meta.cursorNext или meta.cursorPrev. По
умолчанию null — «с начала», и это единственный способ начать: номера страниц у курсора
нет.
$entityClassName — класс для гидрации, как в repo().
$mapper — преобразование строк, как в repo().
Возвращает
PaginationResult с метой
PaginationMetaCursor: size, cursorPrev, cursorNext. Числа
total в ней нет — курсор его не вычисляет, в этом и экономия.
Ошибки
InvalidCursorException — если токен испорчен или выпущен под
другой ключ сортировки.
Пример
$key = new CursorKey('id', Sort::Desc);
$page = Paginator::cursor(
PostRepository::instance(),
size: 20,
key: $key,
cursor: $request->query('cursor'),
);
return ResponseEntity::ok($page);Результат
На краях токены равны null — по ним и рисуются кнопки:
// первая страница
{ "meta": { "size": 4, "cursorPrev": null, "cursorNext": "eyJzIjoi…" }, "data": [ … ] }
// последняя
{ "meta": { "size": 5, "cursorPrev": "eyJzIjoi…", "cursorNext": null }, "data": [ … ] }Wrapper
Wrapper — второй конверт для того же обхода по смещению, у которого мета описана
страницами, а не смещением.
Разница не в запросах, а в том, что получает клиент: вместо offset и total приходят
номер текущей страницы, их общее число и номера соседей. Интерфейсу с нумерацией это
избавляет от собственной арифметики — не нужно делить total на size и проверять края.
Второе отличие — источник: Wrapper принимает и репозиторий, и обычный массив, тогда как
у Paginator для этого два разных метода.
Wrapper::paginator()
Собирает страницу по её номеру.
Синтаксис
public static function paginator(
RepositoryViewInterface|array $repo,
int $limit,
int $page = 1,
?string $entityClassName = null,
?callable $mapper = null,
): WrapResultПараметры
$repo — репозиторий или список в памяти. Это главное отличие от Paginator: с
массивом метод не открывает соединения, а режет список на месте.
$limit — размер страницы. Только положительное число; ноль или отрицательное дают
ValueError.
$page — номер страницы, считая с единицы. По умолчанию 1. Смещение вычисляется
внутри как limit × (page − 1), поэтому page: 1 — это смещение 0.
$entityClassName — класс для гидрации строк. По умолчанию null. Для массива
игнорируется молча: гидрировать нечего, элементы отдаются как есть.
$mapper — преобразование строк страницы. По умолчанию null.
Возвращает
WrapResult с метой WrapMeta: current, size, total, pages,
previous, next.
Ошибки
ValueError — если $limit меньше единицы.
Пример
Wrapper::paginator(range(1, 25), limit: 10, page: 2);Результат
{
"meta": {
"current": 2, // запрошенная страница
"size": 10, // размер страницы
"total": 25, // всего строк — тот самый COUNT
"pages": 3, // ceil(total / size)
"previous": 1, // current − 1, либо null на первой
"next": 3 // current + 1, если pages > current, иначе null
},
"data": [11, 12, 13, 14, 15, 16, 17, 18, 19, 20]
}previous и next равны null на краях, поэтому клиент рисует стрелки прямо по ним, без
собственной арифметики.
Пустой источник
Страниц действительно нет — pages: 0, а не одна пустая:
{"meta":{"current":1,"size":5,"total":0,"pages":0,"previous":null,"next":null},"data":[]}Мета — это арифметика, а не проверка
Номер страницы не ограничивается сверху. Запрос девятой страницы там, где их две, вернёт
пустые данные — и previous: 8, то есть ссылку на страницу, которой тоже нет:
{"meta":{"current":9,"size":5,"total":9,"pages":2,"previous":8,"next":null},"data":[]}Проверять номер должен тот, кто его принял. Обычная защита — сравнить current с pages
и отдать 404, либо привести номер к диапазону до вызова.
CursorKey
CursorKey — описание позиции для cursor(): по какой колонке идти, в какую
сторону и чем разрешать ничью.
Один объект отвечает сразу за три вещи. Из него строится ORDER BY запроса; из него же
берётся, какое значение положить в токен; и его форма подписывается, чтобы токен,
выпущенный под одну сортировку, нельзя было применить к другой.
Синтаксис
new CursorKey(
string $column,
Sort $direction = Sort::Desc,
?CursorKey $tiebreaker = null,
?string $alias = null,
)Параметры
$column — колонка, задающая порядок. Попадает и в ORDER BY, и в WHERE, поэтому
обязана быть в выборке: именно из неё берётся значение для токена. Для запросов с
соединениями пишут её с алиасом таблицы — p.created_at.
$direction — Sort::Asc или Sort::Desc; по умолчанию Sort::Desc. Задаёт не только
порядок, но и смысл сравнения: Desc означает «строки меньше значения», Asc — «больше».
При движении назад направление инвертируется автоматически.
$tiebreaker — следующий ключ, применяемый когда значения первого совпали. По умолчанию
null. Ниже — почему его почти всегда нужно задать.
$alias — имя, под которым колонка приходит в результате. По умолчанию null — то же,
что $column. Нужен, когда в SELECT колонка переименована (p.created_at AS posted_at):
сравнивать надо по p.created_at, а читать значение — из posted_at.
Пример
$key = new CursorKey('created_at', Sort::Desc,
tiebreaker: new CursorKey('id', Sort::Desc));Тай-брейкер: зачем он обязателен
Курсор — это «строки после этого значения». Если строк с одинаковым значением больше, чем размер страницы, граница перестаёт быть однозначной: база вправе вернуть их в любом порядке, и часть потеряется или повторится.
// плохо: у сотни постов одинаковый 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
Конверт, который отдают все три метода Paginator.
| Поле | Тип | Что это |
|---|---|---|
meta |
PaginationMeta|PaginationMetaCursor |
описание окна |
data |
array |
строки страницы после $mapper, если он был |
PaginationMeta
Мета окна, заданного смещением. Её отдают repo() и array().
| Поле | Тип | Что это |
|---|---|---|
offset |
int |
сколько строк пропущено — ровно то, что передали |
size |
int |
запрошенный размер страницы, а не число пришедших строк |
total |
int |
сколько строк удовлетворяет условиям |
size — это запрос, а не факт: на последней странице строк придёт меньше. Считать
пришедшие нужно по count($result->data).
PaginationMetaCursor
Мета окна, заданного позицией. Её отдаёт cursor().
| Поле | Тип | Что это |
|---|---|---|
size |
int |
запрошенный размер страницы |
cursorPrev |
?string |
токен предыдущей страницы; null на первой |
cursorNext |
?string |
токен следующей; null на последней |
Числа total здесь нет: курсор его не вычисляет, и именно на этом экономится второй
запрос.
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
Размер страницы меньше единицы. Бросают все четыре метода — repo(),
array(), cursor() и Wrapper::paginator().
ValueError: Size must be a positive integer (>= 1), got: 0.Ноль трактуется как ошибка, а не как пустая страница, намеренно: размер страницы почти
всегда приходит из запроса клиента, и ?size=0 — это либо опечатка, либо попытка что-то
нащупать. Пустой ответ на неё выглядел бы как нормальный результат.
InvalidCursorException
Токен нельзя применить. Бросает cursor() в двух случаях.
| Когда | Сообщение |
|---|---|
| токен испорчен или обрезан | Cursor payload is not valid JSON. |
| токен выпущен под другой ключ | 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 — слой целиком