Фильтрация и постраничный список
Список заявок DeskQueue пока возвращает все строки. Для трёх учебных записей это удобно, но размер ответа растёт вместе с базой. Пользователь обычно просматривает ближайшие необработанные заявки, а не всю историю. В этом уроке ограничим размер страницы, добавим фильтр состояния и курсор продолжения. Условия станут частью HTTP-договора, а SQL будет выполнять ровно необходимый отбор.
Начальное состояние — stages/13: значения проходят проверку, ошибки имеют устойчивый формат. В stages/14 изменяются функция списка в tickets.js и обработчик GET /api/tickets. Пока записи показываются всем посетителям учебного API; после появления пользователей запрос дополнительно ограничит строки доступным автором. Пагинация не заменяет проверку прав.
Порядок раньше ограничения
LIMIT без определённого порядка не обещает устойчивую первую страницу. PostgreSQL может выбрать разный план чтения, а физическое расположение строки не выражает её место в интерфейсе. Для нашего списка используем ORDER BY id DESC: новая заявка с большим идентификатором идёт раньше. Это не строгая сортировка по времени события, но понятный и однозначный учебный порядок.
Продолжение передаётся параметром before. Клиент получает страницу, запоминает последний идентификатор и просит строки с меньшими номерами. Такой курсор не зависит от числа записей, появившихся перед страницей. Параметр limit ограничивается диапазоном от одного до пятидесяти. Тот, кто передал миллион, получает ошибку ввода, а не право заставить сервер вернуть огромный ответ.
Полная функция списка в этом состоянии принимает уже проверенные значения. before может быть null, а status — null либо разрешённое состояние. Оба возможных отсутствия выражаются явно. Не используем пустую строку как третье значение с неясным смыслом.
import { pool } from './db.js';
export async function listTickets({ limit, before, status, authorId = null }) {
const result = await pool.query(
`SELECT id, title, status, author_id, created_at
FROM tickets
WHERE ($1::integer IS NULL OR id < $1)
AND ($2::text IS NULL OR status = $2)
AND ($3::integer IS NULL OR author_id = $3)
ORDER BY id DESC LIMIT $4`,
[before, status, authorId, limit + 1]
);
const more = result.rows.length > limit;
const items = result.rows.slice(0, limit);
return { items, next: more ? items.at(-1).id : null };
}
Мы запрашиваем на одну строку больше. Дополнительная строка отвечает на вопрос, есть ли продолжение; её не включаем в items. Если вернуть ровно limit строк без дополнительного чтения, полная страница ещё не доказывает существование следующей. Альтернатива — позволить клиенту сделать лишний запрос, но здесь выбираем явный курсор. Ограничение и необходимость порядка объясняются в документации PostgreSQL.
Из запроса в предсказуемую страницу
Для параметров строки запроса нельзя предполагать один тип без проверки. Express может получить массив или объект в зависимости от парсера и повторяющихся параметров. Наш обработчик разрешает только строковые одиночные значения. Отсутствующий limit означает двадцать строк, а отсутствие before — начало списка. Числовой формат проверяется тем же принципом, что идентификатор заявки.
GET /api/tickets?status=open&limit=2
{
"items": [
{"id": 8, "title": "Проверить микрофон", "status": "open"},
{"id": 6, "title": "Заменить кабель проектора", "status": "open"}
],
"next": 6
}
GET /api/tickets?status=open&limit=2&before=6
Это сокращённый ожидаемый пример ответа; реальные объекты также содержат поля схемы. Числа приведены как учебные данные, а не результат выполненного запроса. Второй запрос использует before=6 и сохраняет тот же фильтр status=open. Если клиент забудет фильтр, он начнёт другую последовательность и получит заявки других состояний. Курсор описывает границу внутри выбранного набора, поэтому набор условий следует сохранять вместе с ним.
Новая заявка с номером девять, появившаяся между запросами, не сдвигает уже выбранную границу. Она будет видна при обновлении первой страницы. Но если существующая заявка изменила состояние, состав фильтра изменится. Мы не обещаем снимок базы на всё время просмотра. Такой договор подходит рабочей очереди; для выгрузки с фиксированным составом понадобятся другие механизмы, включая согласованный снимок или сохранённый набор идентификаторов.
Индекс и стоимость чтения
Первичный ключ уже создаёт индекс по id. Для фильтра состояния полезно рассмотреть составной индекс (status, id DESC): сначала он группирует строки по состоянию, затем поддерживает требуемый порядок внутри группы. В архиве отдельный файл migrations/002-list-index.sql добавляет этот индекс. Он является изменением схемы, а не командой, которую нужно повторять при каждом запуске сервера.
CREATE INDEX tickets_status_id_idx ON tickets (status, id DESC);
Индекс ускоряет некоторые чтения, но занимает место и требует обслуживания при записи. Он не гарантирует, что планировщик выберет его для каждой таблицы и каждого фильтра. На крошечном стенде последовательное чтение может быть дешевле. Мы не приводим выдуманные времена выполнения: читатель может сравнить планы своего наполненного стенда через EXPLAIN, сохранив объём данных и условия сравнения. Основные свойства изложены в руководстве PostgreSQL по индексам.
Условие с необязательными фильтрами удобно для небольшого учебного запроса. В приложении с большой нагрузкой разные сочетания могут потребовать отдельных запросов, статистики и проверки планов. Нельзя добавлять индексы механически под каждое поле: это замедляет запись и увеличивает расходы, даже если соответствующий фильтр почти никто не использует.
Также не разрешайте клиенту произвольный текст ORDER BY. Позиционные параметры предназначены для значений, а не SQL-синтаксиса. Если позже появится выбор сортировки, сервер сопоставит несколько известных имён заранее подготовленным фрагментам. В этой главе порядок фиксирован, поэтому запрос не смешивает содержание команды с пользовательскими данными.
Результат урока — ограниченный список, один фильтр состояния и курсор с понятными границами. Мы определили, что происходит при новых и изменённых строках, и отделили логический договор от возможного ускорения индексом. Следующий шаг изменит уже существующую заявку вместе с записью истории, сохраняя согласованность двух таблиц.
Курсор в этом примере читаемый, но клиент не получает право обходить ограничения. Пользователь может вручную передать любой положительный before; сервер всё равно применит фильтр и позднее условие автора. Если курсор содержит несколько частей, его иногда подписывают, чтобы не допустить подмену договора. Такая подпись не заменяет серверное условие прав: даже выданный курсор может быть использован другим пользователем.
Не показываем общий total без отдельного запроса. Полный подсчёт может стоить заметно дороже ограниченной страницы и измениться между чтениями. Для рабочей очереди полезнее своевременно получить небольшой набор, чем каждый раз ждать точное количество всей истории. Если продукту требуется число, определите смысл и период его обновления отдельно. Оно не появится бесплатно из поля next.
Последняя неполная страница получает next: null. Клиент прекращает продолжение, но может обновить начало позже. Пустая страница после ручного большого курсора также является допустимым результатом, если сам параметр корректен. Не превращаем её в 404: операция читает коллекцию, а не требует существования конкретного номера. Так правила одиночного чтения не переносятся на список механически.
Полные файлы учебного проекта. Примеры при подготовке текста не запускались; ответы и вывод в уроке описывают ожидаемый результат читателя.