Пагинация и фильтрация

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

Зачем нужно

Возвращать все записи одним запросом нельзя: 100 000 продуктов убьют память сервера, перегрузят сеть и замедлят клиент. Пагинация и фильтрация — основной механизм работы с большими данными в REST API. Правильная реализация влияет на производительность БД, скорость отклика API и UX.

Где используется

  • Любые списочные API-эндпоинты (пользователи, товары, заказы)
  • Таблицы и списки в пользовательском интерфейсе
  • Бесконечная прокрутка (infinite scroll) — cursor-based пагинация
  • Поиск и фильтры в e-commerce, admin-панелях

Виды пагинации

1. Offset-based (страничная)

GET /api/products?page=3&limit=20
GET /api/products?offset=40&limit=20
// Сервер — Express + SQL (условно)
app.get('/api/products', async (req, res) => {
  const page  = Math.max(1, parseInt(req.query.page)  || 1);
  const limit = Math.min(100, parseInt(req.query.limit) || 20);
  const offset = (page - 1) * limit;

  const [items, total] = await Promise.all([
    db.query('SELECT * FROM products LIMIT ? OFFSET ?', [limit, offset]),
    db.query('SELECT COUNT(*) as cnt FROM products'),
  ]);

  res.json({
    data: items,
    pagination: {
      page,
      limit,
      total: total[0].cnt,
      totalPages: Math.ceil(total[0].cnt / limit),
      hasNext: page * limit < total[0].cnt,
      hasPrev: page > 1,
    },
  });
});

2. Cursor-based (для infinite scroll)

GET /api/posts?limit=20
GET /api/posts?cursor=eyJpZCI6NDJ9&limit=20
// Cursor — обычно base64(JSON) от последнего элемента
app.get('/api/posts', async (req, res) => {
  const limit = parseInt(req.query.limit) || 20;
  const cursor = req.query.cursor
    ? JSON.parse(Buffer.from(req.query.cursor, 'base64').toString())
    : null;

  const where = cursor ? `WHERE id < ${cursor.id}` : '';
  const posts = await db.query(
    `SELECT * FROM posts ${where} ORDER BY id DESC LIMIT ?`,
    [limit + 1]
  );

  const hasNext = posts.length > limit;
  if (hasNext) posts.pop();

  const nextCursor = hasNext
    ? Buffer.from(JSON.stringify({ id: posts.at(-1).id })).toString('base64')
    : null;

  res.json({ data: posts, nextCursor });
});

Фильтрация

# Простые фильтры через query-параметры
GET /api/products?category=electronics&in_stock=true&min_price=1000&max_price=50000

# Сортировка (- = убывание)
GET /api/products?sort=-price,name

# Поиск по тексту
GET /api/products?search=ноутбук

# Выбор полей
GET /api/products?fields=id,name,price,image
// Безопасная сборка фильтров
app.get('/api/products', async (req, res) => {
  const { category, min_price, max_price, in_stock, search, sort, fields } = req.query;
  const conditions = ;
  const params = ;

  if (category) { conditions.push('category = ?'); params.push(category); }
  if (min_price) { conditions.push('price >= ?'); params.push(+min_price); }
  if (max_price) { conditions.push('price <= ?'); params.push(+max_price); }
  if (in_stock === 'true') { conditions.push('stock > 0'); }
  if (search) { conditions.push('name LIKE ?'); params.push(`%${search}%`); }

  const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';

  // Whitelist сортировки — защита от SQL injection
  const ALLOWED_SORTS = ['price', '-price', 'name', '-name', 'created_at'];
  const orderField = ALLOWED_SORTS.includes(sort)
    ? sort.replace('-', '') + (sort.startsWith('-') ? ' DESC' : ' ASC')
    : 'id ASC';

  const products = await db.query(
    `SELECT * FROM products ${where} ORDER BY ${orderField}`,
    params
  );
  res.json({ data: products });
});

Частые ошибки

  • Нет ограничения максимального limit — клиент запрашивает limit=999999
  • SQL injection через поле sortsort=price; DROP TABLE products
  • Offset-пагинация на больших таблицах — OFFSET 100000 медленно, лучше cursor
  • Возврат total без пагинации — пересчёт COUNT на каждый запрос дорого

Связанные темы

Ресурсы