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

Пагинация

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

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

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

Проблема. Список растёт, а запрос за ним не меняется. Пока в таблице сто строк, SELECT * выглядит безобидно; на десяти тысячах он съедает память воркера, а на миллионе — кладёт процесс. Клиенту при этом весь объём не нужен: он показывает экран.

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

Смещение — «пропусти 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
Лента в приложении, бесконечная прокрутка cursor() пользователь не должен видеть повторы
Публичный API со списками cursor() клиент хранит токен, а не номер
Экспорт, обход всей таблицы cursor() смещение на глубине становится дорогим
Список уже в памяти array() или Wrapper::paginator() соединение не нужно
Смещение, но без номеров страниц repo() дешевле Wrapper: нет арифметики страниц

Практическое правило: номера страниц берут тогда, когда их видно пользователю. Если в интерфейсе только кнопка «ещё», платить COUNT за общее число не за что — а если список живой, курсор ещё и избавляет от повторов на границах.


Справочник

Paginator

Paginator — набор из трёх статических методов, каждый из которых собирает одну страницу и возвращает PaginationResult: конверт из meta (описание окна) и data (строки).

Класс ничего не хранит между вызовами и не требует создания: страница целиком описывается аргументами. Различаются методы источником и способом задать позицию — repo() берёт смещение из базы, array() — из готового списка, cursor() идёт по позиции вместо смещения.

main/PostController.php
use Flytachi\Winter\Ppa\Pagination\Paginator;

$page = Paginator::repo(PostRepository::instance(), size: 20);

return ResponseEntity::ok($page);

Ответ клиенту

json
{
"meta": { "offset": 0, "size": 20, "total": 137 },
"data": [ { "id": 137, "title": "…" }, … ]
}

repo()

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

Самый прямой способ разрезать выборку: метод добавляет к вашему запросу LIMIT и OFFSET, выполняет его, а вторым запросом считает, сколько строк удовлетворяет условиям всего. Это второе число и позволяет клиенту нарисовать «страница 3 из 12» — за него же и платят лишним обращением к базе.

Синтаксис

php
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 меньше единицы:

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

Пример

php
// Условия и сортировка — на репозитории; пагинация только режет
Paginator::repo(
  PostRepository::instance('p')
      ->where(Qb::eq('p.status', 'published'))
      ->orderBy('p.created_at DESC'),
  size: 20,
);

Смещение за концом выборки

php
Paginator::array(range(1, 9), size: 5, offset: 99);
json
{ "meta": { "offset": 99, "size": 5, "total": 9 }, "data": [] }

По total клиент и поймёт, что промахнулся: строк всего девять, а пропустить просили девяносто девять.

Преобразование строк

php
$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, из кеша, из файла, — а отдавать их надо в том же конверте, что и всё остальное, чтобы клиент не различал источники.

Синтаксис

php
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 меньше единицы.

Пример

php
$page = Paginator::array(range(1, 4), size: 2, mapper: fn(int $n) => $n * 10);

Результат

json
{ "meta": { "offset": 0, "size": 2, "total": 4 }, "data": [10, 20] }

cursor()

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

Вместо номера строки метод получает токен, в котором лежит значение сортировочной колонки последней отданной строки. Из этого значения строится WHERE, а из описания ключа — ORDER BY. Ни OFFSET, ни COUNT не выполняются: отсюда и устойчивость к вставкам, и постоянная стоимость страницы независимо от глубины.

Плата — отсутствие номеров: перейти сразу на седьмую страницу нельзя, только вперёд и назад.

Синтаксис

php
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 — если токен испорчен или выпущен под другой ключ сортировки.

Пример

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);

Результат

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

json
// первая страница
{ "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()

Собирает страницу по её номеру.

Синтаксис

php
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 меньше единицы.

Пример

php
Wrapper::paginator(range(1, 25), limit: 10, page: 2);

Результат

json
{
"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, а не одна пустая:

json
{"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, либо привести номер к диапазону до вызова.

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

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

CursorKey

CursorKey — описание позиции для cursor(): по какой колонке идти, в какую сторону и чем разрешать ничью.

Один объект отвечает сразу за три вещи. Из него строится ORDER BY запроса; из него же берётся, какое значение положить в токен; и его форма подписывается, чтобы токен, выпущенный под одну сортировку, нельзя было применить к другой.

Синтаксис

php
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.

Пример

php
$key = new CursorKey('created_at', Sort::Desc,
  tiebreaker: new CursorKey('id', Sort::Desc));

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

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

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

Конверт, который отдают все три метода 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:

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

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

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

Ошибки

ValueError

Размер страницы меньше единицы. Бросают все четыре метода — repo(), array(), cursor() и Wrapper::paginator().

text
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.

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

Как обработать

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 — решает приложение.

Дальше

  • Репозитории — как собрать выборку, которую вы пагинируете
  • Сущности — объекты, в которые гидрируются строки страницы
  • Пул соединений — почему второй запрос за COUNT стоит внимания
  • PPA — слой целиком