---
title: "Пагинация и сортировка"
description: "ent пагинация на практике: Limit и Offset, честная математика страниц через Count, курсорная пагинация по id и составному ключу, сортировка Order без сюрпризов."
course: "ent: графовая ORM с кодогенерацией"
url: "https://golangmaster.ru/courses/ent/lessons/12-paginaciya/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Пагинация и сортировка

*Курс «ent: графовая ORM с кодогенерацией», урок 12 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/ent/lessons/12-paginaciya/*

Десятый урок научил отбирать строки предикатами, одиннадцатый — привозить соседей With-вызовами, но оба возвращали выборку целиком: страницей она ещё не стала. Сегодня — ent пагинация и сортировка: пара Limit и Offset, которая живёт в билдере с самого начала, честная математика страниц через Count из девятого урока, цена глубоких offset-ов и их дрейфа, а затем курсорная пагинация — keyset-подход, где страница задаётся не номером, а границей. Отдельный сюжет — order ent во всех подробностях: несколько ent.Asc и ent.Desc в одном запросе, проблема равных значений без тай-брейкера и обещание девятого урока про упорядочивание по числу соседей-рёбер. Если вы гуглили ent limit offset и находили только фрагменты — здесь механика собрана целиком на каноне курса: анна, борис, вера и их посты.

Маршрут урока — от наивной страницы к промышленной. Первый сюжет — ent пагинация в механике Limit и Offset: что именно они пишут в билдер и в SQL, и почему страница — это всегда тройка «сортировка, окно, исполнение». Второй — order ent в развёрнутом виде: несколько полей за один вызов, стабильность порядка и тай-брейкер по id, без которого страницы начинают дублировать и терять строки. Третий — подсчёт страниц через Count и форма пагинационного ответа, которую ждёт любой фронтенд. Четвёртый — цена глубоких offset-ов: сколько строк база читает и выбрасывает, и почему данные, изменившиеся между запросами, сдвигают окна. Пятый — курсорная пагинация: страница как граница, курсор по первичному ключу и составной курсор по паре «время, идентификатор». Шестой — обещание девятого урока: упорядочивание по числу соседей-рёбер через Modify. Финиш — таблица выбора между offset и курсором. Данные прежние: анна (26 лет, два поста — «про рёбра» и «черновик»), борис (19), вера (17), группа go-ninja.

### ent limit offset: что именно делает пара Limit и Offset

С девятого урока вы знаете, что Limit(n) и Offset(k) копятся в билдере так же, как предикаты Where, — и исполняются только вместе с All(ctx). Теперь достроим механику страницы целиком. В SQL запрос с пагинацией состоит из трёх обязательных частей: ORDER BY задаёт порядок строк, LIMIT отрезает окно, OFFSET пропускает начало. Уберёте сортировку — получите окно поверх недетерминированного порядка, и на каждой перезагрузке страницы состав может гулять; забудете OFFSET — каждый раз вернётся первая порция. ent тут без волшебства: Limit и Offset — прозрачные методы, которые рендерятся в одноимённые клаузы SQL, и за содержимое окна отвечает ваша связка с Order. Нюанс из девятого урока вспоминаем одной строкой: OFFSET без LIMIT в SQL не живёт, и ent при заданном offset сам подставляет лимит-максимум — это было в генераторе, страница вам его повторять не будет. Отсюда рабочее правило ent limit offset: любую страницу собирайте полной тройкой — Order, Limit, Offset — и никогда не полагайтесь на «естественный» порядок таблицы: его не существует ни в одной СУБД.

### Первая страница пользователей

```go
// страница 0: первые десять пользователей по убыванию возраста;
// при равном возрасте — по возрастанию id, чтобы порядок был строгим:
users, err := client.User.Query().
        Order(ent.Desc(user.FieldAge), ent.Asc(user.FieldID)).
        Limit(10).
        Offset(0).
        All(ctx)
if err != nil {
        return err
}

// страница 1 — тот же порядок, окно сдвинуто:
page2, err := client.User.Query().
        Order(ent.Desc(user.FieldAge), ent.Asc(user.FieldID)).
        Limit(10).
        Offset(10).
        All(ctx)
```

Оба вызова — один и тот же билдер с разными Offsets: предикатов нет, значит выборка — вся таблица users. Связка с одиннадцатым уроком прямая: With-вызовы и пагинация свободно смешиваются в одной цепочке — загрузить соседей можно и для окна из десяти строк, тариф жадной загрузки при этом не меняется, один запрос на ребро. Notice-момент: Offset(0) можно и не писать — дефолт нулевой, но явный ноль в коде страницы читается лучше.

### Order: несколько полей и тай-брейкер

Сортировка в ent — метод Order на query-билдере, и его аргументы — не строки, а хелперы корневого пакета: ent.Asc и ent.Desc. Каждый принимает вариативный список констант-колонок, поэтому два вида записи эквивалентны: Order(ent.Desc(user.FieldAge), ent.Asc(user.FieldID)) и Order(ent.Desc(user.FieldAge, user.FieldID)) — разве что направление во втором случае общее, а в первом своё на поле. Несколько вызовов Order на одном билдере складываются: последний добавляет поля в конец ORDER BY, а не затирает прежние — в этом отличие от перезаписи With-полей из одиннадцатого урока. Валидация та же, что вы видели у ent.Desc в девятом: выдуманное имя колонки отвергается до базы, потому что константы типа user.FieldName — это ключи ValidColumn, а не свободный текст. Теперь главное — тай-брейкер. ORDER BY age DESC для трёх пользователей вернёт однозначный порядок, а для тысячи — уже нет: при равных возрастах база вольна вернуть строки в любой последовательности, и она действительно меняет её от запроса к запросу. На одной странице это незаметно, на пагинации — беда: строка с возрастом 26 может попасть и в страницу 0, и в страницу 2, а какая-то — не попасть никуда. Лечение простое и обязательное: последним полем сортировки ставьте уникальную колонку — на каноне курса id: Order(ent.Desc(user.FieldAge), ent.Asc(user.FieldID)). Теперь каждая строка занимает строгое место, и окно OFFSET не может вернуть её дважды.

### Проверка: как ляжет ORDER BY

**Вопрос:** Запрос: Order(ent.Desc(user.FieldAge), ent.Asc(user.FieldID)).Limit(10). Что именно гарантирует такая сортировка?

- ✅ Возраст по убыванию; внутри равных возрастов — id по возрастанию; каждая строка имеет единственно возможное место
- Порядок полностью определяется возрастом; id нужен только для скорости выборки
- Поля сортируются независимо: сначала все строки по возрасту, отдельно — по id
- ent сам добавит тай-брейкер по id, даже если его не указать

ORDER BY применяется слева направо: возраст DESC — главный ключ, id ASC — тай-брейкер внутри равных возрастов. Пара «неуникальное поле + уникальное поле» делает порядок строгим, и пагинация перестаёт дублировать и терять строки. Автоматического тай-брейкера у ent нет: если вы сортируете только по age, база свободна в порядке равных строк — это контракт SQL, а не недоработка билдера.

### Математика страниц: Count

Пользовательский интерфейс хочет больше, чем просто окно: «страница 2 из 7», «всего 63». Число страниц добывается тем же запросом, что и данные, минус окно: Count(ctx) из девятого урока — он переиспользует весь WHERE вашего билдера и вычищает ORDER BY, потому что сортировка внутри счётного запроса некоторым базам запрещена. Дальше арифметика: всего страниц — ceil(total / limit), последняя страница — та, где offset + limit >= total. Два практических замечания. Первое: Count — это отдельный поход в базу, и на больших таблицах он не бесплатен; для «показать ещё» с курсором он вообще не нужен, и ниже мы этим воспользуемся. Второе: между запросом Count и запросом страницы данные могли измениться — total честный только на момент своего запроса, и это норма всей клиент-серверной жизни, а не особенность ent. Собственно подсчёт не требует от билдера ничего нового: тот же Query, те же предикаты из десятого урока, другой терминальный метод. Комбинируя Count, Limit и Offset, вы получаете классический «нумерованный» пагинатор, которого достаточно для большинства админок и каталогов.

### Нумерованный пагинатор на Count

```go
// параметры страницы приходят снаружи:
const (
        perPage = 10
        page    = 2 // третья страница, считая с нуля
)

// 1. сколько всего строк под фильтр (WHERE переиспользуется):
total, err := client.User.Query().
        Where(user.AgeGTE(18)).
        Count(ctx)
if err != nil {
        return err
}

// 2. само окно — те же предикаты, плюс сортировка:
items, err := client.User.Query().
        Where(user.AgeGTE(18)).
        Order(ent.Desc(user.FieldAge), ent.Asc(user.FieldID)).
        Limit(perPage).
        Offset(page * perPage).
        All(ctx)

pages := (total + perPage - 1) / perPage // ceil без математики
```

Оба запроса несут один и тот же Where — фильтр согласован между счётом и содержимым. Целочисленный ceil (total + perPage - 1) / perPage — идиома без вызовов math. На каноне курса: взрослых двое — анна и борис, вере 17; при perPage = 1 total равен двум и pages тоже двум. Для пустого фильтра Count вернёт 0 — страниц нет, но и ошибки нет: «пусто» — данные, а не сбой.

### Что кладут в пагинационный ответ

- items — срез сущностей страницы; сюда же после запроса «пристёгиваются» рёбра With-вызовами из одиннадцатого урока.
- total — общее число строк под фильтр; добывается Count-ом с тем же WHERE; для курсорной пагинации часто опускается.
- page и perPage — эхо входных параметров; фронтенд сверяет их со своими стейтами, не доверяя молчанию.
- pages — ceil(total / perPage); для курсоров вместо него отдают hasNext.
- next и prev — ссылки или курсоры следующей и предыдущей страницы; курсор обычно кодирует сортировочный ключ последней строки.

### Глубокие страницы: чем платит OFFSET

У нумерованной пагинации есть цена, и растёт она линейно с номером страницы. OFFSET k в SQL означает: прочитать первые k + limit строк по порядку сортировки и выбросить первые k. Не «перейти» — прочитать и выбросить: база обязана пройти начало выборки каждый раз, потому что окно определяется позиционно. OFFSET 1990 при LIMIT 10 — две тысячи прочитанных строк, десять отданных; OFFSET 999900 — почти миллион. Индекс по сортировочному полю превращает это из «прочитать таблицу» в «пройти по индексу», но константа всё равно остаётся: глубина страницы — это время. Вторая беда тоньше — дрейф. Между запросом страницы 0 и страницы 1 кто-то вставил нового пользователя — все окна сместились на одну строку: анна, стоявшая последней на странице 0, появится снова первой на странице 1. Удалили строку — часть строк страницы 1 не попадёт ни в одну страницу. Тай-брейкер из прошлого блока делает порядок строгим, но не отменяет позиционности: OFFSET считает строки, а не значения. Поэтому в нагруженных списках — ленты, фиды, бесконечные прокрутки — нумерацию страниц заменяют курсорами, и это следующий сюжет. Если вы уже читали курс GORM, его семнадцатый урок показывал ту же развилку на другой ORM: синтаксис меняется, дилемма — нет.

### Сколько строк прочитает база на глубокой странице

```go
// страница 199 при perPage = 10 (offset = 1990):
users, err := client.User.Query().
        Order(ent.Asc(user.FieldID)).
        Limit(10).
        Offset(1990).
        All(ctx)

// в таблице 300 000 пользователей.
// сколько строк база прочитает по порядку, чтобы отдать 10?
```

**Что выведет программа?**

- 10: OFFSET работает как указатель в файле — прыгает сразу к позиции 1990
- ✅ 2000: прочитать и выбросить первые 1990, отдать последние 10
- 300 000: любая пагинация читает таблицу целиком
- 1990: ent сам уменьшит сканирование до размера offset

OFFSET k — это «прочитать k + limit строк и выбросить k»: окно позиционное, указателей в SQL нет. Индекс по id сокращает стоимость одной строки, но не число посещённых строк — 2000 при любом размере таблицы сверх 2000. Вариант «вся таблица» был бы прав без индекса и без сортировки по ключу; вариант «ent уменьшит» — фикция: билдер рендерит LIMIT и OFFSET как есть. Именно поэтому фиды не дают пользователю «страницу 1990» — они ведут его курсором.

### Курсорная пагинация: страница как граница

Курсорная (она же keyset) пагинация меняет саму постановку вопроса. Вместо «дай строки 20–29» клиент просит «дай следующие 10 строк после вот этой» — и «вот эта» кодируется значением сортировочного ключа последней полученной строки. Для сортировки по возрастанию id страница — это WHERE id > lastID в паре с ORDER BY id ASC и LIMIT: предикат IDGT из каталога десятого урока. Вся конструкция опирается на индекс по первичному ключу, поэтому каждый переход между страницами стоит одинаково — глубина страницы перестала существовать. Дрейф тоже исчезает: строки вставляются с растущими id, и новая запись не сдвигает чужие окна — она либо попадёт в конец ленты, либо нет, но уже выданные страницы не перетасуются. Плата за это — два отказа. Первый: нельзя прыгнуть на произвольную страницу, потому что «страница 7» не определена без позиции; курсорные интерфейсы — это «показать ещё» и стрелки, а не нумерованные списки. Второй: без Count вы не знаете общего числа строк — либо считайте отдельным запросом, либо честно скажите дизайнеру, что «всего 63» в бесконечной ленте никому не нужно. Курсор — это просто значение, которое клиент вернёт вам в следующем запросе; сериализуете вы его в base64 или отдаёте голым числом — ваше API-решение, ent ограничивается предикатом.

### Курсор по первичному ключу

```go
// первая страница: обычное окно без предиката:
page1, err := client.User.Query().
        Order(ent.Asc(user.FieldID)).
        Limit(10).
        All(ctx)

// курсор — id последней строки страницы:
cursor := page1[len(page1)-1].ID

// следующая страница: всё после границы, окно того же размера:
page2, err := client.User.Query().
        Where(user.IDGT(cursor)).
        Order(ent.Asc(user.FieldID)).
        Limit(10).
        All(ctx)

// трюк «limit+1»: запросить 11, если вернулось 11 —
// hasNext = true, а одиннадцатую строку не отдаём
```

IDGT — обычный сгенерированный предикат where.go, тот же класс, что AgeGTE из десятого урока: просто здесь он сравнивает первичный ключ. Запрос «следующей страницы» настолько же дёшев, насколько дёшев lookup по индексу, — и одинаков на любой глубине. Трюк limit+1 решает вопрос hasNext без Count: запросите на одну строку больше, увидели переполнение — есть продолжение. Канон: анна id 1, борис id 2, вера id 3 — курсор 2 вернёт страницу с одной верой при LIMIT 10.

### Курсор по не-id колонке: составной ключ

Сортировать только по id — редкая роскошь: ленты обычно упорядочены по времени. И здесь подстерегает ловушка, которая ломает наивные курсоры: created_at не уникален. Два поста, написанные в одну секунду, — норма; курсор вида «после 12:00:00» с WHERE created_at > t молча выбросит второй пост с тем же временем. Лечение — составной курсор: сортируем по паре (created_at, id) и границей считаем тоже пару. Условие «строка строго после границы» читается как: время меньше по значению ИЛИ время то же, но id больше — в предикатах ent это user.Or(user.CreatedAtLT(cursor.Time), user.And(user.CreatedAtEqual(cursor.Time), user.IDGT(cursor.ID))). Запись многословная, но логика — ровно сравнение кортежей, как в математике: (t1, id1) > (t2, id2) означает t1 > t2 ИЛИ (t1 = t2 И id1 > id2). База такой WHERE исполняет по индексу (created_at, id) — составной индекс из двадцатого урока для этого и рождается. Ошибка «взять только время» проявляет себя не сразу: пока у вас одна строка в секунду, всё работает; под нагрузкой посты начинают теряться. Запомните правило: курсор уникален тогда и только тогда, когда сортировочный ключ уникален; не уникален — достраивайте состав.

### Проверка: зачем в курсоре второе поле

**Вопрос:** Лента отсортирована по created_at DESC. Почему одного предиката CreatedAtLT(t) для следующей страницы мало?

- ✅ Строки с тем же created_at, что у границы, и большим id выпадут из обеих страниц
- CreatedAtLT не принимает time.Time — нужен составной предикат
- Составной курсор нужен только базам без индексов
- Одной колонки достаточно: id всё равно участвует в выборке автоматически

Курсор «после t» строго отсекает всё с временем меньшим ИЛИ равным границе; строки с временем ровно t уезжают целиком, включая те, что ещё не были показаны. Составное условие — Or(CreatedAtLT, And(CreatedAtEqual, IDGT)) — пропускает ровно показанную часть: строки с тем же временем и большим id попадут в следующую страницу. Никакой magic в ent тут нет — это чистая логика кортежей, и собирать её приходится вам.

### Составной курсор на (created_at, id)

```go
// лента постов анны по убыванию времени:
page1, err := client.Post.Query().
        Order(ent.Desc(post.FieldCreatedAt), ent.Desc(post.FieldID)).
        Limit(5).
        All(ctx)

// курсор — кортеж последней строки:
last := page1[len(page1)-1]

// следующая страница: строго «после» кортежа:
page2, err := client.Post.Query().
        Where(post.Or(
                post.CreatedAtLT(last.CreatedAt),
                post.And(
                        post.CreatedAtEqual(last.CreatedAt),
                        post.IDGT(last.ID),
                ),
        )).
        Order(ent.Desc(post.FieldCreatedAt), ent.Desc(post.FieldID)).
        Limit(5).
        All(ctx)
```

Сортировка и курсор зеркалят друг друга: DESC по обоим полям — и предикат «строго после» по тем же полям. Направления сортировки и знаки сравнения обязаны совпадать: поменяете порядок на ASC, а курсор оставите — страницы поедут в другую сторону, и границы перестанут значить то, что вы думали. Число запросов на страницу не изменилось: один SELECT на данные, плюс отдельный COUNT, если UI настаивает на «всего».

### Сортировка по числу соседей: обещание девятого урока

Девятый урок оставил вам обещание: покажем, как упорядочить пользователей по числу их постов. Готового API у ent для этого нет — Order принимает только колонки, а «число соседей» колонкой не является. Два рабочих пути. Первый — денормализация: поле posts_count в схеме, которое поддерживают хуки или групповые обновления; сортировка превращается в обычный Order(ent.Desc(user.FieldPostsCount)) и достаётся бесплатно при любом объёме, но требует дисциплины — счётчик надо не забывать двигать при создании и удалении постов, за этим к семнадцатому уроку. Второй путь — Modify из feature-флага sql/modifier (флаги включались в седьмом уроке): он даёт доступ к финальному SELECT-у через sql.Selector, где можно докрутить JOIN, GROUP BY и выражение сортировки. Это уже почти ручной SQL — с типобезопасностью билдера на корневом запросе, но с полной свободой внутри модификатора. Такой запрос стоит держать под присмотром: план с GROUP BY по большой таблице дороже любого курсора, и для «топа авторов» честнее посчитать денормализованно. Показываю механику — решение о применении принимайте по своему объёму данных.

### Modify: топ авторов по числу постов

```go
// требует флага sql/modifier (седьмой урок):
users, err := client.User.Query().
        Modify(func(s *sql.Selector) {
                s.LeftJoin(sql.Table("posts")).
                        On(s.C(user.FieldID), post.FieldAuthorID).
                        GroupBy(s.C(user.FieldID)).
                        OrderBy(sql.Desc(sql.Count(post.FieldID)))
        }).
        All(ctx)

// SQL: SELECT users.* FROM users
//      LEFT JOIN posts ON users.id = posts.author_id
//      GROUP BY users.id
//      ORDER BY COUNT(posts.id) DESC
```

Modify получает финальный селектор — тот самый, что обычно рендерится из билдера. LeftJoin с On по колонке ребра собирает пары «пользователь — его посты», GroupBy сворачивает их в пользователя, OrderBy по COUNT расставляет по активности. У веры ноль постов — LEFT JOIN гарантирует, что она останется в списке с нулём, в отличие от INNER JOIN. GROUP BY по первичному ключу легален в PostgreSQL (функциональная зависимость) и MySQL; всё же лишнее в SELECT ent сам вычищать не станет — проверяйте план запроса на реальном объёме.

### Offset против курсора: сводка выбора

| Критерий | Limit + Offset | Курсор (keyset) |
| --- | --- | --- |
| Произвольный переход | да: страница N вычисляется offset = N * perPage | нет: только «следующая» и «предыдущая» |
| Стоимость глубокой страницы | растёт с offset: база читает и выбрасывает offset + limit строк | константная: предикат по индексу, глубина не важна |
| Дрейф при вставках и удалениях | окна сдвигаются: строки дублируются и теряются между страницами | новые строки не сдвигают уже выданные страницы |
| Общее число строк | из коробки через Count с тем же WHERE | только отдельным Count или никак |
| Требования к порядку | обязателен тай-брейкер по уникальной колонке | курсор уникален ровно настолько, насколько уникален ключ |
| Типовые применения | админки, каталоги, отчёты с номерами страниц | ленты, фиды, бесконечные прокрутки, API для мобильных клиентов |

### Проверка: выбрать стратегию

**Вопрос:** Мобильный клиент бесконечно скроллит ленту постов, вставки идут каждую секунду, номера страниц в дизайне нет. Что выбрать?

- ✅ Курсор по составному ключу (created_at, id): константная цена страницы и нет дрейфа
- Limit и Offset: номера страниц всё равно понадобятся позже
- Offset без сортировки: база и так отдаёт строки в стабильном порядке
- Count перед каждой страницей и Offset: total нужен для hasNext

Это учебниковый сценарий keyset-пагинации: бесконечная прокрутка, высокая частота вставок, без нумерации. Offset здесь страдает дважды — дрейфом на каждую вставку и линейным ростом стоимости с глубиной прокрутки. Count для hasNext не нужен — есть трюк limit+1. А «стабильного порядка базы» не существует: без Order состав страниц недетерминирован, и это было первым правилом урока.

### Соберите курсорную страницу

Впишите сортировку по убыванию id, предикат курсора, размер окна и подсчёт страниц.

```go
// страница после курсора lastID:
page, err := client.User.Query().
        Where(user.___(lastID)). // строго после границы
        Order(ent.___(user.FieldID)). // по возрастанию id
        ___(10). // окно страницы
        All(ctx)

// а общее число строк под фильтр даёт:
total, err := client.User.Query().___(ctx)
```

Подсказка: Первый пропуск — предикат «больше чем» для первичного ключа из where.go; второй — хелпер возрастающей сортировки корневого пакета; третий — размер окна; четвёртый — терминальный метод-счётчик из девятого урока.

Правильные ответы в пропусках: `IDGT`, `Asc`, `Limit`, `Count`

### Путь одной курсорной страницы

Соберите жизненный цикл курсорной страницы ленты: что происходит и в каком порядке от запроса клиента до готового ответа.

Правильный порядок строк:

1. `клиент прислал курсор — значение сортировочного ключа последней строки прошлой страницы`
2. `предикат собран: Or(CreatedAtLT, And(CreatedAtEqual, IDGT)) — «строго после кортежа»`
3. `Order(ent.Desc(post.FieldCreatedAt), ent.Desc(post.FieldID)) — порядок зеркалит курсор и содержит уникальный тай-брейкер`
4. `Limit(10) отрезает окно; внутри SQL нет OFFSET — окно задаётся предикатом`
5. `All(ctx) исполняет один SELECT по индексу (created_at, id) — цена не зависит от глубины`
6. `из последней строки ответа извлечён новый курсор и отдан клиенту вместе с hasNext (трюк limit+1)`

> **Как проверять себя в этом уроке.** Runner-ов в курсе ent нет, и песочницы не будет и здесь: пагинации нужны настоящий драйвер и база — правило всего курса. План проверки: возьмите базу из домашек восьмого урока, включите лог SQL драйвера и исполните каждую страницу из урока. Считайте прочитанные строки на глубоком OFFSET — сравните план запроса страницы 0 и страницы 199; продублируйте дрейф: вставьте пользователя между двумя запросами страниц и найдите его дважды; соберите составной курсор по (created_at, id) и убедитесь, что два поста с одним временем не теряются. Каждый шаг должен быть виден в логе драйвера — ent ничего не делает за кадром.

### Что дальше

Вы умеете резать выборку на страницы обоими способами: нумерованные окна с честной математикой Count и keyset-курсоры, которые не боятся ни глубины, ни вставок; сортировка теперь в вашем распоряжении целиком — от мультиполей с тай-брейкером до сортировки по числу соседей. Но всё, что вы вернули клиенту, он пока может только читать: строки было нечем менять. Двенадцатый урок закрыл чтение, тринадцатый — [13-update](/courses/ent/lessons/13-update/) — откроет запись: UpdateOneID с Set-мутаторами, массовое обновление по предикатам и возврат обновлённой сущности, а в придачу и upsert-развилка, чьи азы лежат в [восьмом уроке](/courses/ent/lessons/08-sozdanie/). Домашнее задание этого урока — перевести свой список пользователей с «всё на одной странице» на обе стратегии и почувствовать разницу в логе драйвера. Если понадобится вспомнить каталог предикатов для курсоров — он в [десятом уроке](/courses/ent/lessons/10-predikaty/), а механика жадной загрузки соседей на странице — в одиннадцатом. Полный план из 25 уроков — на [странице курса](/courses/ent/).

### Карточки: пагинация и сортировка

- **Минимальная честная страница в ent** — Тройка Order + Limit + Offset: сортировка задаёт строгий порядок (последним полем — уникальный id), LIMIT отрезает окно, OFFSET пропускает начало. OFFSET без LIMIT в SQL не живёт — ent сам подставит лимит-максимум. Без Order состав страницы недетерминирован.
- **Зачем тай-брейкер в сортировке** — ORDER BY по неуникальной колонке оставляет базу свободной в порядке равных строк — пагинация начинает дублировать и терять строки между страницами. Правило: Order(ent.Desc(user.FieldAge), ent.Asc(user.FieldID)) — последний ключ всегда уникален. ent тай-брейкер автоматически не добавляет.
- **Как посчитать страницы** — Count(ctx) с тем же WHERE: он переиспользует предикаты билдера и вычищает ORDER BY (сортировка в COUNT запрещена некоторым базам). pages = ceil(total / perPage) = (total + perPage - 1) / perPage. Count — отдельный запрос; между ним и страницей данные могли измениться.
- **Чем плох глубокий OFFSET** — OFFSET k читает и выбрасывает k + limit строк — стоимость растёт линейно с номером страницы, индекс лишь удешевляет каждую строку. Плюс дрейф: вставки и удаления между запросами сдвигают позиционные окна — строки дублируются или пропадают. Для лент это фатально.
- **Курсорная (keyset) пагинация** — Страница — это «после границы», а не «номер N»: Where(user.IDGT(cursor)) + Order(ent.Asc(user.FieldID)) + Limit(10). Цена константная (по индексу), дрейфа нет, но нет и произвольного перехода, а total без отдельного Count недоступен. hasNext — трюком limit+1.
- **Составной курсор по (created_at, id)** — Курсор уникален, только если уникален сортировочный ключ. Для времени — Or(CreatedAtLT(t), And(CreatedAtEqual(t), IDGT(id))) при сортировке Desc по обоим полям: сравнение кортежей. Знаки сравнения обязаны зеркалить направления ORDER BY. Индекс (created_at, id) — из двадцатого урока.

## Коротко: что запомнить

- Пагинация в ent — это тройка Order, Limit, Offset поверх любого билдера: предикаты десятого урока фильтруют, окно отрезает LIMIT, порядок задаёт ORDER BY. Ent limit offset рендерится в одноимённые SQL-клаузы без магии; OFFSET без LIMIT ent достраивает лимитом-максимумом.
- Сортировка order ent: ent.Asc и ent.Desc принимают вариативные списки колонок, несколько вызовов Order складываются в конец ORDER BY. Валидация колонок происходит до базы. Тай-брейкер обязателен: без уникального поля в конце строки дублируются и теряются между страницами.
- Подсчёт страниц — Count(ctx) с тем же WHERE: предикаты переиспользуются, ORDER BY вычищается. pages = ceil(total / perPage). Count — отдельный запрос, честный только на свой момент времени.
- Глубокие offset-ы читают и выбрасывают offset + limit строк — стоимость растёт линейно с номером страницы; вставки и удаления сдвигают позиционные окна (дрейф). Курсорная пагинация убирает обе беды ценой отказа от произвольного перехода и total.
- Курсор — значение сортировочного ключа последней строки: Where(user.IDGT(cursor)) + Order + Limit. hasNext добывается трюком limit+1. Курсор уникален ровно настолько, насколько уникален ключ сортировки; для created_at нужен составной курсор Or(CreatedAtLT, And(CreatedAtEqual, IDGT)).
- Сортировка по числу соседей-рёбер готового API не имеет: либо денормализованный счётчик, поддерживаемый хуками, либо Modify из флага sql/modifier с LeftJoin, GroupBy и OrderBy(sql.Desc(sql.Count(...))).
- Выбор стратегии: нумерованные страницы — для админок и каталогов, курсоры — для лент и бесконечных прокруток. В GORM та же дилемма разобрана в его семнадцатом уроке: синтаксис меняется, механика — нет.
- Пагинация свободно комбинируется с жадной загрузкой из одиннадцатого урока: With-вызовы на странице из десяти строк стоят столько же запросов, сколько на всей таблице, — один на ребро.

## Домашнее задание

Работайте на базе из восьмого урока — анна, борис, вера, их посты и группа go-ninja — и включите лог SQL драйвера. Первое: соберите нумерованный пагинатор по возрасту с тай-брейкером по id, perPage = 1, и проверьте математику страниц: adults — двое, значит pages = 2; пройдите обе страницы и убедитесь, что ни одна строка не повторилась. Второе: умышленно уберите тай-брейкер и поищите недетерминизм — на трёх пользователях его может не быть, тогда отсортируйте по role (у анны админ, у бориса и веры юзер) и посмотрите в лог, что именно база вернула при равных значениях. Третье: смоделируйте дрейф — прочитайте страницу 0 нумерованной пагинации, вставьте пользователя с возрастом больше всех, прочитайте страницу 1 и найдите дубль; затем повторите то же на курсоре по id и убедитесь, что уже выданные страницы не изменились. Четвёртое: соберите составной курсор по постам (created_at, id) — создайте два поста с одним временем и проверьте, что оба доезжают по разные стороны границы; сверьте с логом, что сортировка и предикат зеркальны. Пятое: посчитайте соотношение прочитанных и отданных строк на OFFSET 2 при трёх пользователях, затем доведите таблицу до хотя бы сотни строк CreateBulk-ом из восьмого урока и сравните план запроса страницы 0 и страницы 9. Вопрос на подумать: почему курсор по неуникальному полю «молча» теряет строки, и какие ещё пары полей на нашей схеме годятся в составной курсор?

## Вопросы и ответы по уроку

### Можно ли в ent сделать пагинацию без сортировки?

Технически — да: Limit и Offset работают и без Order. Фактически — нет: без ORDER BY порядок строк не гарантирован ничем, и страницы будут дублировать и терять строки. Единственная безопасная сортировка без явного поля — по первичному ключу, потому что он уникален и индексирован; всё остальное требует тай-брейкера.

### Как совместить курсорную пагинацию с фильтрами и жадной загрузкой?

Свободно: предикат курсора — обычный предикат, он складывается с Where из десятого урока через And, а With-вызовы одиннадцатого урока работают на любой выборке, включая окно страницы. Тариф не меняется: один запрос на данные плюс один на каждое запрошенное ребро. Следите только за тем, чтобы фильтры не менялись между страницами — иначе курсор отсчитывает строки в другой выборке.

### Нужно ли всегда отдавать total и pages?

Нет, и часто это правильно. Count на больших таблицах — отдельный полноценный запрос, а в лентах «всего 63 214 постов» не имеет операционной ценности: данные изменились раньше, чем вы дочитали число. Нумерованные интерфейсы (админка, отчёт) без total не живут; курсорные интерфейсы обходятся hasNext через трюк limit+1. Решение принимает UX, а не ORM.
